Skip to content

Session Store

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The # cli/... transcript is one session in document order, replayed by the course harness. The script uses a 3-second idle timeout so the expiry can be watched; production uses minutes.

Application servers should be stateless, so a session has to live somewhere every server can reach. Redis fits: reads and writes are fast, keys expire natively, and a hash maps onto a session object with named fields. Because the store is shared, logging out on one server logs you out on all of them.

Each session is stored at key session:<id>. Fields such as userId, email and role are hash fields. An EXPIRE on the key sets the idle timeout; refreshing it on every authenticated request gives a sliding TTL.

cli/patterns-sessions-1.redis
127.0.0.1:6379> HSET session:abc123 userId 42 email "[email protected]" role "admin"
(integer) 3
127.0.0.1:6379> EXPIRE session:abc123 1800
(integer) 1
127.0.0.1:6379> HGETALL session:abc123
1) "userId"
2) "42"
3) "email"
5) "role"
6) "admin"
127.0.0.1:6379> HGET session:abc123 role
"admin"
127.0.0.1:6379> EXPIRE session:abc123 1800
(integer) 1
127.0.0.1:6379> TTL session:abc123
(integer) 1800
127.0.0.1:6379> HSET session:abc123 role "superadmin"
(integer) 0
127.0.0.1:6379> HGET session:abc123 role
"superadmin"
127.0.0.1:6379> HSET session:abc123 lastSeen 1700000000
(integer) 1
127.0.0.1:6379> HSET session:abc123 sudo 1
(integer) 1
127.0.0.1:6379> HEXPIRE session:abc123 60 FIELDS 1 sudo
1) (integer) 1
127.0.0.1:6379> HGETEX session:abc123 EX 300 FIELDS 1 role
1) "superadmin"
127.0.0.1:6379> HTTL session:abc123 FIELDS 3 sudo role userId
1) (integer) 60
2) (integer) 300
3) (integer) -1
127.0.0.1:6379> RENAME session:abc123 session:new456
OK
127.0.0.1:6379> TTL session:new456
(integer) 1800
127.0.0.1:6379> EXISTS session:abc123
(integer) 0
127.0.0.1:6379> UNLINK session:new456
(integer) 1
127.0.0.1:6379> EXPIRE session:gone 1800
(integer) 0

How to read it:

  • HSET with several field-value pairs creates the session in one command (variadic HSET has existed since Redis 4.0). It returns the number of new fields; updating role returns 0.
  • The second EXPIRE is the sliding timeout: each call restarts the countdown. It reads 1800 because it was just set (TTL rounds to the nearest second).
  • HSET never touches the key’s TTL. Writing lastSeen does not extend the session; only EXPIRE does. The script below proves it with a clock.
  • Per-field expiry (HEXPIRE, Redis 7.4) gives individual fields their own timer: the sudo flag lives 60 s, role was given 300 s by HGETEX ... EX (8.0, read and set the TTL in one command), and userId has none (-1). This is for short-lived flags inside a session, not for the idle timeout: the key-level EXPIRE is still what removes the whole session.
  • RENAME moves the data to a new key name and keeps the TTL (1800), which is what session-id rotation needs.
  • EXPIRE on a missing key returns 0 and creates nothing, which the script relies on.
HSET session:abc123 userId 42 email "[email protected]" role "admin"
EXPIRE session:abc123 1800
HGETALL session:abc123
HGET session:abc123 role
EXPIRE session:abc123 1800
TTL session:abc123
HSET session:abc123 role "superadmin"
HGET session:abc123 role

A single JSON string forces you to read and rewrite the whole blob to change one field, and two requests that update different fields can overwrite each other (last write wins on the whole string). With a hash, HGET fetches one field, HSET writes one, and concurrent updates to different fields do not collide. The price: fields are flat strings, so nested structures need to be serialised into a field.

