A customer reset their password after a phishing email, and the attacker stayed logged in
Resetting a password changed the hash but left every existing session valid, including the attacker's. We traced why, added session versioning tied to credentials, and made password changes, MFA changes and admin actions end other sessions.
A customer contacted support twice in one week. The first time, they had entered their credentials on a phishing page that imitated our login screen, noticed, and immediately reset their password. The second time, three days later, they reported that someone had changed the payout bank account on their profile.
The audit log showed the bank account change came from an IP address in a different country, using a session created before the password reset:
event | at | session_id | ip | user_agent
-------------------------+---------------------+------------+-----------------+-----------------
login | 2026-10-20 13:02:11 | s_4d1a.. | 203.0.113.45 | Chrome 130 / Win
login | 2026-10-20 13:05:47 | s_9f22.. | 198.51.100.7 | Firefox 131 / Linux
password_reset_completed | 2026-10-20 13:21:30 | s_4d1a.. | 203.0.113.45 | Chrome 130 / Win
payout_account_changed | 2026-10-23 02:44:09 | s_9f22.. | 198.51.100.7 | Firefox 131 / Linux
Session s_9f22 was the attacker's, created three minutes after the phish. The customer reset their
password sixteen minutes later. The attacker's session kept working for three more days, until it was
used to change payout details.
Checking the reset flow
We use Laravel with database-backed sessions. The reset controller was close to the framework's default example:
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) {
$user->forceFill(['password' => Hash::make($password)])
->setRememberToken(Str::random(60));
$user->save();
event(new PasswordReset($user));
}
);
It changed the password hash and rotated the remember-me token. It did nothing to existing sessions. The attacker's session was a normal session row with the user ID in it. Nothing in that row depended on the password, so changing the password did not affect it.
We tried it on staging: log in on two browsers, reset the password in one, reload the other. Still logged in.
A session is not tied to the password unless you tie it
After login, most web apps stop looking at the password. The session cookie points to a server-side record (or is itself a signed token) that says "this is user 4491". Each request checks the session, not the credentials. That is the point of sessions, and it means any change to credentials has no effect on sessions that already exist unless the application does something about it.
Users, quite reasonably, expect the opposite. "I changed my password" means "whoever had my old password is out now". OWASP's session management guidance says the same: applications should invalidate other active sessions when a password is changed or reset.
What the framework offers and what we used
Laravel has a mechanism for exactly this. The AuthenticateSession middleware stores a hash of the
user's password in the session at login and logs the session out if the stored hash no longer matches the
current one. Auth::logoutOtherDevices($password) updates that hash so every other session fails the
check.
We had neither in place. The middleware was not on our web route group, and the reset flow did not touch sessions. Remember-me rotation alone only stops "remember me" cookies from creating new sessions; it does not end the ones already open.
Environment
| Item | Value |
|---|---|
| Framework | Laravel 11, PHP 8.3 |
| Session driver | database, 2 hour idle lifetime, remember-me 30 days |
| Also affected | password change in settings, MFA disable, email change |
| Exposure | 1 confirmed takeover; audit found 4 more accounts with pre-reset sessions used afterwards |
We wanted a rule that covers every credential change, not just the reset form, and works for sessions, remember-me cookies and API tokens.
1. A credential version on the user
We added an integer credential_version column to users. Any security-relevant change increments it:
password reset, password change, MFA enabled or disabled, email change, and "sign out everywhere" by the
user or an admin.
final class CredentialVersion
{
public static function bump(User $user, string $reason): void
{
$user->increment('credential_version');
AuditLog::record($user, 'credentials_changed', ['reason' => $reason]);
}
}
2. Sessions remember the version they were created with
At login we store the current version in the session. A middleware compares it on each request:
final class EnsureCredentialVersion
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if ($user && $request->session()->get('cred_v') !== $user->credential_version) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->with('status', __('Please sign in again.'));
}
return $next($request);
}
}
After a successful password reset or change, the current session gets the new version stored, so the user who made the change stays logged in, and every other session fails the check on its next request:
CredentialVersion::bump($user, 'password_reset');
$request->session()->put('cred_v', $user->credential_version);
$request->session()->regenerate(); // new session ID for the current device too
This works the same way as Laravel's AuthenticateSession but covers changes that do not involve the
password hash, like disabling MFA.
3. Delete the rows as well
The version check ends sessions on their next request. We also delete other session rows for the user immediately, so they disappear from the "active sessions" list and cannot be used even in a race:
DB::table('sessions')
->where('user_id', $user->id)
->where('id', '!=', $request->session()->getId())
->delete();
4. Tokens and remember-me
- API personal access tokens issued from a login session are revoked on reset. Long-lived integration tokens created explicitly by the user are kept, but the reset confirmation page lists them with a "revoke all" button, and the confirmation email mentions them.
- Remember-me tokens were already rotated; we kept that.
5. Tell the user
The password-change confirmation email now says how many other sessions were ended, and links to the active sessions page. On the page itself, after a reset, we show: "You have been signed out on 2 other devices."
1. A feature test with two sessions. The test logs in twice, resets the password from one, and checks the other:
public function test_password_reset_ends_other_sessions(): void
{
$user = User::factory()->create(['password' => Hash::make('old-password-123')]);
$attacker = $this->loginAs($user, 'old-password-123'); // separate cookie jar
$owner = $this->loginAs($user, 'old-password-123');
$owner->post('/reset-password', $this->resetPayload($user, 'new-password-456'))
->assertRedirect('/dashboard');
$attacker->get('/dashboard')->assertRedirect('/login');
$owner->get('/dashboard')->assertOk();
}
The same test is repeated for password change, MFA disable and email change. On the old code, the attacker request returned 200 in all four.
2. Middleware coverage. A route test asserts EnsureCredentialVersion is in the middleware stack of
every route in the web and authenticated api groups, so a new route group cannot skip it.
3. Incident follow-up. For the affected accounts we ended all sessions, restored the original payout details after identity verification, held payouts made after the attacker's change, and contacted the payment provider. We added a separate rule: changing payout details requires recent re-authentication and sends a notice to the account email that cannot be dismissed from the session making the change.
4. Security implications. This fix reduces the damage of a phished password. It does not stop the phishing. We also increased MFA adoption with a prompt after login, and for payout-related actions MFA is now mandatory.
Takeaways
- Sessions do not depend on the password unless your code makes them. Resetting a password does not end them by itself.
- Store a credential version in each session and bump it on every security-relevant change.
- Keep the current session (regenerated), end the others, and tell the user how many were ended.
- Re-authenticate for sensitive actions like payout or email changes, even inside a valid session.
References: OWASP Session Management Cheat Sheet, OWASP Forgot Password Cheat Sheet, Laravel authentication: invalidating sessions on other devices.
Add a label
Embed this post
<iframe src="https://hibug.me/sessions-survive-password-change" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I build and maintain public and partner APIs, currently for a logistics platform with web, mobile and third-party clients all calling the same endpoints. Before that I worked on identity: login, sessions, OAuth integrations and the long tail of edge cases they create. The bugs I care about sit at the contract between client and server. A PUT that wiped fields the client never sent, a cache that served one user's page to another, a field removal that broke app versions still in the wild, a refresh flow that logged people out when two tabs raced. None of these show up in a happy-path demo. My posts walk through how each one surfaced, what the HTTP or auth spec actually says, the fix we shipped and the tests we added so the next change to the API does not repeat it.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.