Skip to content

fix(webauthn)!: require an access session to enroll a passkey - #287

Merged
Bccorb merged 2 commits into
mainfrom
fix/passkey-enrollment-behind-access-auth
Sep 8, 2026
Merged

fix(webauthn)!: require an access session to enroll a passkey#287
Bccorb merged 2 commits into
mainfrom
fix/passkey-enrollment-behind-access-auth

Conversation

@Bccorb

@Bccorb Bccorb commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Closes #278.

The defect

/login and /registration/register both mint an ephemeral token for an account that
already exists, from an email address alone, and /webauthn/register/start and
/webauthn/register/finish accepted that token. Anyone who knew an address could enroll a
credential against the account and sign in as its owner, including an account holding
OWNER_EMAIL admin roles, without ever seeing the OTP that went to the real owner.

An earlier attempt (5b4944a) refused enrollment when the account was already verified.
That caught the primary signup path rather than the attack and was reverted inside the same
PR (9414749), so main has carried no guard. The signal is the token type, not the
account state.

The change

Both enrollment routes take auth: 'access'. The ephemeral gate is removed rather than
supplemented, which is possible because verifyEmailOTP sets verified = true
unconditionally (src/utils/otp.ts:184), so
/otp/verify-email-otp always issues a session and every shipped signup path already holds
one by the time it offers a passkey. Nothing legitimate needed the ephemeral route.

/webauthn/login/* is unchanged and still takes a pre-auth token. Authenticating is what
it is for.

Enrollment no longer issues a session. /webauthn/register/finish returned a fresh
access and refresh pair. Under an access session that leaves the caller's existing session
live and unrevoked while counting against max_concurrent_sessions, which can evict their
other devices. It now answers 200 with the credential it enrolled, in the shape
/users/credentials already uses, and leaves verified and lastLogin alone since the
session that authorised the request proved both.

The two registration decoy responders go with the ephemeral gate. A decoy subject can no
longer reach enrollment, so there is nothing left for them to answer for. That takes the
count of endpoints accepting a pre-auth token from fifteen to thirteen, updated in
docs/security-posture.md.

Second commit is docs: the auth model in AGENTS.md and the bearer contract in README.md
both described an ephemeral token in the terms that made accepting one here look reasonable.

Contract impact, and it is lockstep

Auth mode on two routes, plus a changed 200 response shape on register/finish.

There is no safe release order between this and an adapter. Ship this first and an older
adapter still sends the ephemeral token; ship the adapter first and it sends one this
release refuses. Enrollment answers 401 until both land, so these merge and release
together:

@seamless-auth/types needs no change: CredentialUpdateResponseSchema already exists and
is reused.

Verification

npm run typecheck, npm run lint, npm run format:check all clean. Tests: 113 files,
1327 passed, 1 skipped, 1 todo. Coverage 98.74% statements, 96.01% branches against the
70/65 thresholds.

New coverage drives the real auth middleware, which every other WebAuthn spec replaces
with one that injects a user whatever the route asked for:
tests/integration/webauthn/enrollmentAuth.spec.ts asserts the routes validate as access,
refuse an ephemeral bearer, refuse no bearer, accept an access session, and refuse the exact
token a registration attempt hands back. Three more in webauthn.spec.ts pin the new finish
response and that no session is created.

I checked the new tests are not vacuous: reverting the route to ephemeral fails two of
them, and restoring issueSessionAndRespond fails all three finish-response tests.

/security-review on the branch returned no HIGH or MEDIUM findings. Its one sub-threshold
observation, that a refusal at the auth gate now reaches no audit event, is filed as #286.

Testing locally

Needs all three repos on their branches together, since the upgrade is lockstep. Link the
adapter and SDK into your test app rather than pulling published versions.

Worth exercising:

  1. Register a new address, verify the email OTP, enroll a passkey. Works, and is the path
    that was broken in No way to enrol a passkey on an authenticated session after eaf0e21 #278.
  2. Enroll a second passkey from a signed-in settings screen. Now possible for the first
    time.
  3. Register with an address that already has an account, take the ephemeral token from the
    response body, and call /webauthn/register/start with it directly. Should be 401.
  4. Confirm enrolling does not sign you out elsewhere: check sessions before and after.

/login and /registration/register both mint an ephemeral token for an account that
already exists, from an email address alone, and /webauthn/register/start and
/webauthn/register/finish accepted that token. Anyone who knew an address could enroll a
credential against the account and sign in as its owner, including an account holding
OWNER_EMAIL admin roles, without ever seeing the OTP that went to the real owner. Both
routes now take auth: 'access'.

Nothing legitimate loses a path. Registration proves an address with an email OTP, and
verifying that OTP sets verified and issues a session, so every shipped signup flow
already holds one by the time it offers a passkey. /webauthn/login/start and
/webauthn/login/finish are unchanged and still take a pre-auth token, because
authenticating is what they are for.

An earlier attempt at this refused enrollment when the account was already verified,
which caught the primary signup path rather than the attack and was reverted. The signal
is the token type, not the account state.

/webauthn/register/finish no longer issues a session. Under an access session that would
leave the caller's existing session live and unrevoked, and count against
max_concurrent_sessions, which can evict the user's other devices. It answers 200 with
the credential it enrolled, in the shape /users/credentials already uses, and leaves
verified and lastLogin alone since the session that authorised the request proved both.

The two registration decoy responders go with the ephemeral gate. A decoy subject cannot
reach enrollment any more, so there is nothing left for them to answer for, which takes
the count of endpoints accepting a pre-auth token from fifteen to thirteen.

Adapters must forward the access identity for these two routes. Coordinated releases of
@seamless-auth/express, @seamless-auth/fastify and @seamless-auth/react follow.
The auth model said an ephemeral token continues "registration/login flows", which is what
made accepting one at passkey enrollment look reasonable. It now says what the token
actually proves, possession of an address rather than of the account, and names the rule
that follows: anything changing how an account signs in takes an access session.

The bearer token contract in the README said the same thing in the same misleading way. It
listed WebAuthn continuation among the ephemeral routes, which is now login only, and
passkey among the flows returning an access token, which enrollment no longer does.

The ecosystem map gets the lockstep rule for auth-mode changes. There is no safe release
order between this API and an adapter: ship either side first and the route answers 401
until the other lands, so an auth-mode change is one coordinated release rather than a
sequence. The changeset says the same thing where an adopter will actually read it.

Also refreshes the map's stale version markers against each repo's main, and records that
the Fastify adapter now carries the same route table as Express, with the parity test as
what keeps the two from drifting.
@Bccorb
Bccorb merged commit 42b4c22 into main Sep 8, 2026
4 of 5 checks passed
@Bccorb
Bccorb deleted the fix/passkey-enrollment-behind-access-auth branch September 8, 2026 15:10
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.

No way to enrol a passkey on an authenticated session after eaf0e21

1 participant