src/patterns-sessions.ts
// @run
import { randomBytes } from 'node:crypto';
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const IDLE = 3; // seconds; production: 1800 (30 min)
const key = (sid: string) => `b3:session:${sid}`;
// 128-bit random id from the OS CSPRNG (OWASP asks for >= 64 bits of entropy)
const newId = () => randomBytes(16).toString('base64url');
// HSET + EXPIRE inside MULTI: both or neither reach the server, never a session without a TTL
async function create(data: Record<string, string>) {
const sid = newId();
await redis.multi().hset(key(sid), data).expire(key(sid), IDLE).exec();
return sid;
}
// Read and slide the idle timeout in one round trip. EXPIRE on a missing key does nothing.
async function touch(sid: string) {
const res = await redis.multi().hgetall(key(sid)).expire(key(sid), IDLE).exec();
const [, data] = res![0]! as [Error | null, Record<string, string>];
return Object.keys(data).length ? data : null;
}
// Login must issue a NEW id (session fixation): RENAME keeps the data and the TTL
async function rotate(sid: string) {
const next = newId();
await redis.rename(key(sid), key(next));
return next;
}
const logout = (sid: string) => redis.unlink(key(sid));
let sid = await create({ userId: '42', role: 'admin' });
console.log('id length', sid.length, 'chars, ttl', await redis.ttl(key(sid)));
await sleep(1500);
console.log('after 1.5 s idle, ttl', await redis.ttl(key(sid)), '-> touch:', await touch(sid));
console.log('touch slid the timeout back to', await redis.ttl(key(sid)));
// HSET does NOT slide the TTL; only EXPIRE does
await sleep(1500);
await redis.hset(key(sid), 'lastSeen', String(Date.now()));
console.log('after HSET lastSeen, ttl is still counting down:', await redis.ttl(key(sid)));
const before = await redis.ttl(key(sid));
sid = await rotate(sid);
console.log('rotate: ttl before', before, 'after', await redis.ttl(key(sid)), '| data kept:', (await redis.hgetall(key(sid))).role);
await sleep(3200);
console.log('3.2 s of no requests -> session gone:', (await touch(sid)) === null);
// logout: delete now, do not wait for the TTL
sid = await create({ userId: '7' });
await logout(sid);
console.log('after logout:', (await touch(sid)) === null);
// Redis 8 HGETEX: expire individual FIELDS (here a short-lived "sudo" flag), not the whole key
sid = await create({ userId: '42', sudo: '1' });
await redis.call('HEXPIRE', key(sid), 1, 'FIELDS', 1, 'sudo');
await sleep(1200);
console.log('sudo field after 1.2 s:', await redis.hget(key(sid), 'sudo'), '| userId still there:', await redis.hget(key(sid), 'userId'));
await redis.unlink(key(sid));
await redis.quit();

Real output (this Mac):

id length 22 chars, ttl 3
after 1.5 s idle, ttl 1 -> touch: { userId: '42', role: 'admin' }
touch slid the timeout back to 3
after HSET lastSeen, ttl is still counting down: 1
rotate: ttl before 1 after 1 | data kept: admin
3.2 s of no requests -> session gone: true
after logout: true
sudo field after 1.2 s: null | userId still there: 42

What it proves:

  • Create atomically. multi().hset().expire().exec() sends both commands as one transaction, so a crash between them cannot leave a session that never expires (two separate awaits can). The id is 16 bytes from the OS CSPRNG = 128 bits, 22 base64url characters. OWASP requires at least 64 bits of entropy from a cryptographically secure generator (Session Management Cheat Sheet); never derive an id from a user id, a timestamp or Math.random().
  • Read and slide in one trip. touch runs HGETALL and EXPIRE together: the TTL went back from 1 to 3 after the read. A missing session returns an empty hash (the script maps it to null) and EXPIRE on the missing key creates nothing.
  • HSET does not slide. After 1.5 s more, a lastSeen write left the countdown untouched (still 1). Any “update activity” write must be paired with EXPIRE.
  • Rotate on privilege change. Issue a new id at login (otherwise an attacker who planted an id before login keeps access: session fixation). RENAME keeps both the data and the TTL (1 before, 1 after), and the old id is gone.
  • Idle timeout works. 3.2 s without a request and the session was gone, no cleanup job involved.
  • Logout deletes. UNLINK (or DEL) removes the session immediately. Relying on the TTL alone leaves a stolen cookie valid until it runs out.
  • Field-level expiry. The sudo field disappeared after its 1-second timer while userId stayed.

