Sessions in Redis, and the logout that did not

Moving sessions from the database to Redis was a performance change: one fewer write per request and a store that could be shared across three web servers. It also silently changed what “log out everywhere” meant, and what happened to a session when the cache filled up.

The symptom

# a support ticket, three weeks after the move:
#   "I changed my password because I lost my phone and it
#    is still logged in."

$ redis-cli --scan --pattern 'laravel_session:*' | wc -l
88104

# 88,104 sessions, and no way to find the ones belonging
# to a user without reading all of them.

$ redis-cli GET laravel_session:9c1f4a7e... | head -c 80
s:412:"a:4:{s:6:"_token";s:40:"...";s:9:"_previous"...

# the user id is inside a serialised blob.

The database store had a user_id column and an index on it, so invalidating every session for a user was one statement. In Redis the identity is inside an opaque value and the only way to find them is to read every key, which at eighty-eight thousand keys is not something to do in a request.

Why it happens

A session store is a key-value mapping from a session id to a blob, and every property beyond that — searchability, an index by user, a device label — is something the database store provided incidentally because it was a table.

The move was evaluated on latency and correctness of the happy path, both of which improved. Nobody enumerated what the table had been giving for free.

The fix

An index from user to sessions

// on login, and on any session regeneration
$redis->sadd("user_sessions:{$user->id}", $sessionId);
$redis->expire("user_sessions:{$user->id}", $lifetimeSeconds);

// invalidating everything for a user
$ids = $redis->smembers("user_sessions:{$user->id}");

$redis->pipeline(function ($pipe) use ($ids) {
    foreach ($ids as $id) {
        $pipe->del("laravel_session:{$id}");
    }
});

$redis->del("user_sessions:{$user->id}");

A set per user is the smallest thing that restores the property, and it introduces a consistency problem the table did not have: the set and the sessions can disagree. A session that expires naturally leaves a stale member in the set, which is harmless and accumulates.

Setting a TTL on the set itself bounds the accumulation, and the TTL has to be refreshed on every login or a long-lived user’s index expires while their sessions are still valid. That refresh is one more thing to get right and is the reason this is a second index rather than a first-class feature.

Session identity versus user identity

three things that are usually conflated:

  the session   one browser, one device. regenerated on
                privilege change.
  the login     an authentication event: a time, an IP,
                a user agent.
  the user      the account.

so "log out" can mean this session (the default), every
session for this user (after a password change), or every
session except this one ("sign out other devices").

the third needs to know which is current — which the set
makes possible and the blob does not.
public function logoutOtherDevices(User $user, string $currentId): void
{
    $ids = $this->redis->smembers("user_sessions:{$user->id}");

    foreach (array_diff($ids, [$currentId]) as $id) {
        $this->redis->del("laravel_session:{$id}");
        $this->redis->srem("user_sessions:{$user->id}", $id);
    }

    // and the password hash rotation, which invalidates
    // any session the index missed
    $user->forceFill(['remember_token' => Str::random(60)])->save();
}

Rotating the remember token alongside is the belt-and-braces step that catches a session the index does not know about — a session created before the index existed, or one whose set entry expired. It is one line and it makes the guarantee hold even when the index is wrong.

The password change case

// the framework invalidates sessions whose password hash
// no longer matches — but only for sessions using the
// AuthenticateSession middleware, which is not the default

// routes/web.php
Route::middleware(['web', 'auth', 'auth.session'])->group(...);

// without it, changing a password leaves every existing
// session valid, which is the behaviour most people
// assume is impossible.

The middleware being opt-in is the detail that produces the support ticket, and it is opt-in because it adds a hash comparison per request and breaks any session created before it was enabled. Enabling it is a one-line change and logs everybody out once, which needs saying before it is deployed on a Friday.

The eviction policy that silently logs people out

$ redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"

# which is correct for a cache and wrong for sessions:
# under memory pressure, Redis evicts session keys and
# users are logged out at random.

$ redis-cli INFO stats | grep evicted_keys
evicted_keys:41208

# 41,208 evictions in a week. some of them were sessions.

Sharing one Redis instance between the cache and the sessions means one eviction policy for both, and there is no policy that is correct for both. volatile-lru only helps if every cache entry has a TTL and no session does, which is the reverse of the usual arrangement.

the arrangement that worked:

  redis-cache     allkeys-lru, 4gb, no persistence
  redis-sessions  noeviction,  1gb, appendonly yes

two instances on the same host, on different ports. the
sessions instance refuses writes rather than evicting,
which turns a silent logout into a loud error.

Two instances on one host costs almost nothing and removes a class of failure that is impossible to diagnose from the application side — a user reporting that they were logged out mid-session with no error anywhere. noeviction making it a visible failure is the whole point.

Verifying it worked

$ vendor/bin/phpunit --filter SessionInvalidation
Tests: 8 passed
#   logout                    → 1 session gone, 2 remain
#   logout other devices      → 2 gone, current remains
#   password change           → all gone
#   remember token rotation   → all gone

$ redis-cli -p 6380 CONFIG GET maxmemory-policy
2) "noeviction"

$ redis-cli -p 6380 SMEMBERS user_sessions:4471
1) "9c1f4a7e..."
2) "8c9e1140..."

# and the drift check, weekly:
#   set members with no corresponding session key: 41

The drift check is the standing assertion that the index is approximately correct, and forty-one stale members out of eighty-eight thousand is the expected residue from natural expiry. A number that grows without bound would mean the TTL refresh is broken.

What this costs

A second index that can disagree with the thing it indexes, and an eviction policy that is now load-bearing for authentication. Both are consequences of using a cache as a durable store, which is what a session store is — and the honest framing is that Redis is a good session store with two properties that have to be configured rather than assumed.

The two-instance arrangement also doubles the operational surface: two configurations, two sets of metrics, two things to remember during a capacity change. It is the correct arrangement and the reason most systems do not have it is that the single-instance version works until the day it does not, and the day it does not looks like a user complaining about something unrelated.