The friendly front door
After enough time passed trying to hack into webapps, you notice something a little sad: the friendliest apps are the easiest to break.
Not because friendly developers are careless, in fact, they cared so much about the user experience that they made the app helpful, and helpful is exactly what an attacker wants. Every kind little message on a login screen, “we don’t recognize that email”, “we sent you a link”, “that username is taken”, is a gift. It’s the app answering a question nobody asked out loud yet.
NOTE
None of this is about being mean to your users. It’s about noticing that some kindnesses are addressed to the wrong audience. Please, don’t be mean.
Leak #1: “We don’t have that email”
You’ve seen the two flavours of failed login:
✗ We don't have an account with that email.✗ Invalid email or password.The first one is lovely UX. It’s also telling an attacker which half of the guess was wrong. Type john.doe / hunter2 and if the app says “no such account”, they’ve learned nothing. But if it says “wrong password”, they’ve just learned that john.doe is a real user. Your login form is now a directory lookup, and an attacker can start doing user enumeration.
The fix is simple: one message for both failures. “Invalid email or password.” Don’t say which knob was wrong.
Leak #2: “Check your inbox, jo****@nexus.corp”
Password reset is where good intentions really pile up. The classic:
✓ Reset instructions sent to jo****@nexus.corpLook how considerate that is. It reassures the user the email is on its way, and it masks most of the address so it “feels” secure. Two problems, and an attacker enjoys both:
- It confirms the account exists (enumeration again, through a different door).
- It leaks the shape of the email:
jo****@nexus.corp. If you let users reset by just knowing the user name, now the mail domain is known, roughly the length, and the first couple of letters. That’s plenty to guessjohn.doe@nexus.corpand go phish him directly. (even more if an attacker already have the username.)
It’s a very common pattern precisely because it looks responsible. The masking is security theater: it hides characters from a value an attacker has mostly reconstructed anyway. Full names, company mail pattern, these are not hard to get.
TIP
The move is to put the helpful detail where only the real owner can read it: their inbox. The browser gets one boring line no matter what: “If an account exists for that address, we’ve sent a reset link.” The actual “here’s your account” warmth lives in the email, an authenticated channel. Same kindness, correct audience.
Leak #3: the leak you can’t see
Say you did everything above. Same message for every failure, nothing masked, nothing confirmed. Is an attacker stuck?
Not quite, because there’s a tell that don’t need words: time.
Submit a valid username and the server usually looks up the user and runs a real password hash comparison like bcrypt, argon2, something deliberately slow (as it should be!).
Submit a garbage username and there’s no user, so there’s nothing to hash against, and the server answers instantly. Identical message, very different response time. Now, an attacker enumerates your users without a single word of help from the UI, just using time.
user = db.find_user(email)
# Even when there's no user, compare against a dummy hash# so both paths cost roughly the same:reference = user.password_hash if user else DUMMY_HASHok = verify(password, reference) # constant-ish work either way
if not user or not ok: return "Invalid email or password."This time the fix is to always do the expensive work even when you’re going to reject the request.
Leak #4: the side doors
Even if the login and reset screens are locked down, the signup form is often wide open and just as chatty:
- “That email is already registered.” → enumeration.
- “Username taken” with a little red x that fires as you type → enumeration, now with a real-time API an attacker can script against.
- A “resend confirmation email” link that only shows up for addresses that actually exist → enumeration, quietly, without a single error message.
Keep the responses consistent everywhere an account can be probed instead of announcing it to whoever’s typing.
Leak #5: the lockout is pointed at you
Here’s a nasty one, because it’s a security feature that turns into a weapon.
To stop brute-forcing, someone adds: “5 wrong attempts and the account is locked for 30 minutes.” Sounds responsible, and against a lone password-guesser it genuinely is.
But imagine an attacker has already enumerated your users, through Leak #1 for example. To disrupt you, they don’t need to guess a single password, they just fail five logins for every account they know exists and lock users out of your app. The protective lockout becomes a one-line denial-of-service.
WARNING
Per-account lockout protects one thing (that account’s password) while creating another attack vector (a trivial DoS against every known user).
If you must have it, gate it behind an email unlock, and lean on rate-limiting by IP/behavior + step-up friction (a delay, a CAPTCHA after N tries…) instead of a hard account freeze.
The fix is to be kind where it matters
Notice the shape of every fix above. It’s the same move: stop letting the browser answer questions it shouldn’t, and be identically dull no matter what the truth is. Reserve kindness for private discussions.
Here’s the whole checklist for the above leaks:
- One generic message for every auth failure.
- Never confirm whether an account exists to an unauthenticated visitor.
- Do the expensive work (the hash compare) even on a miss, so timing doesn’t rat you out.
- Put the real helpfulness in the inbox, where only the owner reads it.
- Rate-limit by behavior, not by nuking individual accounts.
And here’s the part we should take away: most of the time, this isn’t even a tradeoff. “Invalid email or password” and “if an account exists, we’ve emailed you” are perfectly clear, calm screens. The leaky versions don’t actually serve the user any better, they just feel more helpful. You give up nothing real and security wins for free.
Where a real tradeoff does exist, it’s the forgot-password flow: someone who misremembers which email they signed up with types the wrong one, gets a calm “if an account exists, we’ve emailed you,” and then waits on a mail that never arrives, instead of an instant “no account with that address, try another.”
Now, one mildly confused user has to go check their inbox, but it is better than giving an attacker your entire user directory, a phishing target’s address, or a company-wide lockout button. Put those two on a scale and the tradeoff isn’t close.
| So: be kind to your users. Just don’t be kind to whoever’s probing the login form.