Skip to content

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

Description

@Bccorb

Follow-up to #58, which closed the part that could be closed safely.

Where #58 left it

POST /login now answers one identical 401 { "error": "Not Allowed" } for an unknown identifier, an unverified account, an account with no permitted continuation method, and a token-signing failure. The reason survives only in the login_failed auth event metadata.

What remains: a valid, verified identifier still gets 200 with an ephemeral token, so /login still separates "this account exists and can sign in" from everything else.

What closing it takes

Returning 200 with a decoy ephemeral token for identifiers that do not resolve to a usable account, so the initial response is identical in every case.

That is only worth doing if the decoy survives the next step. There are 15 ephemeral-auth endpoints today (8 OTP, 2 magic link, 4 WebAuthn, 1 TOTP), and each has to behave indistinguishably for a decoy token, otherwise the oracle simply moves one request later. Concretely:

  • OTP generate must report success without sending, and OTP verify must fail the way a wrong code fails.
  • Magic-link request must report success without sending.
  • WebAuthn options must return a plausible challenge for a subject with no credentials.
  • External delivery mode returns the OTP in the response body, so a decoy needs a fabricated code that is indistinguishable in shape.

Also needs deciding:

  • How a decoy subject is represented. A random UUID in sub keeps the token shape identical but means downstream code must not assume sub resolves to a row. A decoy claim is simpler to branch on but is visible to anyone who decodes the token, which defeats the purpose.
  • Whether timing is levelled. If the real path does a credential lookup and a policy read while the decoy path does not, response time is its own oracle.
  • Whether loginMethods moves off the /login response and behind a step that requires the ephemeral token. It cannot move before decoys exist, because until then the 401 already answers the question and removing it only degrades legitimate clients to a default method list. Both seamless-auth-react and seamless-cli already fall back to defaults when it is absent, so the removal itself is cheap when the time comes.

Contract impact

Contract-affecting. /login would stop returning 401 for unknown identifiers, which changes branching in any consumer that treats that as "no such user". Needs the ripple protocol across seamless-auth-react, seamless-auth-server, and seamless-cli.

Acceptance

  • /login is byte-identical and time-comparable for a valid identifier and an unknown one.
  • No ephemeral-auth endpoint distinguishes a decoy token from a real one.
  • Tests assert both, and docs/security-posture.md is updated to say the enumeration surface is closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestsecuritySecurity-sensitive: auth, tokens, OTP, sessions, crypto

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions