fix(webauthn)!: require an access session to enroll a passkey - #287
Merged
Conversation
/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.
This was referenced Sep 8, 2026
fix(webauthn)!: forward the access session at passkey enrollment
fells-code/seamless-auth-server#155
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #278.
The defect
/loginand/registration/registerboth mint an ephemeral token for an account thatalready exists, from an email address alone, and
/webauthn/register/startand/webauthn/register/finishaccepted that token. Anyone who knew an address could enroll acredential against the account and sign in as its owner, including an account holding
OWNER_EMAILadmin 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), somainhas carried no guard. The signal is the token type, not theaccount state.
The change
Both enrollment routes take
auth: 'access'. The ephemeral gate is removed rather thansupplemented, which is possible because
verifyEmailOTPsetsverified = trueunconditionally (src/utils/otp.ts:184), so
/otp/verify-email-otpalways issues a session and every shipped signup path already holdsone 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 whatit is for.
Enrollment no longer issues a session.
/webauthn/register/finishreturned a freshaccess 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 theirother devices. It now answers
200with the credential it enrolled, in the shape/users/credentialsalready uses, and leavesverifiedandlastLoginalone since thesession 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.mdand the bearer contract inREADME.mdboth 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
401until both land, so these merge and releasetogether:
@seamless-auth/typesneeds no change:CredentialUpdateResponseSchemaalready exists andis reused.
Verification
npm run typecheck,npm run lint,npm run format:checkall 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.tsasserts the routes validate asaccess,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.tspin the new finishresponse and that no session is created.
I checked the new tests are not vacuous: reverting the route to
ephemeralfails two ofthem, and restoring
issueSessionAndRespondfails all three finish-response tests./security-reviewon the branch returned no HIGH or MEDIUM findings. Its one sub-thresholdobservation, 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:
that was broken in No way to enrol a passkey on an authenticated session after eaf0e21 #278.
time.
response body, and call
/webauthn/register/startwith it directly. Should be401.sessionsbefore and after.