Managing Session State in Redis

A server-side session is only as reliable as the store behind it, and Redis is the store most teams reach for — fast enough that the lookup disappears into the noise, and expressive enough that expiry, indexing and bulk revocation are one command each. This guide is part of the Modern Authentication Fundamentals guide, and it covers the operational decisions that decide whether your session store is an asset or the reason everyone was logged out on a Tuesday afternoon.

Prerequisites

Before putting sessions in Redis in production you should have:

  • A settled session model — an opaque identifier in an HttpOnly, Secure cookie mapped to a server-side record, as described in session versus token authentication.
  • A managed Redis with replication, or a self-hosted deployment with a documented failover procedure.
  • TLS between your application and Redis, and authentication enabled — a session store reachable without credentials is a complete authentication bypass.
  • Agreement on your idle and absolute session timeouts, because they drive the key design below.

Key Design Decides Everything Else

Almost every operational property of the store follows from how you name and shape the keys. Two access patterns matter: resolve a session identifier to a record on every request, and find every session belonging to one user when they sign out everywhere or an administrator disables the account.

Session key layout with a per-user index Each session is stored under a key derived from its identifier while a per-user set holds the identifiers of that user's sessions, so a single request is one lookup and sign-out-everywhere is one set read plus a bulk delete. sess:{sid} → hash userId · issuedAt · absoluteExpiry · deviceId one GET per request, TTL refreshed on activity user:{id}:sessions → set of sid written on login, cleaned on logout makes "sign out everywhere" one operation The index is not free — it can outlive the sessions it points to Expired session keys vanish silently, so prune the set on read and cap its size per user.
One key for the hot path, one index for the administrative path. Everything else — device lists, concurrent-session limits, forced logout — is built on those two.
import { createClient } from "redis";
import { randomBytes } from "node:crypto";

const redis = createClient({ url: process.env.REDIS_URL, socket: { tls: true } });
await redis.connect();

const IDLE_SECONDS = 30 * 60;          // sliding window
const ABSOLUTE_SECONDS = 12 * 60 * 60; // hard cap, enforced in the record

export async function createSession(userId: string, deviceId: string) {
  const sid = randomBytes(32).toString("base64url");   // 256 bits of CSPRNG entropy
  const now = Date.now();

  await redis
    .multi()
    .hSet(`sess:${sid}`, {
      userId,
      deviceId,
      issuedAt: String(now),
      absoluteExpiry: String(now + ABSOLUTE_SECONDS * 1000),
    })
    .expire(`sess:${sid}`, IDLE_SECONDS)
    .sAdd(`user:${userId}:sessions`, sid)
    .expire(`user:${userId}:sessions`, ABSOLUTE_SECONDS)
    .exec();

  return sid;
}

Note two details. The session identifier carries 256 bits of entropy from a cryptographic source, because an identifier that can be guessed makes every other control irrelevant. And the absolute expiry is stored in the record rather than only as a key TTL, so refreshing the idle window cannot extend a session past its hard limit.

Expiry, Eviction and the Difference Between Them

Redis removes keys for two very different reasons, and confusing them is how a session store starts logging people out at random under load.

Expiry versus eviction in a session store Expiry removes a key when its time to live elapses and is the intended behaviour, while eviction removes keys because memory is full and can delete live sessions unless the eviction policy is set to volatile-lru. Expiry the TTL elapsed — intended predictable, per key the user sees a normal timeout and logs in again Eviction memory limit reached — unplanned deletes live sessions users log out mid-action, apparently at random
Eviction is the failure mode that looks like a bug in your application. Sizing memory and choosing the policy deliberately is what keeps it from happening.

Set maxmemory explicitly and set maxmemory-policy to volatile-lru so only keys with a TTL are candidates — that keeps the store from discarding anything you deliberately persisted without one. Alarm on memory usage well below the limit, because by the time eviction starts, users are already losing sessions. And keep the session record small: a few hundred bytes of identifiers and timestamps, with anything larger held in your primary database and looked up by user id.

Sliding expiry is convenient and easy to get wrong. Refresh the TTL on activity, but check the stored absolute expiry on every read and treat a session past it as gone, regardless of what the key’s TTL says. Without that check, an active attacker keeps a stolen session alive indefinitely simply by using it.

Replication, Failover and the Login Stampede

A single Redis node means a restart signs out every user at once, and the resulting login surge can overwhelm your authentication path and — in an OIDC deployment — the identity provider’s rate limits.

