Skip to content
Founder engineering cover
writing

Authentication mistakes I see in early products, and the fix for each

The same handful of auth bugs show up in early products every time: session fixation, weak hashing, no rate limiting, and tokens in the wrong place. Here is the concrete fix for each, grounded in real backend work.

Hasan MisbahHasan Misbah7 min read
authentication
security
laravel
founder-engineering
TL;DR

Early products keep making the same four auth mistakes: session fixation, weak password hashing, no login rate limiting, and tokens in localStorage. Each has a cheap, specific fix you can ship before your next deploy.

The authentication bugs in early products are almost always the same handful, and each has a concrete, cheap fix. Session fixation, weak password hashing, no rate limiting on login, and tokens stored somewhere JavaScript can read them. None of these are exotic attacks. All of them are an afternoon to fix before launch and a very bad week to fix after someone’s account gets taken over. Here is each mistake and the exact fix I reach for, from building authentication into Laravel apps and the WordPress plugin ecosystem.

I am not going to lecture about “security is important”. You know that. The value here is specificity: the actual mistake, why it bites, and the two or three lines that close it.

Mistake one: session fixation

The bug: you assign a session ID before login and keep the same ID after login. An attacker who can set a victim’s session ID (via a crafted link, a shared machine, a sibling subdomain) is then logged in as that victim the moment the victim authenticates, because you never changed the identifier that represents “who is logged in”.

The fix is one line: regenerate the session ID at the moment the privilege level changes, which is login. In Laravel this is handled if you use the framework’s auth, but people bypass it with custom login flows:

public function login(Request $request): RedirectResponse
{
    // ... validate credentials ...

    $request->session()->regenerate(); // new ID now that identity changed
    Auth::login($user);

    return redirect()->intended('/dashboard');
}

Do the same on logout by invalidating the session entirely. The rule is simple: any time the answer to “who is this” changes, the session ID changes with it.

Mistake two: weak or fast password hashing

The bug: passwords stored with a fast hash (MD5, SHA1, a single SHA256) or, worse, in a form that can be reversed. WordPress historically leaned on phpass, which is far better than plain MD5 but is showing its age. A fast hash is a gift to anyone who gets a copy of your users table, because fast is exactly what a cracker wants: billions of guesses per second.

The fix is a slow, salted, adaptive hash built for passwords: bcrypt, or argon2id if you have it. Laravel defaults to bcrypt and you should let it:

// Good: adaptive, salted, deliberately slow.
$hash = password_hash($plain, PASSWORD_BCRYPT, ['cost' => 12]);

// Verify without ever reversing anything.
if (password_verify($input, $hash)) {
    // optionally: if password_needs_rehash(...), upgrade the stored hash here
}

“Deliberately slow” is the feature. Tune the cost so a single verify takes a fraction of a second on your hardware. That fraction is invisible to your one legitimate user logging in and brutal to an attacker running a billion guesses. And salting is automatic with these functions, so nobody should be managing salts by hand in 2026. If you are moving an existing user base off a weak hash, rehash on the next successful login and treat it like any other backward-compatible migration on an install base.

Mistake three: no rate limiting on login

The bug: the login endpoint accepts unlimited attempts. That turns every account into a target for credential stuffing (replaying leaked password lists) and brute force. Strong hashing protects a stolen database. It does nothing to stop someone hammering your live login form with a million guesses.

The fix is a limiter keyed on both the account and the source, so one attacker cannot lock out everyone and one victim account cannot be pounded forever:

$key = 'login:'.$request->ip().'|'.Str::lower($request->input('email'));

if (RateLimiter::tooManyAttempts($key, maxAttempts: 5)) {
    $seconds = RateLimiter::availableIn($key);
    abort(429, "Too many attempts. Try again in {$seconds}s.");
}

RateLimiter::hit($key, decaySeconds: 60);
// ... on success: RateLimiter::clear($key);

Pair it with a generic error message. “Invalid email or password” leaks nothing. “No account with that email” tells an attacker which addresses are worth guessing against. Small wording, real information leak.

Mistake four: tokens in the wrong place

The bug: storing a session token or JWT in localStorage because it was easy. Anything in localStorage is readable by any JavaScript on the page, which means one cross-site scripting hole hands your token to an attacker, and a token is a bearer of identity: whoever holds it is you.

The fix depends on what you are building, but the default I reach for is a cookie with the right flags rather than localStorage:

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/

HttpOnly keeps JavaScript from reading it, which defangs the XSS-steals-token attack. Secure keeps it off plaintext HTTP. SameSite blunts CSRF. If you genuinely need token-based auth for a separate frontend or mobile client, keep the tokens short lived, refresh them server side, and never treat a long-lived token sitting in browser storage as acceptable.

This is also the session vs token decision, and people pick token auth reflexively because it feels modern. For a classic web app served from one backend, server-side sessions with a hardened cookie are simpler and safer than hand-rolling JWT refresh logic. Reach for tokens when you actually have the problem they solve (a stateless API, multiple clients), not by default.

The pattern underneath all four

Every one of these is the same mistake wearing different clothes: trusting something you should not, or skipping a cheap control because the happy path worked in testing. Session fixation trusts an identifier across a privilege change. Weak hashing trusts that your database will never leak. No rate limiting trusts that only real users will hit your form. Tokens in localStorage trust that you will never ship an XSS bug. Every one of those trusts is eventually wrong.

The fixes are not clever and that is the point. Regenerate the session on login. Use bcrypt or argon2id. Rate limit the login endpoint by account and IP. Put the token in an HttpOnly cookie. You can close all four before your next deploy, and doing so quietly removes the most common ways early products get their users compromised. Authentication is not where you want to be original. It is where you want to be boringly correct, because the cost of being wrong is not yours to pay. It lands on the people who trusted you with a password.

The takeaway

These four bugs share one root: trusting something you should not, or skipping a cheap control because the happy path worked in testing. The fixes are not clever, and that is the point, because authentication is not where you want to be original. Close all four before your next deploy and you quietly remove the most common ways early products get their users compromised.

FAQ

If I use Laravel’s built-in auth, do I still need to worry about these?

Session regeneration and bcrypt are handled for you if you stay on the framework’s auth. The risk shows up when people bypass it with custom login flows, so the mistakes return the moment you hand-roll.

bcrypt or argon2id, which should I pick?

Either is fine; both are slow, salted, and adaptive. Laravel defaults to bcrypt and you should let it, tuning the cost so a single verify takes a fraction of a second.

Is JWT in localStorage always wrong?

For a classic web app served from one backend, yes, prefer a hardened server-side session cookie. Reach for tokens only when you have the problem they solve, a stateless API or multiple clients, and even then keep them short lived and refreshed server side.

Why rate limit by both account and IP instead of just one?

So one attacker cannot lock out every user and one victim account cannot be pounded forever. Keying on both the source and the account balances those two failure modes.

Does strong password hashing remove the need for rate limiting?

No. Hashing protects a stolen database; it does nothing to stop someone hammering your live login form with credential stuffing. You need both.

let's talk

Building something where this kind of work matters?

I'm in GMT+6 and work async-first, so your timezone is never a reason not to reach out.

Get in touch
Copyright © Hasan Misbah. All rights reserved.
Hasan Misbah