Security recipes · 04

User enumeration

The threat
Probe authentication endpoints to learn which email addresses have accounts, building a target list for stuffing, phishing and password spraying.
Sentinel's counter
Uniform error codes, dummy-hash timing equalization on unknown users, always-succeeding reset requests, and enumeration-safe 404s on owned resources.

The attack

Before credential stuffing comes shopping: the attacker feeds a mailing list to your login or password-reset endpoint and sorts addresses into “has an account here” and “doesn’t”. Any observable difference works — a different error message (Unknown user vs Wrong password), a different status code, a response 200 ms faster because the server skipped the hash check, or a reset endpoint that says “email sent!” only for real accounts. The verified list then feeds targeted phishing (“your Acme Health session expired…”) and credential stuffing with far better hit rates.

Enumeration is rarely treated as a vulnerability by the team that ships it — each leak is “just UX”. It compounds into the reconnaissance phase of every later attack.

How Sentinel counters it

One error for both cases. Unknown email and wrong password both return 401 with the same stable code, invalid_credentials — the problem+json body’s localized message is identical too. There is no user_not_found in the error vocabulary at all.

Timing is equalized by burning a real hash. The expensive step in a failed login is argon2id verification (~tens of milliseconds by configuration). A naive implementation skips it when the user doesn’t exist — a timing oracle readable across the network. LoginService verifies a dummy argon2id hash on user-miss, so the unknown-user path does the same cryptographic work as the wrong-password path. (The dummy hash is constant; the work factor, and therefore the timing, matches the real one.)

Password reset confirms nothing. PasswordResetService.RequestAsync returns success unconditionally — for a real account it sends the mail; for an unknown address it does nothing observable. Email verification behaves the same way. The reset token itself is single-purpose (typ: "pwreset+sentinel", 30-minute lifetime) with its JTI atomically deny-listed on use — the flow leaks nothing at either end.

Owned resources 404 uniformly. DELETE /profile/sessions/{id} answers 404 identically whether the session belongs to someone else or never existed — the profile API can’t be used to probe for valid session or user ids. The same posture runs through the admin service’s AdminResult mapping: not yours and not there are the same answer.

Federation discovery only serves verified domains. POST /auth/idp/discover maps an email to an org and IdP — deliberately public information for routing — but only verified OrganizationDomain entries participate, so the endpoint reveals “this company uses SSO here”, never “this individual has an account”.

And the rate limits assume you’ll try anyway. Enumeration at scale is a high-volume, many-identifiers-per-IP pattern — exactly what the credential-stuffing layer (200 distinct identifiers per IP per 10 minutes) and per-IP limits throttle, with adaptive CAPTCHA raising the cost before hard blocks. See credential stuffing.

What your app must still do

Sentinel closes the API-level oracles; the remaining ones live in your UX:

  • Registration is the classic leak. “This email is already registered” is enumeration — but a silent signup is hostile UX. The standard resolution: accept the registration request unconditionally and send either a welcome email or a “you already have an account” email — the browser learns nothing, the mailbox owner learns everything. That flow is your signup code; Sentinel provides the mailer port and single-purpose tokens to build it.
  • Keep your own messages uniform. If your login UI translates invalid_credentials into “we couldn’t find that account” under some client-side condition, you’ve re-opened the hole above the API.
  • Watch response-size oracles. Custom middleware that decorates auth responses (per-user headers, personalization) can differ between the two paths; keep auth routes boring.
  • Mind third-party signals — a marketing pixel that fires only for known users, a CDN cache rule that varies — enumeration oracles love the edges of the stack.