The customer is never asked who they are. begin takes no email and sends no allowCredentials; the browser offers whichever accounts it holds for this Relying Party, and the assertion says which credential answered. #38 requested discoverable credentials precisely so this would work.
That choice does more than improve the experience. This issue requires that failures not reveal whether an email has an account or has passkeys registered — and with no email ever sent to the endpoint, there is nothing to reveal. The email-first alternative would have had to answer identically for a known and an unknown address, in every branch, forever.
Session creation is shared, not reimplemented
The requirement is stated sharply on the issue: "a second, subtly different session path is how auth bugs get in."
setSessionCookie and createSession move to customerSession.ts, and both paths import them. Two implementations that agree today are two that can be changed one at a time — and the one that would be forgotten is whichever is not the password path, because that is the one every manual test exercises. #332's social sign-in will use the same module.
Requiring an increase from every authenticator refuses the passkeys most customers actually use. Requiring it from none discards the only signal a hardware credential has been cloned. The asymmetry in the last row is deliberate: an authenticator that has ever reported a real counter is held to the strict rule from then on.
Refusals
A disabled account is refused, read from the same row as the credential rather than a second query that could disagree. Enforcing it only on the password path would leave passkeys as a way around it (#33).
Every refusal answers identically. No such credential, a disabled account, a bad assertion and a stalled counter are all "that did not work" to the caller — saying which would turn this into an oracle for whether a credential exists and whether its account is in good standing. The stalled counter is logged, because the customer cannot act on it and the person who can is reading the logs.
One thing the route guard caught
The challenge predicate was an inline async callback at first. routesAreWrapped.test.ts reads the text of each router.post looking for an async that no asyncRoute covers — and an async callback nested inside a wrapped handler looks exactly like an unwrapped one to it.
Hoisting it to a named function was the better fix than teaching the guard another exception. The code reads more clearly, and the guard keeps its teeth. That guard exists because this convention was half-forgotten once already; #307 already had to extend it once for factories, and I would rather not make it a third special case.
Verification
tsc clean for src and tests · lint back to the 7 pre-existing warnings, none added · 521 unit tests across 36 suites (7 new, covering the counter policy in both directions).
Not verified: the ceremony cannot be exercised without a browser and a real authenticator — per #41 that is a standing limitation of this whole feature rather than a gap here. CI can prove the routes exist, are wrapped, and refuse a caller with no credential.
Worth noting: runs 920–925 are all queued, so the last few merges have gone in unverified. This one is worth letting finish, since it touches the session path that password login also uses.
A customer signs in with a registered passkey.
## The decision this issue left open: usernameless
The customer is never asked who they are. `begin` takes no email and sends no `allowCredentials`; the browser offers whichever accounts it holds for this Relying Party, and the assertion says which credential answered. #38 requested discoverable credentials precisely so this would work.
That choice does more than improve the experience. This issue requires that **failures not reveal whether an email has an account or has passkeys registered** — and with no email ever sent to the endpoint, there is nothing to reveal. The email-first alternative would have had to answer identically for a known and an unknown address, in every branch, forever.
## Session creation is shared, not reimplemented
The requirement is stated sharply on the issue: *"a second, subtly different session path is how auth bugs get in."*
`setSessionCookie` and `createSession` move to `customerSession.ts`, and both paths import them. Two implementations that agree today are two that can be changed one at a time — and the one that would be forgotten is whichever is *not* the password path, because that is the one every manual test exercises. #332's social sign-in will use the same module.
## The counter policy #37 deferred
Both halves matter and they pull opposite ways:
| Stored | Received | Verdict | Why |
|---|---|---|---|
| 0 | 0 | Accept | Synced passkeys report zero forever by design |
| 5 | 6 | Accept | Advanced normally |
| 7 | 7 | **Refuse** | Stalled — two copies used alternately |
| 12 | 0 | **Refuse** | Cannot downgrade to look like a synced passkey |
Requiring an increase from every authenticator refuses the passkeys most customers actually use. Requiring it from none discards the only signal a hardware credential has been cloned. The asymmetry in the last row is deliberate: an authenticator that has ever reported a real counter is held to the strict rule from then on.
## Refusals
**A disabled account is refused**, read from the same row as the credential rather than a second query that could disagree. Enforcing it only on the password path would leave passkeys as a way around it (#33).
**Every refusal answers identically.** No such credential, a disabled account, a bad assertion and a stalled counter are all "that did not work" to the caller — saying which would turn this into an oracle for whether a credential exists and whether its account is in good standing. The stalled counter is *logged*, because the customer cannot act on it and the person who can is reading the logs.
## One thing the route guard caught
The challenge predicate was an inline `async` callback at first. `routesAreWrapped.test.ts` reads the text of each `router.post` looking for an `async` that no `asyncRoute` covers — and an async callback nested inside a wrapped handler looks exactly like an unwrapped one to it.
**Hoisting it to a named function was the better fix than teaching the guard another exception.** The code reads more clearly, and the guard keeps its teeth. That guard exists because this convention was half-forgotten once already; #307 already had to extend it once for factories, and I would rather not make it a third special case.
## Verification
`tsc` clean for src and tests · lint **back to the 7 pre-existing warnings, none added** · **521 unit tests across 36 suites** (7 new, covering the counter policy in both directions).
**Not verified:** the ceremony cannot be exercised without a browser and a real authenticator — per #41 that is a standing limitation of this whole feature rather than a gap here. CI can prove the routes exist, are wrapped, and refuse a caller with no credential.
**Worth noting:** runs 920–925 are all queued, so the last few merges have gone in unverified. This one is worth letting finish, since it touches the session path that password login also uses.
Closes #39
🤖 Generated with [Claude Code](https://claude.com/claude-code)
A customer signs in with a registered passkey. Usernameless: they are never asked who they are, the browser offers whichever accounts it holds for this Relying Party, and the assertion says which credential answered. #38 requested discoverable credentials so this would work.
That choice does more than improve the experience. This issue requires that failures not reveal whether an email has an account or has passkeys registered, and with no email ever sent to the endpoint there is nothing to reveal. The email-first alternative would have had to answer identically for a known and an unknown address, in every branch, forever.
Session creation is shared rather than reimplemented, which is the requirement stated most sharply here: a second, subtly different session path is how auth bugs get in. setSessionCookie and createSession move to customerSession.ts and both paths import them. Two implementations that agree today are two that can be changed one at a time, and the one that would be forgotten is whichever is not the password path, because that is the one every manual test exercises. Social sign-in will use the same module when #332 lands.
The signature counter policy #37 deferred is decided here, and both halves matter. Requiring an increase from every authenticator refuses synced passkeys, which report zero forever by design and are what most customers actually use. Requiring it from none discards the only signal that a hardware credential has been cloned. So zero against zero is accepted and anything else must strictly increase — and the asymmetry is deliberate, because an authenticator that has ever reported a real counter is held to the strict rule from then on and cannot downgrade itself to zero to escape it.
A disabled account is refused, read from the same row as the credential rather than a second query that could disagree. Enforcing that only on the password path would have left passkeys as a way around it.
Every refusal answers the same way. No such credential, a disabled account, a bad assertion and a stalled counter are all that did not work to the caller; saying which would turn the endpoint into an oracle for whether a credential exists and whether its account is in good standing. The stalled counter is logged, because the customer cannot act on it and the person who can is reading the logs.
The challenge is spent by deleting it, with the expiry in the same statement, so a replay finds nothing to delete and a stale challenge fails the same way. It is passed to the library as a predicate rather than a value, which is what makes a usernameless flow possible at all — the challenge is not known until the assertion names it.
That predicate is a named function rather than an inline callback, and it was inline first. routesAreWrapped.test.ts reads the text of each router.post looking for an async that no asyncRoute covers, and an async callback nested inside a wrapped handler looks exactly like an unwrapped one to it. The guard caught it, and hoisting the function out was the better fix: the code reads more clearly and the guard keeps its teeth rather than learning another exception.
Verified: tsc clean for src and tests, lint back to the seven pre-existing warnings with none added, 521 unit tests across 36 suites — seven new, covering the counter policy in both directions.
Not verified: the ceremony cannot be exercised without a browser and a real authenticator, which per #41 is a standing limitation of this feature rather than a gap here. CI can prove the routes exist, are wrapped, and refuse a caller with no credential.
Closes#39
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
A customer signs in with a registered passkey.
The decision this issue left open: usernameless
The customer is never asked who they are.
begintakes no email and sends noallowCredentials; the browser offers whichever accounts it holds for this Relying Party, and the assertion says which credential answered. #38 requested discoverable credentials precisely so this would work.That choice does more than improve the experience. This issue requires that failures not reveal whether an email has an account or has passkeys registered — and with no email ever sent to the endpoint, there is nothing to reveal. The email-first alternative would have had to answer identically for a known and an unknown address, in every branch, forever.
Session creation is shared, not reimplemented
The requirement is stated sharply on the issue: "a second, subtly different session path is how auth bugs get in."
setSessionCookieandcreateSessionmove tocustomerSession.ts, and both paths import them. Two implementations that agree today are two that can be changed one at a time — and the one that would be forgotten is whichever is not the password path, because that is the one every manual test exercises. #332's social sign-in will use the same module.The counter policy #37 deferred
Both halves matter and they pull opposite ways:
Requiring an increase from every authenticator refuses the passkeys most customers actually use. Requiring it from none discards the only signal a hardware credential has been cloned. The asymmetry in the last row is deliberate: an authenticator that has ever reported a real counter is held to the strict rule from then on.
Refusals
A disabled account is refused, read from the same row as the credential rather than a second query that could disagree. Enforcing it only on the password path would leave passkeys as a way around it (#33).
Every refusal answers identically. No such credential, a disabled account, a bad assertion and a stalled counter are all "that did not work" to the caller — saying which would turn this into an oracle for whether a credential exists and whether its account is in good standing. The stalled counter is logged, because the customer cannot act on it and the person who can is reading the logs.
One thing the route guard caught
The challenge predicate was an inline
asynccallback at first.routesAreWrapped.test.tsreads the text of eachrouter.postlooking for anasyncthat noasyncRoutecovers — and an async callback nested inside a wrapped handler looks exactly like an unwrapped one to it.Hoisting it to a named function was the better fix than teaching the guard another exception. The code reads more clearly, and the guard keeps its teeth. That guard exists because this convention was half-forgotten once already; #307 already had to extend it once for factories, and I would rather not make it a third special case.
Verification
tscclean for src and tests · lint back to the 7 pre-existing warnings, none added · 521 unit tests across 36 suites (7 new, covering the counter policy in both directions).Not verified: the ceremony cannot be exercised without a browser and a real authenticator — per #41 that is a standing limitation of this whole feature rather than a gap here. CI can prove the routes exist, are wrapped, and refuse a caller with no credential.
Worth noting: runs 920–925 are all queued, so the last few merges have gone in unverified. This one is worth letting finish, since it touches the session path that password login also uses.
Closes #39
🤖 Generated with Claude Code