Session survival across a Redis restart With no persistence and one node a restart clears every session and produces a login stampede; with append-only persistence and a replica, failover preserves sessions and the application sees a brief write error instead. Single node, memory only restart clears every session every user is logged out at once login stampede hits the auth path and the identity provider's limits Replica plus persistence append-only file survives restart replica promoted on failure sessions survive; a few writes fail retry and continue
The goal is not zero failures during failover — it is that a failover costs a handful of retried writes rather than every session in the system.

Enable append-only persistence with everysec fsync, run at least one replica, and test the failover rather than assuming it. During a promotion your application will see connection errors and possibly a few seconds of read-only responses; the correct behaviour is to retry briefly and, if the store is genuinely unavailable, to fail closed with a maintenance response rather than treating every request as anonymous. An authentication layer that silently degrades to “no session” when its store is down has converted an availability incident into an authorization incident.

Use one connection pool per process, sized to your concurrency rather than to the number of routes, and set both connect and command timeouts. An unbounded command timeout turns a slow store into exhausted application threads, which is how a Redis latency blip becomes a full outage.

Sign Out Everywhere, Concurrent Session Limits and Device Lists

The per-user index is what makes the administrative features cheap. Signing out everywhere is a read of the set and a bulk delete; enforcing a maximum number of concurrent sessions is a set-size check on login, dropping the oldest; and a device list screen is the same set, joined to the metadata stored on each session record.

export async function signOutEverywhere(userId: string) {
  const key = `user:${userId}:sessions`;
  const sids = await redis.sMembers(key);
  if (sids.length) {
    await redis.del(sids.map((sid) => `sess:${sid}`));
  }
  await redis.del(key);
  await redis.incr(`user:${userId}:sessionVersion`);   // invalidates anything cached elsewhere
}

The version counter matters if any component caches session lookups or if you issue short-lived tokens derived from the session: bumping it makes every cached artefact stale on its next check without a broadcast. It is the same technique used for caching authorization decisions, and it is worth adopting before you need it.

Prune the index opportunistically. Session keys expire on their own, so the set accumulates identifiers pointing at nothing; remove missing members when you read it, and cap the set per user so a script that creates thousands of sessions cannot grow it without bound.

Security Properties Worth Enforcing

Treat the store as a credential database, because that is what it is. Require authentication and TLS on the connection, restrict network access to the application tier, and disable or rename the commands that make a compromise total — an attacker with KEYS and HGETALL on your session store does not need to guess anything.

Never store anything in the session record that you would not want in a memory dump: no access tokens in plaintext if you can hold a reference instead, no personal data beyond what the request path needs, and certainly no passwords or recovery codes. Regenerate the session identifier at every privilege change, as described in preventing session fixation, which in Redis means writing a new key and deleting the old one — writing the new record first, so a concurrent request never finds neither.

Finally, log the lifecycle rather than the contents: creation, refresh, regeneration, explicit logout, and administrative revocation, each with the user, device and source address. That stream is what an incident investigation is reconstructed from, and it is much easier to add now than during one.

Frequently Asked Questions

Should the session TTL be sliding or fixed?

Both, with different jobs. A sliding idle TTL ends sessions the user has walked away from and is what the key’s expiry implements. A fixed absolute expiry, stored inside the record and checked on every read, caps the total life of the session regardless of activity. Sliding alone lets a stolen session live forever as long as someone keeps using it, which is precisely the case you are trying to bound.

What eviction policy should a session store use?

volatile-lru with an explicit maxmemory, so only keys that already carry a TTL can be discarded and anything you deliberately persisted without one is safe. allkeys-lru will happily evict operational keys, and noeviction turns a full instance into write failures on login. Whichever you choose, alert on memory well before the limit — eviction of live sessions looks to users like random logouts and to you like a phantom bug.

Do I need Redis Cluster for sessions?

Usually not. Session workloads are small values with a high read rate, and a single primary with replicas handles a very large user base comfortably. Cluster adds sharding complexity — multi-key operations must share a hash slot, which affects the per-user index — and is worth adopting when memory or throughput genuinely exceeds one node, not preemptively. If you do shard, hash-tag the keys so a user’s sessions and index land in the same slot.

What should happen when Redis is unreachable?

Fail closed. Existing requests cannot be authenticated without the store, so return a maintenance response rather than treating them as anonymous, and stop issuing new sessions until the store recovers. The tempting alternative — falling back to a local in-memory store — creates sessions that exist on one instance, disappear on the next deploy, and cannot be revoked centrally, which is a worse outcome than a short, visible outage.

How large can a session record safely be?