An assistant writes “update last activity” middleware and says it “keeps the session alive”:

🐛 Spot the bug

app.use(async (req, res, next) => {
const key = `session:${req.cookies.sid}`;
const session = await redis.hgetall(key);
if (!session.userId) return res.status(401).end();
await redis.hset(key, 'lastSeen', String(Date.now())); // "keeps the session alive"
req.session = session;
next();
});
Show the answer

HSET never changes a key’s TTL. In the run above, the lastSeen write left ttl counting down, so an active user is logged out when the original login TTL runs out, however busy they are. Slide the timeout with EXPIRE, and do the read, the write and the refresh in one MULTI so they cannot be separated:

const [[, session]] = (await redis
.multi()
.hgetall(key)
.hset(key, 'lastSeen', String(Date.now()))
.expire(key, 1800)
.exec())!;

A session is a coat-check ticket with an expiry stamp. The hash is the coat, the id in the cookie is the ticket, and EXPIRE is the stamp. Handling the coat (changing a field) does not renew the stamp; only re-stamping does. Swapping the ticket number when the visitor’s identity changes (RENAME) makes a copied old ticket useless, and handing the coat back (UNLINK) ends it right away.

ChoiceBenefitCost
One hash per sessionRead/update individual fields without deserialising everything; concurrent updates to different fields do not collideFlat fields only; you choose the field names, Redis enforces no schema
One JSON stringSimple, holds nested structureWhole-blob read-modify-write; concurrent updates overwrite each other
Sliding TTL (EXPIRE per request)An active session never expires mid-useOne extra command per request (free if it shares the read’s MULTI)
Absolute maximum lifetime as wellBounds a stolen sessionStore createdAt and check it; users are logged out even when active
HEXPIRE/HGETEX per fieldShort-lived flags inside a long sessionRedis 7.4/8.0 only (not on older servers or most forks), does not replace the key TTL
Sessions only in RedisFast, expire on their ownData is lost if Redis loses it; users must log in again (see persistence)
  • “Writing to the session refreshes it.” Only EXPIRE (or a full SET that carries a new EX) changes the TTL; HSET does not.
  • “HSET then EXPIRE is fine as two awaits.” A crash between them leaves a session that lives forever; put both in a MULTI.
  • “Expiry is enough for logout.” The TTL is a fallback; explicit logout must delete the key.
  • “Per-field TTLs replace the session TTL.” They only make some fields disappear earlier; the key still needs its own EXPIRE.
  • Refreshing nothing on activity — an active user is logged out at the original TTL.
  • Refreshing with HSET instead of EXPIRE — proven above not to slide.
  • No DEL/UNLINK at logout — a stolen cookie stays valid until the TTL.
  • Reusing the id after login — issue a fresh id (session fixation).
  • Guessable ids — use at least 128 random bits from a CSPRNG.
  • Storing passwords or tokens in the hash — the session holds what authorisation needs, nothing more.

Real-world example

connect-redis, the Redis store for Express’s express-session, does not use a hash: its source serialises each session with JSON.stringify and writes it with SET key value EX ttl, and its touch() slides the idle timeout by calling EXPIRE. That is the JSON-string design from the table above, with the same EXPIRE-slides rule this lesson proves for hashes (an earlier version of this lesson claimed it used hashes; the source says otherwise).

Which command reads all fields and values of a session hash?
How do you implement a sliding idle timeout?
After HSET session:x lastSeen 1 on a key with 100 s left, what is its TTL?
Why run HSET + EXPIRE inside MULTI when creating a session?
What does RENAME do to a key's TTL, and why does it matter for session rotation?
What command ends a session on logout?