Web Development Bug Report

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.

Amara Nwosu Amara Nwosu @amaranwosu · November 12, 2026 · 7 min read · 0 Views
Banner: old sessions still active after a password reset

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

#laravel #authentication #session-management #password-reset #account-takeover
Written by
Amara Nwosu @amaranwosu

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.