Keep it in the hundreds of bytes: identifiers, timestamps, a device reference, and a version counter. Everything else belongs in your primary database, fetched by user id when the request needs it. Large session records multiply memory usage across every concurrent user, make serialization a measurable cost on every request, and tempt teams into storing data that then has to be invalidated when it changes.

Choosing a Serialization Format

The record’s shape affects both memory and the cost of every request. A Redis hash with flat string fields lets you read or update a single field without deserializing the whole record, keeps memory predictable, and works naturally with per-field expiry logic in your application. A single JSON string is simpler to write from application code and much easier to get wrong: every read parses the whole document, every write rewrites it, and two concurrent updates silently overwrite each other because there is no field-level granularity.

Prefer the hash for anything you update independently — a last-seen timestamp, a step-up elevation flag, a session version — and reserve serialized blobs for data that is written once at login and never touched again. If you do store a blob, keep it small and version it with an explicit schema field, because a session store outlives deployments and you will eventually read a record written by the previous version of your code.

Avoid storing derived data that can go stale. A user’s roles copied into the session at login are wrong the moment an administrator changes them, and the resulting behaviour — the user keeps the old permissions until they log out — is the classic complaint about session-based authorization. Store the user id and read the current roles, or store a version counter you can compare cheaply.

Concurrency and the Refresh Race

Sliding expiry means every request potentially writes to the store, which turns a read-mostly workload into a read-write one and introduces a race that is easy to miss. Two concurrent requests both read the session, both decide to refresh the TTL, and both write — harmless in itself, but if the write also touches a field such as lastSeen or a rolling counter, one update is lost.

Two techniques keep this cheap. First, only refresh the TTL when the remaining life has dropped below a threshold — say, half the idle window — so a burst of requests produces one write rather than dozens. Second, use atomic operations rather than read-modify-write: HINCRBY for counters, EXPIRE for the TTL, and a Lua script for anything that genuinely needs several fields updated together. Redis executes scripts atomically, which turns a multi-step update into a single operation without a distributed lock.

The same reasoning applies to session regeneration at a privilege boundary. Write the new record, set the cookie, and only then delete the old one, with a brief grace period during which the old identifier resolves to the new session. Deleting first creates a window where a concurrent request — a prefetch, a parallel API call from the same page — finds nothing and treats the user as anonymous, which surfaces as being logged out immediately after logging in.

Capacity Planning Without Guesswork

Sizing is straightforward once you measure one record. Take the serialized size of a typical session, add the per-key overhead Redis charges (which is substantial for small values — expect a few hundred bytes before your data), multiply by peak concurrent sessions, and add headroom for the per-user index sets. Peak concurrent sessions is not your user count: it is roughly daily active users multiplied by the fraction of the session lifetime they are active, which for a typical product with an eight-hour absolute cap is a small share of the total.

Then double it. The headroom is not waste — it is what stands between a traffic spike and eviction of live sessions, and eviction is the failure mode that looks like a bug in your application rather than a capacity problem. Alert at 60 per cent of maxmemory so you have time to react, and track the eviction counter directly: a non-zero value on a session store is always worth investigating, even when nobody has complained yet.

Watch the key count alongside memory. A steadily climbing key count with flat active users usually means something is creating sessions without cleaning them up — a health check that authenticates, a crawler hitting a login endpoint, or a client retrying a failed login in a loop. Each of those is cheap to fix once you can see it and expensive to diagnose from memory graphs alone.

Migrating an Existing Session Store

Moving sessions into Redis from an in-memory store, a database table, or another cache is a live migration, and the safe approach is to read from both while writing to one.

Start by writing new sessions to Redis only, while resolution falls back to the old store when a key is missing. Existing sessions keep working, new ones land in the new store, and the fallback rate becomes a burn-down chart that tells you exactly when the old population has drained — usually one absolute session lifetime. Instrument the fallback so you can see it: a flat line rather than a declining one means something is still creating sessions in the old store.

Do not attempt to copy existing records across. Session data is short-lived by definition, so migrating it buys a few hours of continuity at the cost of a translation step that has to be exactly right about expiry semantics. Letting the old population expire naturally is simpler and has a clear finish line.

Keep the fallback path behind a flag you can disable without a deploy, and delete it once the rate reaches zero. A dormant second source of session truth is a liability: the day someone re-enables it during an unrelated incident, sessions you believed were revoked can come back.

Once the migration is finished, delete the old store’s credentials as well as its code. Leaving a decommissioned session database reachable from production is a quiet risk: it still contains real session material until it is dropped, and nobody is monitoring it any more.