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.
Why Redis for sessions?
Section titled “Why Redis for sessions?”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.
Storing a session as a hash
Section titled “Storing a session as a hash”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.
127.0.0.1:6379> HSET session:abc123 userId 42 email "[email protected]" role "admin"(integer) 3127.0.0.1:6379> EXPIRE session:abc123 1800(integer) 1127.0.0.1:6379> HGETALL session:abc1231) "userId"2) "42"3) "email"4) "[email protected]"5) "role"6) "admin"127.0.0.1:6379> HGET session:abc123 role"admin"127.0.0.1:6379> EXPIRE session:abc123 1800(integer) 1127.0.0.1:6379> TTL session:abc123(integer) 1800127.0.0.1:6379> HSET session:abc123 role "superadmin"(integer) 0127.0.0.1:6379> HGET session:abc123 role"superadmin"127.0.0.1:6379> HSET session:abc123 lastSeen 1700000000(integer) 1127.0.0.1:6379> HSET session:abc123 sudo 1(integer) 1127.0.0.1:6379> HEXPIRE session:abc123 60 FIELDS 1 sudo1) (integer) 1127.0.0.1:6379> HGETEX session:abc123 EX 300 FIELDS 1 role1) "superadmin"127.0.0.1:6379> HTTL session:abc123 FIELDS 3 sudo role userId1) (integer) 602) (integer) 3003) (integer) -1127.0.0.1:6379> RENAME session:abc123 session:new456OK127.0.0.1:6379> TTL session:new456(integer) 1800127.0.0.1:6379> EXISTS session:abc123(integer) 0127.0.0.1:6379> UNLINK session:new456(integer) 1127.0.0.1:6379> EXPIRE session:gone 1800(integer) 0How to read it:
HSETwith several field-value pairs creates the session in one command (variadicHSEThas existed since Redis 4.0). It returns the number of new fields; updatingrolereturns0.- The second
EXPIREis the sliding timeout: each call restarts the countdown. It reads1800because it was just set (TTLrounds to the nearest second). HSETnever touches the key’s TTL. WritinglastSeendoes not extend the session; onlyEXPIREdoes. The script below proves it with a clock.- Per-field expiry (
HEXPIRE, Redis 7.4) gives individual fields their own timer: thesudoflag lives 60 s,rolewas given 300 s byHGETEX ... EX(8.0, read and set the TTL in one command), anduserIdhas none (-1). This is for short-lived flags inside a session, not for the idle timeout: the key-levelEXPIREis still what removes the whole session. RENAMEmoves the data to a new key name and keeps the TTL (1800), which is what session-id rotation needs.EXPIREon a missing key returns0and 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:abc123HSET session:abc123 role "superadmin"
HGET session:abc123 roleHashes versus serialised JSON
Section titled “Hashes versus serialised JSON”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.
The session store in ioredis
Section titled “The session store in ioredis”// @runimport { 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 TTLasync 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 TTLasync 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 doesawait 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 TTLsid = 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 keysid = 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 3after 1.5 s idle, ttl 1 -> touch: { userId: '42', role: 'admin' }touch slid the timeout back to 3after HSET lastSeen, ttl is still counting down: 1rotate: ttl before 1 after 1 | data kept: admin3.2 s of no requests -> session gone: trueafter logout: truesudo field after 1.2 s: null | userId still there: 42What 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 orMath.random(). - Read and slide in one trip.
touchrunsHGETALLandEXPIREtogether: the TTL went back from 1 to 3 after the read. A missing session returns an empty hash (the script maps it tonull) andEXPIREon the missing key creates nothing. HSETdoes not slide. After 1.5 s more, alastSeenwrite left the countdown untouched (still 1). Any “update activity” write must be paired withEXPIRE.- Rotate on privilege change. Issue a new id at login (otherwise an attacker who planted an id before login keeps access: session fixation).
RENAMEkeeps 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(orDEL) removes the session immediately. Relying on the TTL alone leaves a stolen cookie valid until it runs out. - Field-level expiry. The
sudofield disappeared after its 1-second timer whileuserIdstayed.
Reading AI-generated code
Section titled “Reading AI-generated code”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())!;Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| One hash per session | Read/update individual fields without deserialising everything; concurrent updates to different fields do not collide | Flat fields only; you choose the field names, Redis enforces no schema |
| One JSON string | Simple, holds nested structure | Whole-blob read-modify-write; concurrent updates overwrite each other |
Sliding TTL (EXPIRE per request) | An active session never expires mid-use | One extra command per request (free if it shares the read’s MULTI) |
| Absolute maximum lifetime as well | Bounds a stolen session | Store createdAt and check it; users are logged out even when active |
HEXPIRE/HGETEX per field | Short-lived flags inside a long session | Redis 7.4/8.0 only (not on older servers or most forks), does not replace the key TTL |
| Sessions only in Redis | Fast, expire on their own | Data is lost if Redis loses it; users must log in again (see persistence) |
Common Misconceptions
Section titled “Common Misconceptions”- “Writing to the session refreshes it.” Only
EXPIRE(or a fullSETthat carries a newEX) changes the TTL;HSETdoes not. - “
HSETthenEXPIREis fine as two awaits.” A crash between them leaves a session that lives forever; put both in aMULTI. - “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.
Common mistakes
Section titled “Common mistakes”- Refreshing nothing on activity — an active user is logged out at the original TTL.
- Refreshing with
HSETinstead ofEXPIRE— proven above not to slide. - No
DEL/UNLINKat 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 withJSON.stringifyand writes it withSET key value EX ttl, and itstouch()slides the idle timeout by callingEXPIRE. That is the JSON-string design from the table above, with the sameEXPIRE-slides rule this lesson proves for hashes (an earlier version of this lesson claimed it used hashes; the source says otherwise).