DEV Community

Kamran Khan
Kamran Khan

Posted on

My login error message never says which half was wrong

The worst version of a login endpoint tells the attacker exactly what they need:

  • "No account with this email" — try another email.
  • "Incorrect password" — you found a real account, now work on the password.

That is user enumeration, and it turns a login form into an account-discovery API. My login endpoint returns the same response for a missing email and a wrong password:

$user = User::where('email', $request->validated('email'))->first();

if (! $user || ! Hash::check($request->validated('password'), $user->password)) {
    return response()->json(['message' => 'Invalid credentials'], 401);
}
Enter fullscreen mode Exit fullscreen mode

One condition, one message, one status code. A wrong email and a wrong password are indistinguishable from the outside.

The tradeoff is honest: legitimate users get a slightly less helpful error. "Invalid credentials" does not tell you whether you mistyped your email or your password. I accept that, because the frontend renders the message as-is and password managers make mistyped emails rare. Security wins over a marginally nicer error.

The real fix for brute force is rate limiting on the login route, which is a separate decision — but the generic message is the one that costs nothing and closes the enumeration hole on day one.

Small decisions like this are why I extracted my auth flow into the Laravel + React SaaS Starter Kit instead of rewriting it per project: https://kamranofficial.gumroad.com/l/mhekoig

Top comments (2)

Collapse
 
saqrelfirgany profile image
Ahmed ElFirgany •

One 401 with "Invalid credentials" for both cases hides the message difference. The timing still differs, though. The ! $user || check short-circuits, so a missing email skips bcrypt. That reply comes back faster than a wrong password. Checking a dummy hash when no user exists evens out the timing. Do the reset and register endpoints give the same uniform reply?

Collapse
 
kamrankhandev profile image
Kamran Khan •

You're right about the timing gap, and it's a real catch. The || short-circuit means a missing email never pays the bcrypt cost, while a wrong password always does. Bcrypt is deliberately slow, so the two failures are measurably different over the wire. The fix I reach for: when !$user, still run Hash::check against a precomputed dummy hash, then return the same 401. Both paths pay for one hash and the timing evens out. It costs one hash computation per failed attempt, which is fine sitting behind rate limiting.

On your question: the password reset endpoint already returns the uniform "if an account exists you'll receive an email" reply either way. Register is the deliberate exception, it tells you the email is taken, because a signup form that lies about that is unusable. That's a conscious tradeoff, mitigated with rate limiting. Login is the one endpoint where both halves stay hidden.