The One Password Mistake I See Everywhere (Even at Big Tech Companies)
Look, I don't want to be the guy who writes another password-security blog post. The internet already has about four million of them, all repeating the same five things (long passwords good, reused passwords bad, MFA please, etc). If you've been in tech for more than six months you've probably skimmed at least a dozen. I've skimmed at least a dozen. I usually make it to the third bullet point before I start thinking about lunch.
But I've been doing code reviews on auth-adjacent systems for about nine years now — four at Stripe, one at a boring little enterprise company I won't name, and the last three at a fintech I also won't name (but you've seen our Super Bowl ad, it was the one with the dog). And there is one specific pattern I keep finding in production code that nobody talks about. Not the OWASP Top 10 writeups. Not the "10 password myths" articles. Not even most security trainings.
I'm going to describe it, explain why it's everywhere, and then tell you how to actually fix it. And I'm going to try really hard not to be smug about it, because I wrote this exact bug myself in 2018 and shipped it to production for four months before anyone noticed.
The setup: how password reset actually works
Here's the boring, canonical flow every team implements:
- User clicks "forgot password"
- They type in their email
- Backend generates a token, stores it in the database with an expiry, sends a link to the email
- User clicks the link, types a new password, backend validates the token, updates the hash, done
Seems simple. There's a tutorial for this in literally every web framework's docs. Django has it, Rails has it, NextAuth has it, Supabase has it. Copy-paste, ship it, move on.
The bug isn't in that flow. The bug is in what happens around it.
The actual mistake
The mistake is this: when the password changes, existing sessions don't get invalidated.
I know. I know it sounds too obvious. You're rolling your eyes at your screen. But hear me out, because the number of times I've seen a senior engineer — at companies that absolutely should know better — write the password-reset endpoint without touching the session table is genuinely uncomfortable.
Think about the threat model for a second. When does a user actually reset their password? Sometimes they forgot it, sure. But a lot of the time, they reset it because something went wrong. They got a weird notification. Their email was in a breach. Their roommate used their laptop. They have a bad feeling.
If your app doesn't kill the existing sessions when the password changes, the user's defensive action accomplishes exactly nothing against the threat they were actually worried about. They changed the lock, but all the old keys still work.
Why this happens
It's not that engineers are dumb. It's that the default in most auth libraries doesn't do this for you, and the mental model for sessions and passwords got decoupled a long time ago.
Here's what usually happens in a growing startup:
- Someone ships auth in week three using whatever library came with the framework. Sessions are in Redis or signed JWTs.
- Eighteen months later, a PM files a ticket: "Add password reset." An engineer implements it by following the Django/Rails/whatever tutorial.
- The tutorial updates the password hash and redirects to login. It does not mention sessions, because sessions are "a separate concern."
- The engineer ships. QA tests: "I reset my password, I can log in with the new one." Ticket closed.
- Nobody writes a test for "after reset, my old session cookie should no longer work." Why would they? The ticket didn't ask for that.
I've seen this exact sequence play out at three different companies. One of them had raised $400M at the time. The pattern is the same everywhere.
The JWT version is worse, honestly. If you're using stateless JWTs with a 7-day expiry (because someone on HN told you sessions were uncool), you literally cannot invalidate them unless you've built a denylist or you're checking a password_changed_at timestamp on every request. Most JWT implementations I've reviewed don't do either. They just pray.
A specific incident I can actually talk about
I'll be careful here because NDAs are real and I like being employable. But at a company I worked at between 2019 and 2021 — not Stripe, the other one — we shipped a password reset flow that didn't invalidate sessions. We used JWTs with a 24-hour expiry. No denylist, no server-side revocation.
A support agent flagged it after a customer called in a panic: "I reset my password last night because I saw a login from Russia, but the 'active devices' list still shows the Russian session and it's still making API calls." The attacker's token was still valid for another 19 hours. The customer's reset did literally nothing except slightly inconvenience the attacker, who already had access anyway.
We fixed it that week. The fix was maybe 40 lines of code. The incident postmortem was 14 pages. Don't be us.
What the fix actually looks like
The fix depends on how you're storing sessions, but the pattern is the same everywhere: any server-side state that represents "this user is authenticated right now" needs to be invalidated when the password changes.
Database-backed sessions
Delete them. On a password reset, run something like:
DELETE FROM sessions WHERE user_id = $1 AND id != $current_session_id;
I exclude the current session so the user isn't immediately logged out of the device they just did the reset from — that's just good UX. But kill everything else. Some apps kill even the current session and force the user to log in again with the new password. That's actually more secure. The UX cost is one extra login. Do that if your users can handle it.
JWT-based auth
You have two options. Neither of them is "do nothing."
Option A: Store a password_changed_at timestamp on the user record. Embed the password-issue-time in the JWT. On every request, compare. If the token was issued before the password changed, reject it. This adds one DB read per request, which sucks, but you can cache it.
Option B: Keep a denylist of revoked token IDs (jti claim) in Redis. Check on every request. This is what most mature JWT implementations end up doing, and at that point you've basically reinvented sessions, which is why I'm generally not a fan of JWTs for web apps in the first place. But that's a rant for another post.
OAuth / refresh tokens
Revoke them. All of them. Especially if you're using long-lived refresh tokens, because those are the real attack surface. A 30-day refresh token that survives a password reset is basically a free "I'm still in your account" ticket for an attacker.
While we're here: other password things teams consistently mess up
I'll keep this tight because this post is already longer than I planned.
Using the wrong hash function
I still find SHA-256 or — I'm not joking — MD5 in production code. In 2026. If you're writing an auth system and you're not using bcrypt, Argon2, or scrypt, stop what you're doing and go fix that. Regular hash functions are designed to be fast. Password hash functions are designed to be slow on purpose. Fast is bad here. Slow is the feature.
(If you want to just see what a bcrypt hash looks like for a test string, you can generate one with our hash generator or bcrypt tool — useful for debugging seeded test data.)
Rate-limiting login but not password reset
A surprising number of apps rate-limit login attempts but not the "send me a reset email" endpoint. Attackers love this. They'll spam your endpoint to enumerate valid emails, to flood your users' inboxes, or to waste your SES budget. Rate-limit both. Rate-limit everything that touches email.
"Strong password" requirements that are actively harmful
NIST changed their guidance on this back in 2017 and half the industry still hasn't updated. The old rules — must have uppercase, must have a number, must have a symbol, must rotate every 90 days — are worse than useless. They push users toward predictable patterns (Password1!, then Password2! ninety days later). NIST's current guidance is: minimum 8 chars, ideally much longer, check against known breach lists, don't force rotation unless you suspect compromise.
If you're building signup forms in 2026 and still requiring "at least one special character," please update your shame. Length is the variable that matters. A randomly generated 16-character password from a password generator will outlive the universe against a brute-force attack, whether or not it has an exclamation mark.
The passphrase / password length confusion
Quick fact that most people get wrong: a four-word Diceware passphrase (correct-horse-battery-staple style, 44 bits of entropy) is actually weaker than a 12-character random password from a full charset (around 78 bits). Not by a small margin. By roughly a trillion times.
I love passphrases for human-memorable secrets, but don't let someone tell you "four random words is equivalent to a strong password." It isn't. Use six words if you want passphrase-level parity with random passwords. This is one of those things I've argued about on Twitter more times than I'd like to admit.
What I actually do at work
On PRs that touch auth, I have a little mental checklist. I'll share it because it might save someone a postmortem:
- Does changing the password invalidate other sessions? (The main point of this post.)
- Is the password hashed with a slow function, with a reasonable cost factor? (bcrypt cost 12+, Argon2id with real memory, scrypt with N=2^15+.)
- Are reset tokens single-use and time-limited? (24h max, ideally 1h.)
- Are reset tokens compared in constant time? (Use
hmac.compare_digestor your language equivalent, not==.) - Is the reset endpoint rate-limited per IP and per account?
- On successful reset, do we send a confirmation email? (This is a "you got hacked" early warning for the user.)
- Do we check the new password against a breach list like Have I Been Pwned? (Free API, use it.)
None of this is novel. All of it is boring. Auth is boring when it's done well. That's kind of the point.
The part where I admit what I don't know
I'm a backend engineer. I know my way around auth systems, I've read more OWASP docs than is probably healthy, and I've shipped fixes for the bug I just described. But I'm not a cryptographer. If you want to argue about the specific entropy margins of Argon2 vs scrypt on memory-constrained hardware, find someone else. I will immediately lose that argument.
I also don't know everything about WebAuthn and passkeys, which is probably where all of this is going anyway. Passkeys solve a bunch of these problems by not having passwords at all. If you're designing a new system in 2026 and you're not at least considering passkeys as the primary auth method, you're a little behind. I'm a little behind too. We can be behind together.
But until the whole internet moves to passkeys (which will take, what, eight more years? twelve?), passwords are still what most of your users will use. And the sessions around those passwords are still where most teams mess up.
Check your session invalidation code. Right now. I'll wait.