Deactivating a user should end their sessions

  • security
  • auth
  • laravel

I’ve been building a small multi-tenant side project where a team owner can add and remove members. “Remove a member” looked like a one-line feature: set a deactivated_at timestamp, hide them from the team list, done. Then I wrote the test I should have started with. Sign someone in, deactivate them from another browser, and have the first browser load a page.

It loaded fine. So did the next one, and the one after that.

That is the gap this post is about. Deactivating an account changes a row in the users table. It does nothing to the sessions that already exist. The person you just removed keeps working until their session expires on its own, which might be hours, or weeks if they ticked “remember me.”

Why the obvious fix isn’t enough

The first fix most of us reach for is the login check:

Auth::attempt($credentials + [fn ($q) => $q->whereNull('deactivated_at')]);

That’s worth having. A deactivated person can’t sign in again. But login happens once, and a session is a promise the server made at that moment. After that, most frameworks load the user from the session on each request and trust it. Nothing asks “is this person still allowed in?” unless you make something ask.

There are really three separate jobs here, and I ended up doing all three:

  1. Refuse new logins for deactivated accounts (the check above).
  2. Reject existing sessions on the very next request.
  3. Delete the stored sessions so there’s nothing left to reject.

Check status on every request

The core of the fix is middleware that runs on every authenticated request and logs the person out if their account is no longer active:

class EnsureActiveMember
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if ($user && $user->isDeactivated()) {
            Auth::guard('web')->logout();
            $request->session()->invalidate();
            $request->session()->regenerateToken();

            if ($request->expectsJson()) {
                abort(401, 'This account has been deactivated.');
            }

            return redirect()->route('login')
                ->withErrors(['email' => 'This account has been deactivated.']);
        }

        return $next($request);
    }
}

A few things I care about in that small block:

  • It costs almost nothing. The framework already loaded the user row to authenticate the request, so checking one more column is free. You don’t need a cache or a revocation list to get this right.
  • It invalidates, not just logs out. invalidate() throws away the session data and issues a new session id, and regenerating the CSRF token means a form left open in a tab can’t be submitted afterward.
  • It covers the background requests. This is the one that bit me. My UI makes a lot of small AJAX calls to update components without a full page load. If the middleware only runs on page routes, a deactivated person can keep clicking buttons on a page they already had open. Register it in the same middleware group those endpoints use, and return a 401 to anything that expects JSON instead of a redirect it can’t follow.

Write the test for that case specifically. “A deactivated user’s next page load goes to login” passes easily. “A deactivated user’s next in-page update returns 401” is the one that catches the real hole.

Delete the stored sessions too

Middleware rejects the session the next time it shows up. I still want the session gone, for two reasons: an admin page that lists “active sessions” shouldn’t show one for someone who was removed, and I’d rather not depend on a single check being registered on every route forever.

With sessions stored in a database table, ending them is a delete:

class EndUserSessions
{
    public static function run(User $user, ?string $exceptSessionId = null): int
    {
        if (config('session.driver') !== 'database') {
            return 0; // Other drivers rely on the per-request check.
        }

        return DB::table(config('session.table', 'sessions'))
            ->where('user_id', $user->id)
            ->when($exceptSessionId, fn ($q) => $q->where('id', '!=', $exceptSessionId))
            ->delete();
    }
}

The deactivation itself runs in one transaction: set deactivated_at and who did it, clear the remember-me token, hand their open work to someone else (or flag it for reassignment), delete their sessions, and write audit entries for each step. Clearing the remember-me token matters on its own. A long-lived “remember me” cookie can quietly start a brand new session after the old one is deleted, and the login check isn’t always in that path.

Notice the early return for other drivers. If sessions live in signed cookies, there’s no server-side row to delete, and the per-request check is the only thing standing between a removed person and your app. That’s a good reason to keep both layers instead of picking one.

A password reset is the same problem

Once I had this, I noticed the exact same gap somewhere else. If someone resets their password because they think their account was compromised, every other signed-in session should end. Otherwise the person who stole the old password keeps their session and the reset accomplished nothing.

Two pieces handle it:

  • After a successful reset, rotate the remember-me token and call the same EndUserSessions action.
  • Turn on the framework’s session-authentication middleware (in Laravel, AuthenticateSession). It stores a fingerprint of the password hash in the session and logs the session out if the hash changes, which covers password changes from any code path, not just the reset form.

When the person changing their password is signed in and should stay signed in, pass their current session id as the exception so you don’t log them out of the tab they’re using.

Deactivate, don’t delete

It’s tempting to make “remove a member” an actual DELETE. I keep the row and stamp it instead. Their past actions, assignments, and audit history all point at that user id, and deleting it either cascades away records you need or breaks foreign keys. A deactivated user also has to disappear from the right places: assignee pickers, validation that accepts a user id, and any count of “people who still need to do X.” I added an active() query scope and went looking for every place that lists users.

Tradeoffs

  • One more check per request. In practice it’s a column on a row you already loaded, so the cost is negligible. If you cache the user, make sure deactivation busts that cache or you’re back to trusting stale data.
  • Stateless tokens are harder. A signed token (a JWT, for example) is valid until it expires no matter what you do to the database. Keep their lifetime short and check account status when you refresh them, or keep a deny list.
  • Reactivation is its own feature. I left it out on purpose. Bringing someone back should be a deliberate, audited action, not “clear the timestamp.”

The checklist I’ll reuse

  • Login refuses inactive accounts.
  • Every authenticated request, including background and JSON endpoints, re-checks status and invalidates the session.
  • Deactivation deletes stored sessions and clears remember-me tokens in the same transaction.
  • Password resets end every other session.
  • Deactivated users drop out of pickers, validation, and counts, and their history stays.
  • There’s a test that signs someone in, removes them from another session, and proves their very next request fails.