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.
Follow-up to #58, which closed the part that could be closed safely.
Where #58 left it
POST /loginnow answers one identical401 { "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 thelogin_failedauth event metadata.What remains: a valid, verified identifier still gets
200with an ephemeral token, so/loginstill separates "this account exists and can sign in" from everything else.What closing it takes
Returning
200with 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:
Also needs deciding:
subkeeps the token shape identical but means downstream code must not assumesubresolves to a row. Adecoyclaim is simpler to branch on but is visible to anyone who decodes the token, which defeats the purpose.loginMethodsmoves off the/loginresponse and behind a step that requires the ephemeral token. It cannot move before decoys exist, because until then the401already answers the question and removing it only degrades legitimate clients to a default method list. Bothseamless-auth-reactandseamless-clialready fall back to defaults when it is absent, so the removal itself is cheap when the time comes.Contract impact
Contract-affecting.
/loginwould stop returning401for unknown identifiers, which changes branching in any consumer that treats that as "no such user". Needs the ripple protocol acrossseamless-auth-react,seamless-auth-server, andseamless-cli.Acceptance
/loginis byte-identical and time-comparable for a valid identifier and an unknown one.docs/security-posture.mdis updated to say the enumeration surface is closed.