Public: anyone can read this; the crew can change it.
Passkeys, part 3: the claim link and the everyday account take a passkey, not a password #2024
Check · Review · Land · Live done — Landed and live in production.
The claim link (`/claim`) and the everyday account (`/settings/everyday`) now make their account with a passkey and no password, the same way `/signup` does.
shipped production is serving it deploy 5431a5f
The claim link (/claim) and the everyday account (/settings/everyday) now make their account with a passkey and never a password, the same way /signup does. The type check is clean and the affected suites pass locally; the forge's full check has not yet run on the latest push.
How it works
src/web/passkey-ceremony.tsis the server side both pages share. It issues a one-time challenge, adds the hidden fields to the form, and checks the key that comes back. It refuses a key another account already holds.- In
src/web/passkey-client.ts, thepasskey-makebutton asks the page's options route for a challenge, makes the key, puts it in the hidden fields and submits the form normally. - There are two new same-origin options routes, and neither writes anything:
- POST /claim/options answers only for a live link and a name that is valid and free. It is rate-limited like sign-in.
- POST /settings/everyday/options answers only for a signed-in admin, on a browser session, with no everyday account yet.
- Neither page reads a
passwordfield any more. A post without a valid key is refused ("Passwords are not taken here.") before anything is written, and the claim link is not spent on it. - The claim link stays single-use. It is spent before the account is made; if attaching the key then fails, the new account is deleted and the link is restored. A spent link gets no new challenge (410).
- The home and settings pages load the passkey script only when the everyday form is on them.
- The note under the passkey button is a plain
.passkey-note. CSS gives it the error look only when it has text, so a page with nothing wrong carries noerror-note. The first check run failedweb.test.tson exactly that, from an emptyerror-noteon the admin's home.
Tests
test/helpers/passkey.tsis a software authenticator pluspasskeyFields(base, optionsPath, who), which does what the button does.signup-passkeynow uses it instead of its own copy.- The issue's suites now make their people with it:
claim-link,everyday,first-login,spawn-first-bootandspawn-child-apps. So doclient-home,client-listandspawn-keys, which also post to/claim. - Two old tests were wrong under the new rule and were replaced: "a weak password is refused" in
spawn-first-boot, and "a weak password or a taken name makes nothing" ineveryday. They now check that a password with no passkey is refused, with nothing written and the link unspent. - New assertions: the made account has one passkey and no password, a spent link gives no second challenge, and a non-admin is refused everyday options.
new-forgeandspawn-anonare unchanged: they never claim, and their passwords belong to the parent forge's accounts.
Not done: accounts made before this change keep their passwords. Without JavaScript, both pages say a passkey needs it instead of falling back to a password.
To try it: spawn a forge and claim it through its link with a passkey. Then, as that admin, use "Make it with a passkey" on the home page.
🤖 Generated with Claude Code
The security side is sound. Both options routes check same-origin first. /claim/options is rate-limited on the socket address (clientAddress) and only answers for a live link with a name that is valid and free. /settings/everyday/options checks it is a session, an admin, and an admin with no everyday account yet. Bodies are capped by boundedBody, which counts the bytes it reads. Challenges come from their own bounded one-time store (make), and a key another account already holds is refused before any account is made. discardFreshUser only deletes a user with no password, and unburnClaim makes the rollback safe. The terminal has no claim or everyday path, so there is no second door to match. The tests make real keys through test/helpers/passkey.ts and cover these cases: a password with no passkey is refused, the link is not spent on that, the made account has one passkey and an empty password hash, a spent link gets 410, and a non-admin gets 403.
One thing to fix: the README is now wrong. README.md:121 still says: "Two paths still make an account without a passkey and are the next step: the owner claim of a spawned forge (web/pages/claim.ts) and an admin's everyday account (web/pages/everyday.ts)." This pull closes exactly those two paths, and the README is what tells the owner how sign-up works. Replace that sentence with what is true now. The claim link and the everyday account both make their account with a passkey (web/passkey-ceremony.ts, through POST /claim/options and POST /settings/everyday/options). Neither reads a password. Without JavaScript the page says a passkey needs it. Then the README, the code and the tests all agree, and this can land.
A note on the evidence: I could not open the preview or the look PNGs from my sandbox (both are denied), so this verdict rests on the code and the tests.
The claim link (/claim) and the everyday account (/settings/everyday) now make their account with a passkey and never a password, the same way /signup does. Locally the type check is clean and the affected suites pass. The forge's full check has not run on the latest push yet.
How it works
- Shared server side:
src/web/passkey-ceremony.tsis used by both pages. It issues a one-time challenge, adds the hidden fields to the form, and checks the key that comes back. It refuses a key another account already holds. - Browser side: in
src/web/passkey-client.ts, thepasskey-makebutton asks the page's options route for a challenge and makes the key. It then puts the key in the hidden fields and submits the form normally. - Two new options routes, both same-origin and neither writing anything:
- POST /claim/options answers only for a live link and a name that is valid and free. It is rate-limited like sign-in.
- POST /settings/everyday/options answers only for a signed-in admin, on a browser session, who has no everyday account yet.
- No password path: neither page reads a
passwordfield any more. A post without a valid key is refused ("Passwords are not taken here.") before anything is written, and the claim link is not spent on it. - Single-use link: the link is spent before the account is made. If attaching the key then fails, the new account is deleted and the link is restored. A spent link gets no new challenge (410).
- Scripts: the home and settings pages load the passkey script only when the everyday form is on them.
- Error note: the note under the passkey button is a plain
.passkey-note, and CSS styles it as an error only when it has text. A page with nothing wrong therefore carries noerror-note, which is whatweb.test.tschecks on the admin's home. - README: the passkeys paragraph now describes these two pages the same way (the review comment). It used to say they still made password accounts.
Tests
- Helper:
test/helpers/passkey.tsis a software authenticator pluspasskeyFields(base, optionsPath, who), which does what the button does.signup-passkeynow uses it instead of its own copy. - Suites that make people with it:
claim-link,everyday,first-login,spawn-first-bootandspawn-child-apps, from the issue. Alsoclient-home,client-listandspawn-keys, which post to/claimtoo. - Two replaced tests: these were wrong under the new rule.
- "a weak password is refused" in spawn-first-boot.
- "a weak password or a taken name makes nothing" in everyday.
Both now check that a password with no passkey is refused, with nothing written and the link unspent.
- New assertions:
- The made account has one passkey and no password. - A spent link gives no second challenge. - A non-admin is refused everyday options.
- Unchanged:
new-forgeandspawn-anon. They never claim, and their passwords belong to the parent forge's accounts.
Not done: accounts made before this change keep their passwords. Without JavaScript, both pages say a passkey needs it rather than falling back to a password.
To try it: spawn a forge and claim it through its link with a passkey. Then, as that admin, use "Make it with a passkey" on the home page.
🤖 Generated with Claude Code
New accounts made from a claim link or as an everyday account now use a passkey, and no password can be set. I also merged main into the branch to clear the conflict that failed the check. The type check passes, and the six passkey, claim-link, everyday-account, sign-up and recovery test files pass (55 tests). I did not run the full suite.
The change:
- Account creation: the claim link and the everyday account create the account by registering a passkey (
src/web/pages/passkeys.ts, under a newmakestore for one-time challenges). - Tests: they create claim-link and everyday users with a passkey through
test/helpers/passkey.ts. - Page notes: the passkey notes on those two pages stay empty until they have something to say, so the page doesn't shift when they fill in.
- README: it now says these two flows take a passkey rather than a password.
The merge: main had added a recover store for the passkey recovery link (#2008) on the same line where this branch added make. The merged code keeps both, as { add, signin, recover, signup, make }. That line is the only hand edit; everything else in the merge commit comes from main unchanged.
To try it: open a claim link or start an everyday account on the preview. The page asks you to register a passkey and has no password field. Signing in afterwards uses that passkey.
🤖 Generated with Claude Code
The design holds up. Both pages now make their account with a passkey. A post that carries a password and no key is refused before anything is written, and the claim link is not spent on it. /claim/options is same-origin, its body is capped by the bytes actually read, and its rate limit keys on the socket address, not a header. /settings/everyday/options checks for a signed-in admin on a browser session. Burning the link and rolling it back on failure looks right. I found no injection, no secret, and no shell or SQL built from user text. The terminal never had a claim or everyday path, so nothing regresses on that door.
What blocks it is tests. Several behaviours this pull adds have no test that would fail without them:
- Fix first: a passkey another account already holds. Neither the
takenrefusal inreadPasskeynor the rollback inclaimSubmit(discard the user, unburn the link) is exercised. Add a claim-link test: register a device's key on an existing account, then claim with that same device (passkeyFields(..., { device })). Assert a 422 that says "That passkey is already registered.", that no user was made, and that the link is still unspent. - A replayed challenge: post the same filled fields twice. The second should be refused with "That request expired." and make nothing.
POST /claim/options: cross-origin gets 403, and the rate limit eventually gets 429 withretry-after. Today only the everyday options route has a refusal test (a non-admin).
Smaller, not blocking: one make challenge store serves both ceremonies, and a challenge is not tied to the token or name it was issued for. A challenge from /settings/everyday/options can therefore be spent at /claim. The POST re-checks everything, so I can't see an exploit, but separate claim and everyday stores would keep each options route's gate meaningful.
I could not open the preview (my sandbox's egress refused the host) or read the forge's pictures (the path is denied to me). This verdict is from the diff and the base.
The claim link and the everyday account now make their account with a passkey, never a password. A form that carries a password and no key is refused before anything is written, and the claim link is not spent on it.
- Making the account: each page asks its own options route (
POST /claim/optionsorPOST /settings/everyday/options) for a challenge. The browser makes the key, and the page's POST checks it inreadPasskey(src/web/passkey-ceremony.ts). A key that another account already holds is refused there, before any user is made or the link is spent. - A challenge store for each form:
claimandeverydayeach have their own store (src/web/pages/passkeys.ts), next toadd,signin,recoverandsignup. A challenge issued by one options route is only accepted at that form's POST, so each route's checks still mean something. - Merged
main: the stores now includerecoverfrom #2008 alongside the two new ones.
New tests in test/claim-link.test.ts:
- Taken key: one device's key is registered to an existing account, then the same device tries to claim. The answer is a 422 saying "That passkey is already registered.", no user is made, the holder still has exactly one passkey, and
/claim/optionsstill gives a challenge for the link. - Replayed challenge: the same filled form is posted twice. The second post gets "That request expired." and makes nothing.
- Challenge from the other form: a challenge from
/claim/optionsis posted to/settings/everydayby the newly signed-in owner and is refused as expired. This test fails if both forms share one store; I checked that by pointing everyday at the claim store. POST /claim/options: a foreign Origin gets a 403, and repeated asks end in a 429 withretry-after.
The undo inside claimSubmit (remove the new user, make the link live again when addPasskey fails) still has no test. A taken key is caught earlier, in readPasskey, so that branch only runs if another account registers the same key while the claim is in progress. A test could only reach it by injecting a failure, and I chose not to add that hook. The other undo, making the link live again after createUser fails, is covered by the reserved-name case.
To try it: bun test test/claim-link.test.ts test/everyday.test.ts. On the preview, open a claim link; the page asks for a passkey and has no password field.
🤖 Generated with Claude Code
Approved. The claim link and the everyday account now make their accounts only with a passkey. Neither handler reads password. A post with no key, a replayed key, a key from the other ceremony or a taken key gets plain words, writes nothing and leaves the link unspent.
Security checks:
- Both options routes refuse a foreign Origin.
/claim/optionsanswers only for a live token, rate-limits on the socket address (clientAddress) and reads its body withboundedBody(STRANGER_MAX), which counts the bytes actually read./settings/everyday/optionschecks for a session, an admin and no existing everyday account.- Each ceremony has its own one-time challenge store, so a challenge can't be carried from one form to the other, and a test covers that.
- The POST handlers still go through
readForm(same-origin plus a byte cap) and re-check the name, so a challenge issued for one name can't be spent on another. - Output goes through
html. No SQL or shell is built from user text, and no secrets were added. - The terminal has no claim or everyday path, so there is no second door to bring level. The README describes the new flow.
Tests: the new behaviour is covered by tests that would fail without it. That includes the made account having a passkey and an empty password hash, a 410 for a spent link, a 403 for a non-admin and a 429 with retry-after.
Limits of this review: I couldn't reach the preview or read the screenshots from my sandbox, so I judged this from the code and tests only.
Two small points, neither blocking:
- The undo in
claimSubmit(addPasskeyfails → discard the user and unburn the link) only runs in a race and has no test. - The name checks are written twice; see the line comment.
The full gate on land was red; the pull is open again. Red tests:
- the crew, with a stand-in Claude Code: continuing a merged pull > a pull merged before its run begins: the follow-up branches from the default branch and lands as a new pull
- the crew, with a stand-in Claude Code: review > a reviewer's run that posts its verdict is ok, though it pushes nothing
- the crew, with a stand-in Claude Code: continuing a merged pull > a pull merged before its run begins: the follow-up branches from the default branch and lands as a new pull [4810.65ms] [shard 45]
- the crew, with a stand-in Claude Code: review > a reviewer's run that posts its verdict is ok, though it pushes nothing [4857.17ms] [shard 46]
The everyday step of the claim link now makes its account with a passkey. Claim and everyday challenges each come from their own store, and there are tests for a key that's already taken, a replayed challenge, and the cases where /claim/options refuses.
This push merges main into the branch. The only conflict was in src/web/studio/home.ts, where both sides changed the same line of the home page body:
- This branch shows
askthere: the first-steps card if there is one, otherwise the everyday card. It also adds the passkey script when the first steps are shown. mainaddedliveLine(ctx)after the address.
The merged line keeps both: ${ask} ${address} ${liveLine(ctx)}. No test was changed. Type check is clean, and 23 test files covering home, studio, passkeys, claim link and releases pass (306 pass, 1 skipped, 0 failed).
To try it: open the home page in the preview and check that the passkey ask and the live line both appear. Then follow a claim link and register a passkey.
🤖 Generated with Claude Code
Read for security at 6178e909. Nothing high or medium.
- Identity and writes:
/claim/optionsand/settings/everyday/optionsrefuse cross-origin requests. The claim route checks that the link is live before it reveals anything about a name, and its rate limit keys onclientAddress(the socket peer), not on a header. The everyday route needs a session admin with no everyday account, and the POST still goes throughreadForm(ctx, "admin"). - Bodies and the key: bodies are capped by the bytes actually read (
boundedBody). Each form has its own challenge store, so a claim challenge is refused at the everyday POST (tested). A taken key is refused before the link burns or a user is made. - No password path: neither POST reads
passwordany more. A post with a password and no passkey is refused, and the link stays unspent (tested). - Terminal: neither flow existed in the terminal before, and WebAuthn cannot run there, so leaving the terminal out is right.
- Tests: the 10 affected suites pass at this head (132 tests, 0 failing).
My sandbox could not reach the preview host or the screenshot files, so this verdict rests on the code and the tests.
One low note, inline: the rollback after addPasskey fails has no test.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The claim link (
/claim) and the everyday account (/settings/everyday) now make their account with a passkey and no password, the same way/signupdoes.How it works
src/web/passkey-ceremony.tsis the server side both pages share. It hands out a challenge from its own one-time store, adds the hidden fields to the form, and checks the key that comes back, refusing a key another account already holds.src/web/passkey-client.ts, thepasskey-makebutton asks the page's options route for a challenge, makes the key, puts it in the hidden fields and submits the form normally.-
POST /claim/optionsanswers only for a live link and a name that is valid and free. It is rate-limited like sign-in. -POST /settings/everyday/optionsanswers only for a signed-in admin, on a browser session, who has no everyday account yet.passwordfield. A post without a valid key is refused in plain words ("Passwords are not taken here.") before anything is written, and the claim link is not spent on it.Tests
test/helpers/passkey.tsis a software authenticator pluspasskeyFields(base, optionsPath, who), which does what the button does.signup-passkey.test.tsnow uses it instead of its own copy.claim-link,everyday,first-login,spawn-first-bootandspawn-child-apps. So do three other suites that also post to/claim:client-home,client-listandspawn-keys.spawn-first-boot) and "a weak password or a taken name makes nothing" (everyday). They now check that a password with no passkey is refused, with nothing written and the link unspent.new-forgeandspawn-anonare unchanged: they never claim, and their passwords belong to accounts on the parent forge.The type check is clean and every suite listed above passes locally; I did not run the full check.
Not done here: accounts made before this change keep their passwords. Without JavaScript, these two pages now explain that a passkey needs it, instead of falling back to a password.
To try it: spawn a forge, open its claim link and claim it with a passkey. Then, as that admin, use the home page's "Make it with a passkey".
🤖 Generated with Claude Code
Closes #1890