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.
- Capability
- /docs/authentication/

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_credentialsinto “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.