[{"data":1,"prerenderedAt":37},["ShallowReactive",2],{"blog-post-authentication-mistakes-in-early-products":3},{"slug":4,"title":5,"description":6,"date":7,"updated":8,"tags":9,"pillar":13,"draft":14,"image":15,"imageAlt":16,"html":17,"markdown":18,"faq":19,"readingTimeText":35,"readingTimeMinutes":36},"authentication-mistakes-in-early-products","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.","2026-04-15T00:00:00.000Z",null,[10,11,12,13],"authentication","security","laravel","founder-engineering",false,"/blog-covers/founder-engineering.png","Founder engineering cover","\u003Cdiv class=\"post-tldr\">\u003Cdiv class=\"post-tldr-label\">TL;DR\u003C/div>\u003Cp>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.\u003C/p>\u003C/div>\n\u003Cp>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.\u003C/p>\n\u003Cp>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.\u003C/p>\n\u003Ch2 id=\"mistake-one%3A-session-fixation\" tabindex=\"-1\">Mistake one: session fixation\u003C/h2>\n\u003Cp>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”.\u003C/p>\n\u003Cp>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:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">public\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> login\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">Request\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> $request)\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">:\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> RedirectResponse\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">{\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">    // ... validate credentials ...\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $request\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">session\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">()\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">regenerate\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(); \u003C/span>\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// new ID now that identity changed\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">    Auth\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">::\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">login\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($user);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\"> redirect\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">()\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">intended\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'/dashboard'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>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.\u003C/p>\n\u003Ch2 id=\"mistake-two%3A-weak-or-fast-password-hashing\" tabindex=\"-1\">Mistake two: weak or fast password hashing\u003C/h2>\n\u003Cp>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.\u003C/p>\n\u003Cp>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:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// Good: adaptive, salted, deliberately slow.\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">$hash \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> password_hash\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($plain, \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">PASSWORD_BCRYPT\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, [\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'cost'\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\"> =>\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 12\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">]);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// Verify without ever reversing anything.\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">if\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">password_verify\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($input, $hash)) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">    // optionally: if password_needs_rehash(...), upgrade the stored hash here\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>“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 \u003Ca href=\"/blog/backward-compatible-migrations-on-an-install-base\">backward-compatible migration on an install base\u003C/a>.\u003C/p>\n\u003Ch2 id=\"mistake-three%3A-no-rate-limiting-on-login\" tabindex=\"-1\">Mistake three: no rate limiting on login\u003C/h2>\n\u003Cp>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.\u003C/p>\n\u003Cp>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:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">$key \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\"> 'login:'\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">.\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">$request\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">ip\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">()\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">.\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'|'\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">.\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">Str\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">::\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">lower\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($request\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">->\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">input\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">'email'\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">));\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">if\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">RateLimiter\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">::\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">tooManyAttempts\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($key, \u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">maxAttempts\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">5\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">)) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">    $seconds \u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">=\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\"> RateLimiter\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">::\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">availableIn\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($key);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">    abort\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">429\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"Too many attempts. Try again in {\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">$seconds\u003C/span>\u003Cspan style=\"color:#032F62;--shiki-dark:#9ECBFF\">}s.\"\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">RateLimiter\u003C/span>\u003Cspan style=\"color:#D73A49;--shiki-dark:#F97583\">::\u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">hit\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">($key, \u003C/span>\u003Cspan style=\"color:#6F42C1;--shiki-dark:#B392F0\">decaySeconds\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#005CC5;--shiki-dark:#79B8FF\">60\u003C/span>\u003Cspan style=\"color:#24292E;--shiki-dark:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D;--shiki-dark:#6A737D\">// ... on success: RateLimiter::clear($key);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>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.\u003C/p>\n\u003Ch2 id=\"mistake-four%3A-tokens-in-the-wrong-place\" tabindex=\"-1\">Mistake four: tokens in the wrong place\u003C/h2>\n\u003Cp>The bug: storing a session token or JWT in \u003Ccode>localStorage\u003C/code> because it was easy. Anything in \u003Ccode>localStorage\u003C/code> 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.\u003C/p>\n\u003Cp>The fix depends on what you are building, but the default I reach for is a cookie with the right flags rather than \u003Ccode>localStorage\u003C/code>:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan>Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>\u003Ccode>HttpOnly\u003C/code> keeps JavaScript from reading it, which defangs the XSS-steals-token attack. \u003Ccode>Secure\u003C/code> keeps it off plaintext HTTP. \u003Ccode>SameSite\u003C/code> 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.\u003C/p>\n\u003Cp>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.\u003C/p>\n\u003Ch2 id=\"the-pattern-underneath-all-four\" tabindex=\"-1\">The pattern underneath all four\u003C/h2>\n\u003Cp>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 \u003Ccode>localStorage\u003C/code> trust that you will never ship an XSS bug. Every one of those trusts is eventually wrong.\u003C/p>\n\u003Cp>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 \u003Ccode>HttpOnly\u003C/code> 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 \u003Ca href=\"/blog/boring-code-is-a-feature-you-ship-to-your-future-self\">boringly correct\u003C/a>, because the cost of being wrong is not yours to pay. It lands on the people who trusted you with a password.\u003C/p>\n\u003Cdiv class=\"post-takeaway\">\u003Cdiv class=\"post-takeaway-label\">The takeaway\u003C/div>\u003Cp>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.\u003C/p>\u003C/div>\u003Ch2 id=\"faq\" tabindex=\"-1\">FAQ\u003C/h2>\n\u003Cdiv class=\"post-faq\">\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">If I use Laravel’s built-in auth, do I still need to worry about these?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>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.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">bcrypt or argon2id, which should I pick?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>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.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">Is JWT in localStorage always wrong?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>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.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">Why rate limit by both account and IP instead of just one?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>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.\u003C/p>\u003C/div>\u003C/div>\u003Cdiv class=\"faq-item\">\u003Cdiv class=\"faq-q\">Does strong password hashing remove the need for rate limiting?\u003C/div>\u003Cdiv class=\"faq-a\">\u003Cp>No. Hashing protects a stolen database; it does nothing to stop someone hammering your live login form with credential stuffing. You need both.\u003C/p>\u003C/div>\u003C/div>\u003C/div>","\n## TL;DR\n\nEarly 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.\n\nThe 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.\n\nI 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.\n\n## Mistake one: session fixation\n\nThe 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\".\n\nThe 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:\n\n```php\npublic function login(Request $request): RedirectResponse\n{\n    // ... validate credentials ...\n\n    $request->session()->regenerate(); // new ID now that identity changed\n    Auth::login($user);\n\n    return redirect()->intended('/dashboard');\n}\n```\n\nDo 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.\n\n## Mistake two: weak or fast password hashing\n\nThe 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.\n\nThe 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:\n\n```php\n// Good: adaptive, salted, deliberately slow.\n$hash = password_hash($plain, PASSWORD_BCRYPT, ['cost' => 12]);\n\n// Verify without ever reversing anything.\nif (password_verify($input, $hash)) {\n    // optionally: if password_needs_rehash(...), upgrade the stored hash here\n}\n```\n\n\"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](/blog/backward-compatible-migrations-on-an-install-base).\n\n## Mistake three: no rate limiting on login\n\nThe 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.\n\nThe 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:\n\n```php\n$key = 'login:'.$request->ip().'|'.Str::lower($request->input('email'));\n\nif (RateLimiter::tooManyAttempts($key, maxAttempts: 5)) {\n    $seconds = RateLimiter::availableIn($key);\n    abort(429, \"Too many attempts. Try again in {$seconds}s.\");\n}\n\nRateLimiter::hit($key, decaySeconds: 60);\n// ... on success: RateLimiter::clear($key);\n```\n\nPair 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.\n\n## Mistake four: tokens in the wrong place\n\nThe 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.\n\nThe fix depends on what you are building, but the default I reach for is a cookie with the right flags rather than `localStorage`:\n\n```\nSet-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/\n```\n\n`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.\n\nThis 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.\n\n## The pattern underneath all four\n\nEvery 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.\n\nThe 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](/blog/boring-code-is-a-feature-you-ship-to-your-future-self), because the cost of being wrong is not yours to pay. It lands on the people who trusted you with a password.\n\n## The takeaway\n\nThese 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.\n\n## FAQ\n\n**If I use Laravel's built-in auth, do I still need to worry about these?**\nSession 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.\n\n**bcrypt or argon2id, which should I pick?**\nEither 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.\n\n**Is JWT in localStorage always wrong?**\nFor 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.\n\n**Why rate limit by both account and IP instead of just one?**\nSo 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.\n\n**Does strong password hashing remove the need for rate limiting?**\nNo. Hashing protects a stolen database; it does nothing to stop someone hammering your live login form with credential stuffing. You need both.\n",[20,23,26,29,32],{"q":21,"a":22},"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.",{"q":24,"a":25},"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.",{"q":27,"a":28},"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.",{"q":30,"a":31},"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.",{"q":33,"a":34},"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.","7 min read",7,1785070532920]