Syed Hasibur Rahman18 min read
The 202 That Tells You Nothing: Anti-Enumeration Engineering in Auth Systems
Every difference between your system's answer for a user that exists and a user that does not is a database query an attacker runs for free. This post treats anti-enumeration as an engineering discipline rather than a checklist item: the three leak channels (response shape, wording, timing), the uniform-response patterns that close them — the always-202 password-reset and OTP start, the decoy success shape for registration — the timing side channels that survive naive fixes and the dummy-hash and constant-time-comparison techniques that actually work, and the places where uniformity genuinely conflicts with honesty (support, debugging, counterparty notice) with a principled resolution: uniform to strangers, honest through the authenticated channel. Grounded in OWASP's testing guide and cheat sheets, and in real enumeration CVEs in OpenSSH and GitLab.
The ticket was filed by a product manager and it was, on its own terms, completely reasonable: “Bug: password reset says ‘check your email’ even when the email doesn’t exist. Users mistype their address, see a success message, wait for an email that never comes, and churn. Please show an error for unknown emails.”
The engineer assigned to it did what the ticket asked. Two weeks later, a security researcher reported — politely, through the disclosure program — that the password-reset endpoint could be used to test any email address against the user database at 50,000 addresses an hour, and that for this particular product (a mental-health app), “this address has an account here” was itself sensitive personal information. The fix was reverted. The PM’s ticket was reopened, because the underlying UX complaint was still real. And the team finally had the conversation this post is about: the vague success message was never sloppiness. It was a security control. It just was never written down as one, so the first person with a legitimate grievance dismantled it.
User enumeration is the quietest vulnerability class in authentication. It has no memory corruption, no injection, no CVSS drama — just a boolean, leaked one account at a time. This post treats its prevention as the engineering discipline it deserves to be: what enumeration actually costs, the three channels it leaks through, the uniform-response patterns that close each channel, the timing side channels that survive the obvious fixes, and — the part most treatments skip — what to do about the fact that uniformity is genuinely in tension with honest UX, debuggability, and support, and pretending otherwise is how the control gets dismantled by the next reasonable ticket.
What an existence bit is worth
Start with the attacker’s ledger, because “an attacker can learn whether an email has an account” sounds trivial until you price it.
Credential stuffing gets a pre-filter. Stuffing runs breached credential lists against your login. Against a list of 10 million pairs, the expensive part is 10 million login attempts through your bot defenses. An enumeration oracle lets the attacker first reduce the list to the accounts that exist — typically a few percent — before touching the defended endpoint. Your login rate limits now defend a target set the attacker has already refined a hundredfold for free, on an endpoint you probably rate-limit less aggressively because “it doesn’t do anything.”
Phishing gets a targeting list. “Your account needs verification” converts dramatically better when sent only to addresses that actually have accounts. Enumeration turns spray into spear.
Existence is sometimes the whole secret. For a dating service, an addiction-recovery program, a clinic portal, a political organization, membership is the sensitive datum. The canonical demonstration predates most modern guidance: when Ashley Madison breached in 2015, Troy Hunt documented that the site’s password-reset flow had tried to be uniform — same message for known and unknown emails — but the response page for existing accounts differed in other details, so the site had been disclosing its membership to anyone who asked, for years, before any breach1. Hunt’s broader catalogue of the pattern across mainstream sites — enumeration via reset flows, signup flows, and login wording — remains the best empirical tour of how common the leak is2.
OWASP treats this as a first-class test category: WSTG-IDNT-04, Testing for Account Enumeration and Guessable User Account, instructs testers to probe exactly these flows for response differences — and notes explicitly that both the content and the timing of responses are in scope3.
The three channels
Everything below is a variation on one theorem: if any observable property of the response is a function of account existence, the endpoint is an existence oracle. The observable properties come in three families.
Channel 1: response shape. Status code (200 vs 404, 200 vs 401 variant), redirect target, response size, JSON structure ({"error": "user_not_found"} vs {"error": "wrong_password"}), presence of a field, a Set-Cookie that only appears on real accounts. Shape leaks are the crudest and the most common, and they hide in places far from the login form — GraphQL nodes, autocomplete endpoints, “check username availability” APIs, error pages.
Channel 2: wording. “No account with that email” vs “incorrect password.” “This username is taken.” The classic login pair that OWASP’s Authentication Cheat Sheet addresses with its blunt prescription: respond to all failed authentication with the same generic message — “Login failed; Invalid user ID or password” — regardless of which half was wrong4. Wording leaks are shape leaks with better grammar; they get their own channel only because they are so often reintroduced deliberately, by tickets like the one in the opening.
Channel 3: timing. The response says nothing; the clock says everything. If the exists-path does work the not-exists path skips — a bcrypt verification, an email dispatch, a third join — the duration difference is measurable, and “measurable” is a lower bar than intuition suggests: Crosby, Wallach, and Riedi’s classic study of remote timing measurement found attackers could resolve differences down to roughly 15–100 µs across the Internet and ~100 ns on a LAN with enough samples, because repeated measurement and good filtering strip the network jitter that developers assume will hide them5. A bcrypt verify costs tens of milliseconds — five orders of magnitude above that floor. Timing is not the exotic channel; it is the one your other fixes silently leave open.
Three CVEs, three channels, in the wild
The channels are not hypothetical; each has a marquee CVE.
CVE-2018-15473 — shape, in OpenSSH. Through OpenSSH 7.7, sshd parsed authentication packets in an order that depended on whether the username existed: for an invalid user it bailed out and returned SSH2_MSG_USERAUTH_FAILURE immediately, while for a valid user it continued parsing the (deliberately malformed) packet and fatal()-ed, closing the connection. Failure message versus dropped connection: a perfectly clean one-bit oracle, in the most widely deployed authentication daemon on earth, exploitable with a few lines of Python — and promptly weaponized in public mass-enumeration tools6. The instructive part is why: nobody designed that difference. It fell out of the order of validation and parsing. Enumeration resistance lost to a refactoring nobody thought of as security-relevant.
CVE-2016-6210 — timing, in OpenSSH. Two years earlier, the same daemon leaked the same bit through the clock: when a user did not exist, sshd hashed a static fake password with Blowfish, but real users on modern systems had SHA256/SHA512-hashed passwords — and those hashes cost measurably more to compute, especially when an attacker submitted a very long password (~10 KB) to amplify the difference. Time the response, learn the bit7. Note the shape of the failure: OpenSSH had the right idea — do fake work for nonexistent users — but the fake work was not the same work, and “roughly similar effort” is exactly what a thousand samples and a t-test defeat.
CVE-2021-4191 — shape again, in GitLab’s GraphQL API. All GitLab versions since 13.0 (fixed February 2022) allowed unauthenticated GraphQL queries to walk the user list — usernames, names, sometimes emails — because an authentication check was missing on certain query paths, even on instances configured to restrict public visibility. Rapid7’s Jake Baines reported it and shipped a Metasploit module that harvests the directory wholesale8. The lesson generalizes viciously: you can perfect your login and reset flows and still run an enumeration service, because every API surface that resolves identifiers is in scope — GraphQL nodes, REST 404-vs-403 semantics, avatar URLs, calendar-free/busy lookups, payment-recipient confirmation.
The uniform-response discipline
The engineering answer is a discipline, not a patch: for every unauthenticated flow that takes an identifier, define one response shape — status, body, headers, size class, and (next section) time — and return it unconditionally, moving all existence-dependent work out of the response path. Flow by flow:
Password reset and magic links: the always-202. The endpoint takes an email and returns 202 Accepted — semantically exact per RFC 9110: the request has been accepted for processing, with no claim about the outcome9 — with the same body every time: “If that address has an account, we’ve sent a reset link.” Whether the account exists decides only what happens after the response is committed, in a background worker: send a reset link, or send nothing (better: send a different email — see the honesty section, because this is where the resolution lives). OWASP’s Forgot Password Cheat Sheet prescribes exactly this: consistent message for existent and non-existent accounts, consistent response time, and side effects deferred so they cannot modulate the response10.
The naive version and the disciplined version, side by side:
# LEAKY — the ticket-driven version
@app.post("/password-reset")
def reset(req):
user = db.find_user(email=req.email)
if user is None:
return json(404, {"error": "no account with that email"}) # shape + wording
send_reset_email(user) # SMTP latency inside the request: timing
return json(200, {"ok": True})
# UNIFORM — one shape, one time, side effects off-path
@app.post("/password-reset")
def reset(req):
if not valid_email_syntax(req.email):
return json(400, {"error": "invalid email format"}) # syntax is not existence
reset_queue.enqueue(req.email) # worker resolves existence + sends
return json(202, {"message": "If that address has an account, "
"we've sent a reset link."})
The enqueue is the load-bearing move. The request handler never learns whether the user exists, so it cannot leak it — by status, wording, or time. (Rate-limit the queue per-address and per-IP, or you have built a free email-bombing service; anti-enumeration and anti-abuse are separate properties and you need both.)
OTP and phone-based starts: same pattern. POST /otp/start takes a phone number, returns 202 with a uniform body, and a worker decides whether an SMS actually goes out. Phone enumeration is more sensitive than email in most jurisdictions’ eyes — a phone number maps to a person — and SMS costs money, so the per-recipient rate limit is doing double duty.
Login: one message, one cost. The generic failure message per OWASP’s Authentication Cheat Sheet4, and — critically — the same work on both paths, which is the next section’s subject.
Registration: the decoy success. Signup is the flow where teams give up, because “an account with this email already exists” feels unavoidable — the user has to know, right? They do not, and the standard fix is the decoy success shape: registration always responds “we’ve sent a confirmation link to that address”; if the address was fresh, the email contains the verification link; if it was already registered, the email says “you already have an account — here’s a sign-in link / password reset.” The stranger at the keyboard sees one shape; the owner of the mailbox — the only party entitled to the truth — gets the truth. OWASP’s WSTG and the authentication guidance both point to this verify-by-email pattern as the enumeration-safe signup34. Username-choice systems (where handles are public anyway) are a different regime — see the “when not to bother” section.
Account-recovery adjacent surfaces. “Check username availability,” “invite user,” “add payment recipient,” password-manager-style breach checks: every one of these resolves an identifier and needs either the uniform treatment, authentication in front of it, or an explicit decision that enumeration is acceptable there.
Timing: where naive uniformity goes to die
Everything above can be implemented perfectly and still leak through channel 3, because the work differs. Three techniques close the gap, in increasing order of subtlety.
Defer the variable work. Already covered: anything expensive and existence-dependent (email dispatch, SMS, audit writes) goes behind a queue. WSTG calls out exactly this leak — an external mail call adds hundreds of milliseconds precisely when the account exists3. The 202 must be committed before the system does anything the two paths do differently.
The dummy hash. Login’s problem: verifying a real user costs a bcrypt/argon2 verification (tens of milliseconds by design — that slowness is the point of a password hash); a nonexistent user costs one failed SELECT (microseconds). The fix is to make the miss path pay the hit path’s bill: when the user is not found, verify the submitted password against a static dummy hash anyway. Django’s ModelBackend has shipped exactly this for years — on a missing user it runs the default password hasher once, and the source comments name the reason: reducing the timing difference between existing and nonexistent users11.
DUMMY_HASH = bcrypt.hashpw(b"correct horse battery staple", bcrypt.gensalt(12))
def authenticate(email: str, password: str) -> User | None:
user = db.find_user(email=email)
if user is None:
bcrypt.checkpw(password.encode(), DUMMY_HASH) # pay the same cost
return None
if not bcrypt.checkpw(password.encode(), user.password_hash):
return None
return user
Mind CVE-2016-6210’s lesson while doing this: the dummy work must be the same algorithm at the same cost parameters as the real work. A Blowfish dummy against SHA512 reality is how OpenSSH lost7. And keep the cost parameters in one place, so the next Argon2 migration updates both paths together.
Constant-time comparison. Wherever a secret is compared — reset tokens, OTP codes, API keys, session tokens — == short-circuits at the first differing byte, leaking how much of the guess was right, byte by byte. This is the oldest news in the side-channel literature — Kocher’s 1996 paper founded the field on exactly this class of data-dependent timing12 — and the remediation is a stdlib call: Python’s hmac.compare_digest, Node’s crypto.timingSafeEqual1314. For token lookups the stronger pattern is hash-then-lookup: store SHA-256(token), look up by the hash’s value (the DB index comparison is then over uniformly distributed data with no exploitable structure), and you get constant-time behavior plus tokens-at-rest protection in one move.
const crypto = require("node:crypto");
function tokenMatches(presented, storedHash) {
const presentedHash = crypto.createHash("sha256").update(presented).digest();
return crypto.timingSafeEqual(presentedHash, storedHash); // equal-length by construction
}
A word on proportion: do not respond to channel 3 by adding random sleeps everywhere. Random delay raises the sample count an attacker needs (noise averages out — Crosby et al.’s filtering is designed for exactly this5); equalizing the work removes the signal instead of blurring it. Randomization is a defense-in-depth garnish, not the meal.
The leaks around the edges
Uniform shapes on the front doors, and the bit still escapes through the vents. A field checklist from real assessments:
- Rate limiters as oracles. If per-account limits only exist for real accounts, then hitting a 429 is the existence bit. Unauthenticated flows should rate-limit on attacker-controlled dimensions (IP, identifier string) identically for hits and misses.
- Response size. Same status and wording, but the exists-path body is 412 bytes and the miss is 380 — enumerable through TLS by length alone. Return literally identical bytes.
- Secondary flows disagreeing. Your reset flow is uniform; your signup flow still says “already registered”; the attacker uses signup. Uniformity is a property of the identifier surface as a whole, which is why it needs an inventory, not a fix-list.
- The email itself. If reset emails only go to existing accounts and the attacker controls, or can observe, the mailbox (shared inboxes, catch-all domains), delivery is the oracle. This is inherent to the pattern — hence sending the decoy “no account here” email, which also happens to be the honest thing to do (next section).
- Cache and CDN behavior. Per-user cache keys,
Varydifferences, or an edge cache that stores the miss but not the hit (or vice versa) reintroduce shape and timing differences above your application. - Infrastructure truthfulness. GitLab’s CVE-2021-4191 again: the auth stack was uniform; the GraphQL layer simply forgot to ask8.
Where uniformity and honesty genuinely conflict
Here is the part that determines whether the control survives contact with your own organization, because every uniform response is a small lie of omission told to someone, and three constituencies have legitimate claims on the truth.
The mistyping user (the opening ticket). She typed gamil.com, got a cheerful 202, and no email will ever come. The uniform response has genuinely worsened her experience — Troy Hunt, in the same posts that document enumeration leaks, is candid that the uniform message trades UX away for privacy1. The resolution is that the lie is only told at the anonymous surface; the truthful channel still exists — it is the mailbox. Send email on both paths: reset link if the account exists; if not, a “you don’t have an account with this address (someone may have mistyped — or you signed up with a different email)” note when the address is deliverable. The stranger sees uniformity; the mailbox owner gets honesty. What remains genuinely unfixable at the anonymous surface is the typo’d address that belongs to nobody — for that, invest in client-side syntax and domain-typo hints (did you mean gmail.com?), which leak nothing because they never consult the database.
Support and debugging. “Customer says password reset is broken” lands on an engineer whose logs, if they were made externally uniform internally too, now say nothing. This one is a false conflict, cleanly resolved by drawing the trust boundary correctly: uniformity is a property of unauthenticated, externally observable responses — it is not a property of logs, traces, metrics, or admin tooling. Log the real outcome (found / not-found / suppressed-send) with full fidelity behind your access-controlled observability stack; give support staff an internal lookup tool, gated by authentication, authorization, and an audit trail. The support agent then completes the pattern by applying the same discipline socially: verify the caller’s identity before disclosing account existence, because “does this email have an account?” asked over the phone is enumeration with a human API. (Your support desk is an identifier surface. Social-engineering assessments treat it as exactly that.)
Counterparty and contractual notice. B2B and regulated contexts create obligations to be truthful about account state: an enterprise admin is entitled to know which invited employees have accounts; a bank’s business customer may be contractually owed confirmation that a payee identifier resolves; incident-response law may require notifying affected account holders — an act that itself confirms account existence. The resolution is the same principle at a different altitude: these are not strangers. They are authenticated counterparties inside a contractual relationship, and disclosure to them flows through that authenticated relationship (the admin console, the signed API with mutual auth, the formal notice process) — never through the anonymous surface. The enterprise admin sees her own tenant’s roster, not an oracle over your whole user base.
One sentence covers all three: uniform to strangers; honest through the authenticated or contractual channel. The anonymous HTTP response is the only place the existence bit must not appear, because it is the only channel where you cannot know who is asking. Every legitimate claim on the truth can be routed through a channel where you do.
And the mirror-image judgment call — when not to bother: systems with public identity semantics (GitHub-style public usernames and profile pages) cannot meaningfully hide handle existence, and contorting signup to pretend otherwise buys nothing; WSTG itself frames the concern as contextual3. Enumeration resistance protects a confidentiality property — “membership here is private” or “this identifier space feeds authentication.” Where the property does not exist (public handles) or is priced differently (the email address space feeding your login always qualifies), spend accordingly. Threat model first; discipline second; cargo cult never.
Making it hold: uniformity as a tested invariant
The opening story’s real failure was organizational: the control existed but was illegible, so a reasonable ticket deleted it. Three practices make it durable:
- Write it down as policy. One paragraph in the security standards: “Unauthenticated responses must not vary by account existence — in status, body bytes, headers, or measurable time. The truthful channel for account state is email to the address itself, or an authenticated support/admin surface.” Now the PM’s ticket gets a citation in its wontfix, and an alternative that actually fixes her problem.
- Test it like any invariant. For each identifier surface, a CI test that calls the endpoint with a known-existing and known-missing identifier and asserts byte-identical bodies, identical status and headers, and response-time deltas under a threshold across n trials. It will not catch a determined statistical attack; it will catch the refactor that reintroduces
user_not_found— which is the failure mode that actually happens, as OpenSSH’s parse-order bug shows6. - Inventory the identifier surfaces. Login, signup, reset, OTP start, username availability, invites, GraphQL/REST object fetches, support tooling. The list is the attack surface; CVE-2021-4191 is what the unlisted item costs8.
Closing: the nothing is the product
There is a mindset shift hiding in this topic. Engineers are trained to make systems informative — precise errors, rich responses, observable behavior. Anti-enumeration asks for the opposite at one specific boundary: a response engineered to carry zero information beyond “received.” The 202 that tells you nothing is not an unfinished feature or a lazy error handler. It is a constructed artifact with a specification — same bytes, same time, same everything, regardless of the one bit the caller is fishing for — and it takes more engineering than the talkative version: queues to move side effects off-path, dummy hashes to equalize work, constant-time comparisons, tests to hold the line, and an org-level document explaining why the “bug” is load-bearing.
The judgment: treat account existence as data with an access-control policy, and treat your anonymous response surface as an unauthorized reader. Strangers get the uniform nothing, every time, on every identifier surface, at every observable resolution — status, bytes, and microseconds. The people with a right to the truth — the mailbox owner, the verified support caller, the contracted counterparty — get it through channels that know who they are. OpenSSH leaked the bit twice through pure implementation drift67; GitLab leaked the whole directory through one unguarded query path8; Ashley Madison’s users learned what membership disclosure costs in the worst possible way1. None of them decided to build an enumeration oracle. That is the point. Nobody ever decides to. You either engineer the nothing, or you ship the oracle.
References
Hunt, T., “Your affairs were never discreet – Ashley Madison always disclosed customer identities”, July 2015. https://www.troyhunt.com/your-affairs-were-never-discrete-ashley/ ↩︎ ↩︎ ↩︎
Hunt, T., “Website enumeration insanity: how our personal data is leaked”, 2017. https://www.troyhunt.com/website-enumeration-insanity-how-our-personal-data-is-leaked/ ↩︎
OWASP, “WSTG-IDNT-04: Testing for Account Enumeration and Guessable User Account”, Web Security Testing Guide (stable). https://owasp.org/www-project-web-security-testing-guide/stable/4-Web_Application_Security_Testing/03-Identity_Management_Testing/04-Testing_for_Account_Enumeration_and_Guessable_User_Account.html ↩︎ ↩︎ ↩︎ ↩︎
OWASP Cheat Sheet Series, “Authentication Cheat Sheet” — authentication and error messages. https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html ↩︎ ↩︎ ↩︎
Crosby, S., Wallach, D., and Riedi, R., “Opportunities and Limits of Remote Timing Attacks”, ACM Transactions on Information and System Security (TISSEC), 2009. https://www.cs.rice.edu/~dwallach/pub/crosby-timing2009.pdf ↩︎ ↩︎
NVD / CVE, “CVE-2018-15473: OpenSSH through 7.7 user enumeration via malformed authentication packets”, August 2018. https://www.cvedetails.com/cve/CVE-2018-15473/ ↩︎ ↩︎ ↩︎
NVD / CVE, “CVE-2016-6210: OpenSSH before 7.3 user enumeration via timing of password hashing”, July 2016. https://www.cvedetails.com/cve/CVE-2016-6210/ ↩︎ ↩︎ ↩︎
Baines, J. (Rapid7), “CVE-2021-4191: GitLab GraphQL API User Enumeration (FIXED)”, Rapid7 Blog, March 2022. https://www.rapid7.com/blog/post/2022/03/03/cve-2021-4191-gitlab-graphql-api-user-enumeration-fixed/ ↩︎ ↩︎ ↩︎ ↩︎
Fielding, R., Nottingham, M., and Reschke, J., “HTTP Semantics”, IETF RFC 9110, June 2022, §15.3.3 (202 Accepted). https://www.rfc-editor.org/rfc/rfc9110 ↩︎
OWASP Cheat Sheet Series, “Forgot Password Cheat Sheet”. https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html ↩︎
Django source,
django/contrib/auth/backends.py—ModelBackend.authenticateruns the default password hasher on nonexistent users to reduce the timing difference. https://github.com/django/django/blob/main/django/contrib/auth/backends.py ↩︎Kocher, P., “Timing Attacks on Implementations of Diffie-Hellman, RSA, DSS, and Other Systems”, CRYPTO ‘96, LNCS 1109, 1996. https://link.springer.com/chapter/10.1007/3-540-68697-5_9 ↩︎
Python documentation, “hmac — Keyed-Hashing for Message Authentication” —
hmac.compare_digest. https://docs.python.org/3/library/hmac.html#hmac.compare_digest ↩︎Node.js documentation, “crypto.timingSafeEqual(a, b)”. https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b ↩︎