Mario—I run AI-assisted security audits at Ceron. This is a workflow bug I keep finding in real SaaS products, so here's the full test recipe, what the major frameworks do by default, and the fix.
A user suspects their account is compromised. Maybe they got a login alert from a city they've never been to. Maybe their laptop ran infostealer malware — those tools lift session cookies straight out of the browser's cookie database, no password needed. So they do the obvious thing: hit Forgot password, set a new one, and breathe a sigh of relief.
The attacker doesn't notice a thing.
Their stolen session cookie is still valid. The password reset authenticated the victim, issued the victim a new session — and left every other existing session untouched. The attacker keeps reading emails, exporting invoices, and changing settings with a session that a password reset was supposed to kill.
This is CWE-613 (Insufficient Session Expiration) in its most consequential form. OWASP's session-management guidance is explicit that sessions should be invalidated when a password is changed or reset — and yet a large share of production apps don't do it, because whether they're vulnerable depends almost entirely on framework defaults and which session strategy was picked at the start of the project.
Below is a 10-minute test you can run against your own app (or any app you're authorized to test — which, for your own product, you are).
The test
You need two things: a test account on the app, and two separate browser sessions. The cleanest setup is one normal browser window (the "attacker") and one private/incognito window (the "victim"). You'll also want curl or any HTTP client.
Step 1 — Attacker logs in and grabs the session.
Log into the test account in the attacker window. Then extract the session credential:
- Cookie-based apps: DevTools → Application → Cookies → copy the session cookie's name and value (this works even for HttpOnly cookies, which document.cookie can't see).
- JWT/token apps (common with NextAuth, SPAs, mobile backends): DevTools → Application → Local Storage (or Session Storage) → copy the access/refresh token.
Verify the stolen credential works from outside the browser:
# Cookie-based:
curl -s https://app.example.com/api/me \
-H "Cookie: session=<PASTE_STOLEN_VALUE>"
# Token-based:
curl -s https://app.example.com/api/me \
-H "Authorization: Bearer <PASTE_TOKEN>"
Pick an endpoint that requires authentication and returns account data (/api/me, /api/account, /api/invoices). You should get a 200 with your user's data. That's your baseline: the attacker is in.
Step 2 — Victim resets the password.
In the victim window, go through the real Forgot password flow: request the reset email, click the link, set a new password. Don't shortcut it by changing the password in account settings yet — the reset flow is the one that matters most, because it's the flow a compromised user is told to use.
Step 3 — Attacker replays the stolen credential.
curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/api/me \
-H "Cookie: session=<SAME_STOLEN_VALUE>"
Step 4 — Read the result.
| Result | Meaning |
|---|---|
| 401 / 403 / redirect to login | Sessions were invalidated on reset. You're clean. |
| 200 with account data | The old session survived a password reset. Anyone holding that cookie is still logged in as you. |
If you got a 200, run two follow-ups before you fix anything, because the answers change the fix:
- Repeat the test using the authenticated Change password form in account settings instead of the email reset flow. Apps frequently handle one and not the other.
- Check the session's remaining lifetime. A 15-minute JWT that survived a reset is a nuisance; a 30-day cookie that survived is an open door.
What the frameworks actually do
This bug is rarely hand-written. It's usually inherited from a default. Here's what I see in the wild, by stack:
| Stack | Default behavior on password change/reset |
|---|---|
| Django (database sessions) | ✅ Invalidates all sessions — the session auth hash no longer matches. Django even kills your current session, which is why update_session_auth_hash() exists. The rare framework that fails safe. |
| Rails / Devise | ❌ The default cookie store is client-side and encrypted — there is nothing server-side to invalidate. Old cookies keep working until they expire. |
| Laravel | ❌ Doesn't invalidate on password change. Auth::logoutOtherDevices() exists but you have to wire it in yourself. |
| NextAuth (Auth.js) | ❌ Default JWT sessions live up to 30 days (maxAge) and are stateless — the server can't revoke what it doesn't store. Database-session mode can delete rows, but the reset flow doesn't do it for you. |
| Express + express-session | ⚠️ Sessions live in your store (Redis, Postgres, …), so they can be destroyed — but nothing does it automatically. Most apps never write the DELETE. |
| Supabase Auth | ✅ Mostly — updateUser() with a new password revokes other sessions by default (scope: 'global'). Verify nobody flipped the scope, and check what your RLS policies do with still-valid access tokens during their TTL. |
Two patterns jump out. First, client-side session strategies (signed cookies, JWTs) structurally cannot be revoked — if your sessions live in the browser, "invalidate on reset" requires bolting on server-side state you deliberately avoided. Second, the frameworks that get it right (Django) are the ones that made sessions a server-side, first-class object.
The fix
If your sessions are server-side (database, Redis, cache): on successful password change and password reset, delete every session row for that user except the current one. In SQL terms:
DELETE FROM sessions
WHERE user_id = $1
AND id != $2; -- $2 = current session, or drop the clause on the reset flow
Put this inside the same transaction as the password update. On the reset flow specifically, delete all sessions including the current one — the person completing an email reset should get a fresh session, and there's no reason to trust the browser that's already there.
If your sessions are stateless (JWTs, signed cookies), you need to reintroduce a tiny bit of state: a per-user session_version counter.
- Add session_version INT DEFAULT 0 to the users table.
- Embed the value in every issued token/cookie.
- On each authenticated request, compare the token's version to the database's. Mismatch → reject.
- On password change or reset: UPDATE users SET session_version = session_version + 1 WHERE id = $1.
One integer column, one comparison per request, and every credential issued before the reset becomes worthless — including the one in your account-settings page, your mobile app, and the attacker's cookie jar.
While you're in there, two things the test usually surfaces as bonus findings:
- "Remember me" tokens are a second session system with their own storage. They're revoked on password reset in a minority of the apps I test.
- API keys / personal access tokens issued to the user should have a documented policy on password reset. Killing them silently can break integrations; never killing them means the reset didn't actually reset access. Either is defensible — undecided is not.
Make it a regression test
The fix decays the moment someone refactors auth. Pin it with a test that runs the exact attack:
def test_password_reset_kills_other_sessions():
attacker = login_as(TEST_USER) # session A
victim = login_as(TEST_USER) # session B
complete_password_reset(victim, NEW_PASSWORD) # via the real reset flow
assert attacker.get("/api/me").status_code == 401
If you can't script the email-reset flow in CI, at least test the authenticated change-password path automatically, and the reset flow manually per release. It's four clicks.
Why your scanner never caught this
Automated scanners — the free dashboard kind, the $99/month kind, most of them — check for signatures: missing headers, known CVEs, SQL injection patterns. This bug has no signature. It's a stateful, multi-step, cross-session workflow property: log in as A, log in as B, drive B through an out-of-band email flow, then re-probe A. That's business-logic territory, and it's exactly where the interesting bugs live. It's also why this finding is a regular guest in my reports: not because it's exotic, but because nothing in a typical stack is designed to look for it.
Run the test. It's ten minutes, and the answer is binary.
I'm Mario. At Ceron I run AI-assisted security audits of web apps, APIs, and cloud infrastructure. One-time, fixed fee, no findings no fee. If you'd rather not find out what else your reset flow doesn't do, that's what I'm for.
Top comments (0)