Blog
Changing the Password Should Log Them Out
- security
- laravel
- typescript
- wordpress
- authentication
Someone gets into a user's account. The user notices, panics, and resets their password. Problem solved, right?
Not if the attacker's session is still sitting there, perfectly valid, with a cookie that doesn't care what the password is now.
A password change is the moment a user is telling you "I don't trust whoever else is logged in as me." If your app doesn't act on that, the reset is mostly decorative.
A session isn't checking your password
Once someone logs in, the app stops looking at the password. It looks at a session ID in a cookie, or a token in a header. That's the whole point. You don't want to verify a bcrypt hash on every request.
But it means the password and the session live separate lives. Changing one does nothing to the other unless you wire them together on purpose.
So the question for any auth setup is simple. When the password changes, what happens to every session and token that already exists?
If the answer is "they keep working until they expire," you have a gap.
Laravel: there's a middleware for this
Laravel ships with AuthenticateSession, aliased as auth.session. It stores a hash of the user's password in the session and logs the session out if that stops matching.
Route::middleware(['auth', 'auth.session'])->group(function () {
Route::get('/dashboard', DashboardController::class);
// ...
});With that in place, changing the password from somewhere else kills the other sessions on their next request. And when the user changes it themselves, you can end their other devices explicitly:
use Illuminate\Support\Facades\Auth;
public function update(Request $request)
{
$request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$request->user()->update([
'password' => Hash::make($request->password),
]);
Auth::logoutOtherDevices($request->password);
}logoutOtherDevices leans on that same middleware. Without auth.session on the routes, it doesn't do what the name says. That's a fun one to discover after the fact.
If you're on the database session driver, you also have a blunt option. The sessions table has a user_id column, so you can just delete the rows:
DB::table('sessions')
->where('user_id', $user->id)
->where('id', '!=', $request->session()->getId())
->delete();Don't forget remember me and API tokens
Sessions aren't the only way back in.
The "remember me" cookie is tied to the remember_token column. Laravel's default password reset flow rotates it for you with setRememberToken(Str::random(60)). If you wrote a custom reset or a custom "change password" screen, check that you do the same.
Sanctum tokens are their own thing entirely. They don't care about passwords or sessions. If a compromised account means the API tokens are suspect too, revoke them:
$user->tokens()->delete();Whether you nuke every token or only some is a product decision. A user probably doesn't want their CI integration to break because they changed their login password. But that should be a choice someone made, not something nobody thought about.
JWTs don't have an off switch
This is where Node and SPA setups get bitten. A signed JWT is valid until it expires. There's no session row to delete. That's the tradeoff you signed up for.
The usual fixes: keep access tokens short-lived, make refresh tokens revocable, and give the user a version number you can bump.
// On password change
await db.user.update({
where: { id: user.id },
data: { passwordHash, tokenVersion: { increment: 1 } },
});// In auth middleware, after verifying the signature
const user = await db.user.findUnique({ where: { id: payload.sub } });
if (!user || user.tokenVersion !== payload.ver) {
return res.status(401).json({ error: "Session expired" });
}Yes, that's a database lookup on every request. Which is roughly what sessions were doing all along. If you can't stomach the lookup, short expiry plus refresh token revocation is the floor.
Regenerate on login, too
The flip side of killing old sessions is not reusing one across a login. If the session ID before login is the same as the one after, someone who planted or grabbed that ID gets an authenticated session for free. That's session fixation.
Laravel's docs already show the fix right after Auth::attempt:
if (Auth::attempt($credentials)) {
$request->session()->regenerate();
return redirect()->intended('dashboard');
}And on logout, invalidate the session and rotate the CSRF token:
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();In Express with express-session, it's req.session.regenerate() before you write the user ID into the session. Same idea.
WordPress already has the pieces
WordPress tracks login sessions per user, and there are functions to end them. The profile screen even has a "Log Out Everywhere Else" button.
If you're building a custom password change flow in a plugin or theme, call it yourself:
// Keep the current session, end the rest
wp_destroy_other_sessions();
// Or end all sessions for a given user
WP_Session_Tokens::get_instance( $user_id )->destroy_all();The checklist
When a password changes, or an admin locks an account, walk through it:
- Other browser sessions end
- Remember me cookies stop working
- API tokens are revoked or deliberately kept
- JWTs stop being accepted, at worst after one short expiry
- The current session gets a fresh ID
Then actually test it. Log in on two browsers, change the password in one, and refresh the other. If you're still logged in, so is whoever the user was trying to kick out.