Skip to content

feat(login): answer unknown identifiers with decoy pre-auth tokens - #257

Merged
Bccorb merged 6 commits into
mainfrom
feat/login-decoy-pre-auth-tokens
Sep 4, 2026
Merged

feat(login): answer unknown identifiers with decoy pre-auth tokens#257
Bccorb merged 6 commits into
mainfrom
feat/login-decoy-pre-auth-tokens

Conversation

@Bccorb

@Bccorb Bccorb commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Closes #120.

What changed

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: 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 now answer for a decoy the way they answer for a
real one.

Endpoint group A decoy gets
OTP send (4) 200 { message: 'success', token }, nothing sent
OTP verify (4) 401 { error: 'Not allowed' }, the body a wrong code gets
Magic link request The usual "if an account exists" body
Magic link poll 204, where a real account sits until someone clicks
WebAuthn register start A challenge, with no challenge record stored
WebAuthn register finish 403 { error: 'Missing challenge' }
WebAuthn login start A challenge over one fabricated credential
WebAuthn login finish 401 { error: 'Authentication failed.' }
TOTP login verify 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 decoy claim. Recognition is "the subject resolves to no user row". A claim saying
which 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 to API_SERVICE_TOKEN, mirroring stateSecret() in
oauthService).

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 that
defineRoute dispatches a decoy request to the route's decoy responder and never to the
controller, so no handler, and therefore no write, ever sees it. defineRoute now refuses
to 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') outside defineRoute.

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 than
a system_config key because that schema ships in @seamless-auth/types, so a key there
means 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.
loginMethods is filtered by what an account can actually do, so an account with no
passkey is offered magic_link and email_otp and nothing else, and no decoy would ever
have answered that way. Any narrower list was proof that a real account existed, which
is the original oracle with extra steps. Verified before fixing:

decoy          : ["passkey","magic_link","email_otp","phone_otp"]
real no passkey: ["magic_link","email_otp","phone_otp"]  <- differs
real no phone  : ["passkey","magic_link","email_otp"]    <- differs

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:

  • Under a passkey-only policy, a decoy the shape gave no passkey to would have had no
    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.
  • A real account with no phone answers 400 on /otp/generate-phone-otp, and a decoy
    shaped 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 answers 400 Redirect URI is not allowed for a real account. The caller picks that value, so this was one
    deliberately bad request away from confirming an identifier.
  • /magic-link with no identifiable device metadata answers 400 Invalid device data,
    and omitting a User-Agent header 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 not
finished; 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-review on the branch as CLAUDE.md requires. It cleared the things I most
wanted cleared (no session issuance or write reachable from a decoy, the User cast safe
because only the rate limiters run before dispatch and they read email/phone only, the
unverified path signing over the decoy subject rather than user.id, HMAC domain
separation sound). It also found two live oracles in the WebAuthn responders, both fixed
in the last commit:

  • /webauthn/login/start was the worst one in the PR. The real handler filters the
    account's credentials by the requested credentialId and prf and answers
    401 Credentials not found when nothing survives. My decoy returned a challenge
    unconditionally, so {"credentialId":"not-a-real-credential-id"} got 401 from every
    real account and 200 from every decoy: a complete oracle, two requests long,
    independent of account state or policy, which undid the loginMethods shaping one
    request later. Now filtered the same way, with res.send rather than res.json so the
    content type matches too.
  • /webauthn/register/start passed an always-empty excludeCredentials, which said
    "this subject has no passkey" to anyone who looked, and skipped the PRF extensions and
    the attachment_not_allowed branch. All three reproduced.

Two residuals I did not close, because both need a decision that is yours:

  • register/start echoes the account's email as user.name, since that is what an
    authenticator displays. A decoy echoes its synthetic address, so a caller reading
    user.name sees @example.invalid where a real account shows the identifier they
    typed. One request past /login, and complete.
  • A decoy's shape is derived from its subject, so it cannot depend on which identifier
    type was looked up. /login finds a phone account by its number, so such an account
    always has a phone and is always offered phone_otp while only half of decoys are.
    Where phone_otp is 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 decoy claim by another name, so every ephemeral token would
have 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.md under "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:

  • Lockout still answers 423, and only a real account can be locked. Unchanged and
    previously accepted; it needs prior failed attempts against that specific account.
  • External delivery mode returns a fabricated code and the decoy's synthetic address
    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.
  • A deleted or revoked account mid-flow is answered as a decoy rather than a
    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 /login
    response (verifyUpstreamSessionverified.sub !== data.sub throws). I checked this
    path 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 the
    same builder as a real login, with a matching sub and a positive ttl. The adapter
    also strips sub/token/ttl before responding, so browsers never see the decoy token.
    Its !up.ok passthrough becomes dead code for this case.
  • seamless-cli: needs a follow-up. src/core/loginFlow.ts:122-126 is 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:217 and :333 pin the old 401
    contract and would become false confidence.
  • seamless-auth-react: works, with a behavior change. No 401-specific branching, and
    LoginStartResult deliberately omits token/sub. Login.tsx:140-171 stops 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

  • 1283 tests pass (47 new), lint, format, typecheck and build clean.
  • New: decoyPrincipal.spec.ts (derivation, stability, case folding, UUID shape, shape
    distribution, production secret refusal), defineRouteDecoy.spec.ts (the registration
    guard and the dispatch invariant), decoyContinuation.spec.ts (all 15 endpoints plus
    "no writes"), loginTimingFloor.spec.ts.
  • The indistinguishability test compares an unknown, an unverified, a no-method and a
    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.json and src/generated/api.ts regenerated; the only diff is the removed
    /login 401.

Notes

  • docs/api-contract.md had /login documented as returning 401 and not 423. Both
    corrected.
  • A pre-existing true-positive CodeQL finding at loginPolicyService.ts:82 (passkeyAvailable in the login body downgrades a passkey-only policy #213) appears
    to have been fixed already; resolveAvailableLoginMethods no longer lets
    passkeyAvailable downgrade a passkey-only policy. Not touched here.

Brandon Corbett 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.
@Bccorb
Bccorb merged commit c502782 into main Sep 4, 2026
5 checks passed
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.

Make /login non-enumerable with decoy pre-auth tokens

1 participant