feat(login): answer unknown identifiers with decoy pre-auth tokens - #257
Merged
Conversation
added 6 commits
September 3, 2026 21:00
POST /login no longer returns 401. An identifier with no usable account, meaning unknown, unverified, or with no permitted continuation method, now gets 200 with a decoy ephemeral token that is real, signed, and shaped like one issued to a genuine account. Returning 200 is worth nothing unless the next request keeps the secret, so all fifteen endpoints that accept a pre-auth token answer for a decoy the way they answer for a real one. OTP sends report success without sending, OTP and TOTP verifies fail the way a wrong code fails, the magic link poll returns 204 indefinitely, and WebAuthn returns a plausible challenge. Login start offers one fabricated credential, because a real account with no passkey answers 401 there and an empty allow list would have sorted the decoy into that bucket. Policy branches are reproduced rather than skipped, so a deployment with email_otp disabled still answers 403 for every identifier. A decoy derives from one HMAC over the normalised identifier. The same unknown identifier always maps to the same subject, since one that rerolled would be an oracle by itself, and the subject is a well formed v4 UUID that cannot be told from a real user id without the key. Nothing is written: decoys are issued for any identifier a stranger can type, so persisting them would trade enumeration for a way to fill the disk. There is no decoy claim, because anyone can decode a JWT. The stand in principal carries a synthetic email and phone so the OTP and magic link limiters keep bucketing per identifier. Left empty they fall back to an IP bucket, so every unknown identifier probed from one address would have shared a counter while every real one got its own. LOGIN_RESPONSE_FLOOR_MS holds every answer to a minimum, since identical bodies arriving at different times still answer the question. defineRoute refuses to register an ephemeral route with no decoy responder. That failure is silent at runtime and invisible in a diff, so it is caught at registration rather than left to convention. Closes #120
…oves nothing loginMethods is filtered by what an account can actually do: one with no passkey is not offered passkey, one with no phone is not offered phone_otp. A decoy that always claimed the full permitted set therefore made any narrower set proof that a real account exists, which is the original oracle with extra steps. Derive the shape from the subject alongside everything else, so about half of decoys have a passkey and about half a phone, stable per identifier. A narrow list is then as likely to be a decoy as a real account. A decoy left with no methods by its derived shape falls back to the full set. A real account with no permitted method is itself answered as a decoy, so an empty list is something only a decoy could produce, and under a passkey-only policy that would have been every decoy without a passkey.
…nswered A real account with no phone answers 400 on /otp/generate-phone-otp. About half of decoys are now shaped without a phone so that a narrow login method list proves nothing, and those were answering 200, so the shape that was hiding them became the thing that showed them.
…on purpose A decoy responder that only mirrors the success path is not finished. Any 400 a real account can be made to answer is an oracle if a decoy answers 200 to the same request. Two were reachable by choice. A redirectUri outside the configured origins answers 400 for a real account, and the caller picks that value. A request with no identifiable device metadata answers 400, and omitting a User-Agent header is enough to get there. Both were answering 200 for a decoy. The decoy responder now runs the same redirect validation and the same fingerprint check. Neither writes anything.
/webauthn/login/start filters the account's credentials by the requested credentialId and prf and answers 401 when nothing survives. The decoy responder returned a challenge unconditionally, so a caller asking for a credential id no credential can have got 401 from every real account and 200 from every decoy. That is a complete oracle two requests long, independent of account state or policy, and it undid the login method shaping one request later. The decoy's fabricated credential is now filtered the same way, a decoy shaped without a passkey refuses outright, and the refusal uses res.send like the real one rather than differing in content type. /webauthn/register/start now offers the fabricated credential in excludeCredentials when the shape has one, passes the PRF extensions, and reproduces the attachment_not_allowed branch. An always-empty exclude list said "this subject has no passkey" to anyone who looked. Two residual oracles are documented rather than closed, because both need the ephemeral token to carry the identifier and that is a second contract change: register/start echoes the account email as user.name, and a decoy's shape cannot depend on which identifier type was looked up.
…tes nothing The claim was that nothing is written for a decoy. Each responder records an auth event, so that was not true as stated. What is true is narrower and is the part that matters: no decoy is stored to be looked up later, and no responder writes a challenge, a magic link or an OTP, so probing cannot fill a table. The auth events are the ones every request already writes, and they are what keeps bulk probing visible to operators.
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 #120.
What changed
POST /loginno longer returns401. An identifier with no usable account, meaningunknown, unverified, or with no permitted continuation method, now gets
200with adecoy ephemeral token: real, signed, and shaped like one issued to a genuine account.
Returning
200is worth nothing unless the next request keeps the secret, so all fifteenendpoints that accept a pre-auth token now answer for a decoy the way they answer for a
real one.
200 { message: 'success', token }, nothing sent401 { error: 'Not allowed' }, the body a wrong code gets204, where a real account sits until someone clicks403 { error: 'Missing challenge' }401 { error: 'Authentication failed.' }401 { error: 'totp_verification_failed' }Design notes worth reviewing
One response builder. Real and decoy logins both return through
respondWithPreAuth,so the shapes cannot drift apart. A field added to one and forgotten in the other is
exactly the tell this closes. It is also what keeps the server adapter working, below.
No
decoyclaim. Recognition is "the subject resolves to no user row". A claim sayingwhich tokens are fake is readable by anyone who base64-decodes a JWT.
No decoy state is stored. No decoy is kept to be looked up later, and no responder
writes a challenge, a magic link or an OTP: decoys are issued for any identifier a
stranger can type, so persisting one would trade an enumeration oracle for a way to fill a
table. (Auth events are still recorded, as they are for every request, which is what keeps
bulk probing visible to operators.) Everything derives from one HMAC over the normalised
identifier, keyed with the new optional
DECOY_SUBJECT_SECRET(falling back toAPI_SERVICE_TOKEN, mirroringstateSecret()inoauthService).Stable per identifier. The same unknown identifier always maps to the same v4-shaped
subject. One that rerolled would be an oracle by itself, since a real identifier resolves
to the same row every time.
The stand-in principal is a plain object cast to
User. What makes that safe is thatdefineRoutedispatches a decoy request to the route's decoy responder and never to thecontroller, so no handler, and therefore no write, ever sees it.
defineRoutenow refusesto register an ephemeral route that declares no decoy responder: that failure is silent at
runtime and invisible in a diff. I verified all 15 ephemeral routes are covered and that
no route calls
attachAuthMiddleware('ephemeral')outsidedefineRoute.Timing. Identical bodies arriving at different times still answer the question, since
the real path reads four tables and the decoy path reads one.
LOGIN_RESPONSE_FLOOR_MS(default
250) holds every answer to a minimum. It is an environment variable rather thana
system_configkey because that schema ships in@seamless-auth/types, so a key theremeans a coordinated release across both SDKs for an operational tuning knob.
A hole I opened and then closed
The first version gave every decoy the full permitted method list. That was wrong.
loginMethodsis filtered by what an account can actually do, so an account with nopasskey is offered
magic_linkandemail_otpand nothing else, and no decoy would everhave answered that way. Any narrower list was proof that a real account existed, which
is the original oracle with extra steps. Verified before fixing:
A decoy's shape is now derived from its subject alongside everything else, so about half
have a passkey and about half a phone, stable per identifier, and a narrow list is as
likely to be a decoy as a real account. Second commit.
That fix opened three smaller holes in the other direction, all now closed and pinned by
tests:
methods, and an empty list is something only a decoy can produce, since a real account
with none is itself answered as a decoy. A decoy left empty falls back to the full set.
400on/otp/generate-phone-otp, and a decoyshaped without one was answering
200. The shape hiding it became the thing showing it.Auditing for that pattern turned up two more that predate the shape change and are the
more interesting ones, because a caller triggers them on purpose rather than waiting
for the right account state:
/magic-link?redirectUri=outside the configured origins answers400 Redirect URI is not allowedfor a real account. The caller picks that value, so this was onedeliberately bad request away from confirming an identifier.
/magic-linkwith no identifiable device metadata answers400 Invalid device data,and omitting a
User-Agentheader is enough to get there.Both now run the same validation in the decoy responder. Neither writes anything, so
reproducing them costs a decoy nothing. The general rule, written into
docs/security-posture.md: a decoy responder that only reproduces the success path is notfinished; every rejection a real account can be made to reach, a decoy has to reach too.
The security review found two more, and two it could not close
I ran
/security-reviewon the branch as CLAUDE.md requires. It cleared the things I mostwanted cleared (no session issuance or write reachable from a decoy, the
Usercast safebecause only the rate limiters run before dispatch and they read
email/phoneonly, theunverified path signing over the decoy subject rather than
user.id, HMAC domainseparation sound). It also found two live oracles in the WebAuthn responders, both fixed
in the last commit:
/webauthn/login/startwas the worst one in the PR. The real handler filters theaccount's credentials by the requested
credentialIdandprfand answers401 Credentials not foundwhen nothing survives. My decoy returned a challengeunconditionally, so
{"credentialId":"not-a-real-credential-id"}got401from everyreal account and
200from every decoy: a complete oracle, two requests long,independent of account state or policy, which undid the
loginMethodsshaping onerequest later. Now filtered the same way, with
res.sendrather thanres.jsonso thecontent type matches too.
/webauthn/register/startpassed an always-emptyexcludeCredentials, which said"this subject has no passkey" to anyone who looked, and skipped the PRF extensions and
the
attachment_not_allowedbranch. All three reproduced.Two residuals I did not close, because both need a decision that is yours:
register/startechoes the account's email asuser.name, since that is what anauthenticator displays. A decoy echoes its synthetic address, so a caller reading
user.namesees@example.invalidwhere a real account shows the identifier theytyped. One request past
/login, and complete.type was looked up.
/loginfinds a phone account by its number, so such an accountalways has a phone and is always offered
phone_otpwhile only half of decoys are.Where
phone_otpis enabled, a phone identifier whose answer omits it is a real account.Both have the same root cause and the same fix: the decoy would have to echo the
identifier that was supplied, and it cannot, because it is rebuilt from a one-way HMAC of
that identifier. Carrying it means putting it in the ephemeral token, and putting it only
in decoy tokens is the
decoyclaim by another name, so every ephemeral token wouldhave to carry it. That is a second change to the token contract and a second coordinated
release, and #120 lists exactly this ("how a decoy subject is represented") as an open
question. I would rather you decide it than have me widen the contract twice in one PR.
Both are written into
docs/security-posture.mdunder "What this still does not cover"rather than left implicit, so the posture section does not overclaim.
What is still observable
Stated plainly rather than claimed closed:
423, and only a real account can be locked. Unchanged andpreviously accepted; it needs prior failed attempts against that specific account.
rather than the identifier the caller sent, so a caller comparing them can tell. That
mode requires a valid internal service token, so the caller is a trusted backend that
can enumerate through the admin API anyway. The service token is the reason this is
acceptable, not the fabrication.
distinguishable
401, so such a user sees a continuation that quietly never succeeds.Contract impact and blast radius
Contract-affecting. I surveyed the three dependents; no sibling repo is edited here.
seamless-auth-server: unaffected. It cryptographically verifies the/loginresponse (
verifyUpstreamSession→verified.sub !== data.subthrows). I checked thispath specifically because a malformed decoy would have turned every unknown-identifier
login into a
500. It holds: the decoy is signed by the same signer and returned by thesame builder as a real login, with a matching
suband a positivettl. The adapteralso strips
sub/token/ttlbefore responding, so browsers never see the decoy token.Its
!up.okpassthrough becomes dead code for this case.seamless-cli: needs a follow-up.src/core/loginFlow.ts:122-126is the"No account was found" branch and goes unreachable. The user-visible regression is real:
an unknown identifier now prints "A code was sent to ...", prompts three times, and fails
with a generic message. Two tests at
loginFlow.test.ts:217and:333pin the old401contract and would become false confidence.
seamless-auth-react: works, with a behavior change. No 401-specific branching, andLoginStartResultdeliberately omitstoken/sub.Login.tsx:140-171stops showing"Failed to start sign-in" for an unknown identifier and instead runs a passkey ceremony
that fails, or falls through to the fallback options.
Happy to do the CLI change in a coordinated PR on your go-ahead.
Verification
decoyPrincipal.spec.ts(derivation, stability, case folding, UUID shape, shapedistribution, production secret refusal),
defineRouteDecoy.spec.ts(the registrationguard and the dispatch invariant),
decoyContinuation.spec.ts(all 15 endpoints plus"no writes"),
loginTimingFloor.spec.ts.fully usable identifier under one policy. The first draft varied the policy between
probes, which compares two different servers rather than two different accounts.
openapi.jsonandsrc/generated/api.tsregenerated; the only diff is the removed/login401.Notes
docs/api-contract.mdhad/logindocumented as returning401and not423. Bothcorrected.
loginPolicyService.ts:82(passkeyAvailable in the login body downgrades a passkey-only policy #213) appearsto have been fixed already;
resolveAvailableLoginMethodsno longer letspasskeyAvailabledowngrade a passkey-only policy. Not touched here.