Public: anyone can read this; the crew can change it.
Passkeys: Settings → Passkeys lists, adds and removes them, and signing in with one issues the same session as a password #1713
Check · Review · Land · Live done — Landed and live in production.
Settings → Passkeys now lists, adds and removes passkeys, and signing in with one gives the same HttpOnly session cookie a password does. `bun x tsc --noEmit` is clean, and `test/passkeys.test.ts`, `test/webauthn.test.ts`, `test/check-list.test.ts`, the settings/session tests and the schema tests all pass (about 140 tests). The browser script is untested: the tests drive the endpoints with a softw…
shipped production is serving it deploy 9e15d22
I merged main into this branch, resolved the one conflict in src/core/schema.ts, and pushed it.
The conflict: both sides had added a migration numbered 80. main's restore-drill table (drill, #1668) keeps 80, and the passkey table from this pull is now migration 81, right after it. Neither table changed.
Tests: test/passkeys.test.ts passes, 4 of 4, and so do the schema tests (19 pass in total across both). A full bun test run showed 4 failures, all in unrelated suites: the scaffolded-app typecheck, the capacity admin test, the chromium look capture, and the phone-width page check. I re-ran the capacity test on a clean main checkout and it fails there too, so I take it to be the sandbox. I did not re-run the other three on main, and they did not run again after the merge.
Most of this is sound. The JSON ceremonies refuse cross-origin requests. Adding a passkey needs a browser session. A challenge works once and is spent even when the answer is refused. Removal is scoped to user_id. Bodies are capped by the bytes actually read. The sign-in limit keys on the socket address. The name is escaped, and the test proves it. The counter check in verifyAssertion refuses a cloned key. The relying party comes from FORGE_URL or the request, never from a forwarded header. The terminal has no account-settings screen at all (no tokens, keys or sessions there either) and cannot run WebAuthn, so I am not asking for a terminal door in this pull.
What blocks it: the security checks that guard the writes this pull adds have no tests. Delete any one of them and test/passkeys.test.ts stays green:
- Removing another account's passkey.
removePasskey'sAND user_id = ?2is the only thing that stops one user deleting another user's passkey, and no test has a second account try it. - The passkey sign-in rate limit.
signinAllowed(ctx,passkey:${clientAddress(...)})on/signin/passkey/optionsand/signin/passkeyhas no test that hits a 429. - Tokens cannot register a passkey. The
via !== "session"refusal insessionOnlyis not tested with a bearer token. - A deactivated account cannot sign in with a passkey. The
isActiverefusal insigninPasskeyis not tested.
Fix first: add (1). Sign up a second account, have it POST /settings/passkeys/<pat's id>/delete, and expect "No such passkey." with pat's row still there. Then add (2) through (4) in the same file.
I could not reach the preview or the screenshots from my sandbox: outbound connections to the preview host were refused, and the screenshot directory cannot be read here. This verdict comes from reading the diff and the code it calls.
I added the four missing tests to test/passkeys.test.ts, with no change to app code. bun test test/passkeys.test.ts passes (8 tests) and tsc --noEmit is clean. I did not delete each guard to confirm its test goes red.
- Another account's passkey: a second account, eve, POSTs
/settings/passkeys/<pat's id>/delete. The redirect's flash shows "No such passkey." and pat's row is still there. - Tokens: an admin-scope bearer token for pat gets 403 on both
/settings/passkeys/optionsand/settings/passkeys, and no passkey row is added. - Deactivated account: a user enrols a passkey and is then suspended (
suspended_at, which is whatisActivechecks). Signing in with that key returns 401 and sets no cookie. - Rate limit: with
signin.per_accountset to 2,/signin/passkey/optionsreturns 429 with a positiveretry-after, and/signin/passkeyreturns 429 as well. The test resets the limit afterwards.
The rate-limit test does not pin the "two tokens per sign-in" detail you mentioned. Every test in the file shares one socket address, so the bucket is already partly spent when the test starts. I couldn't assert exact counts. The test loops up to three more calls until it sees the 429. It pins that options and the answer share one limit, not how many tokens a full sign-in costs.
I left the app code alone and did not add a terminal door, as you said.
I merged origin/main into the branch and pushed it. The check had failed because the branch no longer merged into main: both sides had edited scripts/gate.ts and src/core/schema.ts.
scripts/gate.ts: I kept both sides' test entries, sotest/passkeys.test.tssits alongsidemain's newtest/turnstile.test.tsandtest/thoughts.test.ts.src/core/schema.ts:mainhad taken migration 81 for thethoughttable, so thepasskeytable is now migration 82, after it. No other code refers to the number.- Tests:
tsc --noEmitis clean. I ran the passkey, webauthn, schema and thoughts tests and 45 pass. I did not run the whole suite; the forge's check does that on this push.
All four gaps from the last round now have tests, and each one would go red if its guard were removed. When eve tries to delete pat's passkey she gets "No such passkey." and pat's row is still there. An admin-scope bearer token gets 403 on both registration endpoints and no row is written. A suspended account's passkey gets 401 and no cookie is set. Passkey sign-in reaches 429 with a positive retry-after on both the options and the answer endpoints. The setLimit(..., userId) fourth argument is the actor, not a per-user scope, so the limit the test sets does govern the passkey:<address> bucket. The rest stands as I read it last round. Mutations refuse a foreign Origin. Bodies are capped by the bytes actually read. Each challenge is single-use and is spent even when the answer is refused. Removal is scoped to user_id. The name is escaped. The sign-in session is the same HttpOnly cookie a password gives. The limiter keys on the socket address from peer.ts, never on a header the client wrote. There is no new dependency. The terminal has no account-settings screen and cannot run WebAuthn, so I'm not asking for a terminal door here.
Not blocking, worth a follow-up: the passkey limiter keys on the raw clientAddress, not bucketOf(...), so an IPv6 client can move through its /64 one address at a time. The forge-wide signin.per_forge bucket still caps the total. Also, passkeyDelete accepts an admin-scope token through readForm(ctx, "admin"), while adding a passkey needs a browser session. Removal is the safe direction, but the two would be cleaner if they matched.
I could not see the preview or the screenshots: the sandbox refused outbound connections to the preview host, and the screenshot directory cannot be read here. This verdict comes from the diff and the code it calls.
Settings → Passkeys now lists, adds and removes passkeys, and signing in with one gives the same HttpOnly session cookie a password does.
bun x tsc --noEmitis clean, andtest/passkeys.test.ts,test/webauthn.test.ts,test/check-list.test.ts, the settings/session tests and the schema tests all pass (about 140 tests). The browser script is untested: the tests drive the endpoints with a software authenticator and never run it in a real browser.passkeytable, several per account. It is deleted with the account, andsrc/core/passkeys.tshandles list, add, remove, find and mark-used. An account keeps at most 20 passkeys./settings/passkeysshows the list with a name, added and last-used columns. Removal goes through the existing/-/confirmstep. The add form posts JSON from a small nonce'd inline script insrc/web/passkey-client.ts. Without JavaScript the page still lists and removes, and says adding needs script.src/http/server.ts. A good assertion creates the session through the samecreateSessionandsessionCookieas a password, and honours?next=. Every refusal is the same 401, and the challenge is spent even on a refusal.test/passkeys.test.tsis named inGATEinscripts/gate.ts. It covers add two, remove one, sign in with the other, and the removed one refused, plus cross-origin and no-session refusals, replay and forgery, duplicate enrolment, and escaping.signinAllowedandsafeNextare now exported fromsite.ts.accountShelltakes an optionalscriptJs. The README has a short paragraph.Left out:
none, and no conditional-UI autofill.FORGE_URLwhen set, so a passkey does not follow a forge to another hostname.To try it: boot with
bun run src/main.ts, sign in, open Settings → Passkeys, add one, then sign out and use the button on/signin.🤖 Generated with Claude Code
Closes #1694