Skip to content

fix(webauthn): give challenges their own store, with expiry and one-time use - #201

Merged
Bccorb merged 1 commit into
mainfrom
fix/webauthn-challenge-store
Aug 29, 2026
Merged

fix(webauthn): give challenges their own store, with expiry and one-time use#201
Bccorb merged 1 commit into
mainfrom
fix/webauthn-challenge-store

Conversation

@Bccorb

@Bccorb Bccorb commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Closes #164, and fixes the replay finding tracked privately alongside it, which
#164 said belonged in the same change.

What was wrong

Challenges lived in one users.challenge column shared by registration, login
and step-up.

  • The flows clobbered each other. Starting a login invalidated a
    registration already in flight for the same user. A second tab invalidated the
    first.
  • Nothing expired. The column had no lifetime. The timeout in the
    credential options is only a hint to the browser and was never enforced server
    side, so a challenge stayed valid until some later flow happened to overwrite
    it.
  • A challenge could outlive its ceremony. Login finish never cleared it at
    all, which is the private finding.

What it looks like now

A webauthn_challenges table keyed by user and flow, with expires_at and
consumed_at. Registration, login and step-up can each be outstanding at once.

Two design points worth a look:

Consume on read, not on success. consumeChallenge is called once at the
top of verification and spends the challenge before anything else can fail. That
makes "spent on every terminal path" a property of the shape of the code rather
than something each error branch has to remember. Every future exit added below
it is covered for free.

A five minute life. Comfortably longer than the sixty second client hint, so
a user hunting for a security key or waiting on a biometric prompt is not cut
off, while bounding a captured challenge to minutes instead of however long it
took some later flow to overwrite it.

The per-flow context that used to sit in users.challengeContext, currently the
PRF flags, travels with the challenge it was issued for rather than sitting on
the user.

A magic link completing spends any half-finished ceremony for that user, which
preserves what the old user.challenge = '' line was doing.

Regression guards, mutation checked

Two tests assert the challenge is spent on a failed login and on a
successful one. I confirmed they are not vacuous: removing the
record.update({ consumedAt }) line fails exactly those two and nothing else.
A third asserts a second attempt after the challenge is spent gets a 401.

Migration verified, not assumed

Run against a real Postgres 17: table created with both indexes and the cascade
foreign key, then db:migrate:undo confirmed to drop it.

Left deliberately

users.challenge and users.challengeContext are no longer read or written but
the columns remain, so this release can be rolled back without data loss. They
should be dropped in a follow-up once it has run in production. Cutting over and
dropping in the same release would leave no way back.

Verification

92 test files, 1006 passed, 1 skipped. Coverage 98.78% lines. typecheck,
lint, format:check clean. Contract regenerated, no change to it.

Existing tests were updated rather than deleted where they encoded the old
storage: the PRF-required case now puts that requirement on the challenge, and
"stored challenge is missing" becomes "no live challenge exists". Both still
assert the same behaviour.

…ime use

Challenges lived in a single users.challenge column shared by registration,
login and step-up, so the three flows clobbered each other and a second tab
invalidated the first. The column also had no lifetime: the timeout in the
credential options is only a hint to the browser, and nothing server side ever
enforced it, so a challenge stayed valid until some later flow happened to
overwrite it.

Challenges now live in webauthn_challenges, keyed by user and flow, with a
server enforced five minute life. That is comfortably longer than the sixty
second client hint, so a user hunting for a security key is not cut off.

They are also spent when verification reads them, before anything else can fail,
so no outcome leaves a redeemable challenge behind. That closes the replay
finding tracked privately alongside this issue, whose fix belonged in the same
change.

The per-flow context that used to sit in users.challengeContext travels with the
challenge it was issued for.

users.challenge and users.challengeContext are no longer read or written. They
are left in place so this release can be rolled back, and should be dropped once
it has run in production.

Migration verified up and down against Postgres 17.

Closes #164
@Bccorb
Bccorb merged commit 0e6664d into main Aug 29, 2026
4 checks passed
@Bccorb
Bccorb deleted the fix/webauthn-challenge-store branch August 29, 2026 04:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebAuthn challenges share one user column, with no TTL and no concurrency

1 participant