Skip to content
Back to field notes
AuthJune 6, 20266 min read

Account enumeration via login and password reset in AI-built apps

AI-generated login and reset handlers often reply differently for known versus unknown emails, letting an outsider build a list of your real users before you launch. This note shows how to test for it and what the fix does and does not cover.

AuthEnumerationLogin

What an attacker can confirm before they ever guess a password

The concrete risk is narrow and easy to miss: an outsider can find out which email addresses have accounts on your app, one address at a time, without logging in. This is an account enumeration attack, and it lives in the most ordinary parts of an auth flow — the login form, the password reset form, and sometimes the signup form. None of these need to be broken for the leak to happen. They just need to answer differently depending on whether the email is already registered.

What is observable: the app returns one response for a known email and a different response for an unknown one. The difference can be a message ("no account found" versus "wrong password"), an HTTP status, a redirect, a JSON field, or how long the request takes. Any one of those is enough to sort a list of emails into "has an account here" and "does not."

What is only possible from there: that confirmed list becomes the input to the next step. Knowing an email is a real user is what makes credential stuffing, targeted phishing, and password-guessing worth the attacker's time. Enumeration itself does not log anyone in. It is a disclosure problem — it tells an outsider who your users are. This maps to the CWE class for observable response discrepancy and to the OWASP guidance on not revealing account existence in auth responses. Keep the two ideas apart: enumeration is about who exists (information disclosure), not about who gets in (a broken authentication or authorization control).

Why AI-built login and reset handlers leak this

The generated default optimizes for a helpful user experience, and helpfulness is exactly what leaks here. Ask an assistant to "build a login form with good error messages" and you tend to get precise feedback: "We couldn't find an account with that email" on one path, "Incorrect password" on another. That feels like better UX. It is also a public signal that distinguishes a real account from a typo.

Password reset is the most common offender. A prompt like "send a reset link if the email exists" produces a handler that branches: look up the user, and if found, send the email and say "Check your inbox," and if not found, say "That email is not registered." The branch is the leak. The same pattern shows up in signup forms that say "This email is already taken."

A few build-time habits make it worse in AI-generated apps:

The skipped step is the same one that gets skipped elsewhere: nobody reviewed what the app says to a stranger probing an address it has never seen.

  • The happy path is what gets tested by hand, so the unknown-email path keeps whatever message the generator wrote, unreviewed.
  • Timing is invisible in manual testing. A handler that hashes a password only when the user exists will answer faster for unknown emails, and no one notices a few dozen milliseconds by clicking.
  • Off-the-shelf auth libraries and backends differ in their defaults. Some return a generic response out of the box; some surface "user not found" unless you change a setting. The generated code rarely changes that setting because the default already "works."

Test your own app from the outside

You can confirm this on your own app with no attack and no third-party target. Pick two emails you control: one that has a real account, and one you are certain does not. Then compare the responses carefully. Use your browser DevTools Network tab, or a request tool like curl, so you see the raw status, body, and timing rather than just the rendered page.

Run each of these against the known and the unknown email and compare:

Then check timing. Send the same request several times for each email and watch the response time in the Network tab. Suspicious result: one email is consistently and noticeably faster or slower than the other. A repeatable gap usually means the code does extra work (a password hash, an email send) only on one branch. A safe result is response times that overlap and do not sort cleanly by whether the account exists.

A note on honesty: a single side-by-side comparison can be misleading because of network noise. The signal you trust is a difference that repeats across many attempts. That is exactly why this check is behavioral and not a one-shot scan.

  • Login with a deliberately wrong password. Suspicious result: the known email returns "wrong password" while the unknown returns "no account found," or the two return different HTTP statuses or redirects. Safer result: both return the identical generic message and status, for example "If those credentials are valid you'll be signed in."
  • Password reset. Suspicious result: the known email says "Check your inbox" and the unknown says "Email not found," or only one triggers a network response. Safer result: both show the same "If an account exists for this email, we've sent a link" message regardless of whether the address is registered.
  • Signup. Suspicious result: an already-registered email returns "email already in use" while a fresh one proceeds. This one is a real design tension, covered below.

Make the responses identical, and know what that does not cover

The core fix is short and the same everywhere: make the user-facing response identical whether or not the account exists, on every auth entry point.

What this fix does not cover, stated plainly:

  • Password reset: always return the same message ("If an account exists for this email, we've sent a reset link") and the same status, then send the email only when the user is real. The caller cannot tell the difference; the legitimate user still gets their link.
  • Login: return one generic failure ("Invalid email or password") for every wrong attempt, with the same status code, regardless of which part was wrong. Do not distinguish "no such user" from "bad password."
  • Timing: do the same expensive work on both branches, or none of it. The standard pattern is to run a password hash comparison even when no user was found, against a dummy hash, so a failed lookup costs the same time as a failed password. Many auth libraries do this for you; confirm yours does rather than assuming.
  • Signup genuinely needs to tell a user their email is taken, or they cannot recover their own account. The mitigation here is partial: you cannot fully hide existence at signup without hurting real users. Reduce the exposure instead — rate-limit the endpoint, require the reset/verify flow to reveal nothing extra, and consider sending account-exists notices by email rather than in the form response. Treat signup enumeration as reduced, not eliminated.
  • Identical messages do not stop someone from probing. They remove the cheap signal; they do not remove the attempt. Pair the message fix with rate limiting and monitoring on these endpoints so high-volume probing is throttled and visible.
  • This is an information-disclosure fix. It does not address whether your login itself can be bypassed or whether a logged-in user can reach data they should not — those are separate authentication and authorization problems. If your concern is the login gate itself, see the note on auth bypass in middleware; if it is the magic-link channel, see the note on magic-link security.

Closing the message and timing gaps reduces enumeration to guesswork, but it cannot make it impossible. Side channels remain — signup behavior, rate-limit responses, even how fast a real reset email arrives. The realistic goal is to remove the easy, scriptable signals and make the rest noisy enough to be impractical, not to claim enumeration is solved.

Where VibeCodeGuard fits, and what it does not prove

Honest answer: a public-surface scan is not the right tool to catch account enumeration, and VibeCodeGuard's Launch Check will not catch it for you. Finding this leak means sending many login and reset requests across known and unknown emails and comparing how the replies vary by message, status, and timing. That is behavioral authentication testing, and it lives outside what a scan of your public surface inspects. The fix and its verification need manual testing or dedicated auth-testing tooling — there is no shortcut that confirms it from the outside in one pass.

What the Launch Check does cover is the adjacent public surface that an enumerated user list becomes dangerous against: exposed secrets in your client bundle, missing security headers, source maps, reachable build or deploy artifacts, and open CORS. Closing those does not fix enumeration, and a clean Launch Check says nothing about whether your login and reset handlers leak account existence. Treat that as a separate, manual check you own.

A scan cannot prove your auth responses are uniform, because uniformity is a property of behavior over many requests, not of a single page fetch. Run the two-email comparison above against your own login and reset flows before launch, fix any message or timing difference you find, then add rate limiting on those endpoints. If you want a focused Launch Check on the public surface as well, run it before you share the URL — just don't mistake a clean scan for an enumeration-free auth flow.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.