Build a Server-Side Session Store Service
A complete server-side session store: opaque session ids, Redis-backed records, sliding expiry, rotation on privilege change, and fixation tests.
Project goal
Implement server-side sessions with opaque ids so the client holds no readable state, sessions can be revoked instantly, and privilege changes force rotation.
Final features
- Create session
- Resolve middleware
- Rotation on privilege change
- Logout and revoke
- Session index per user
Architecture
On login the server creates a random 256-bit id, stores a record in Redis keyed by a hash of the id, and sets an HttpOnly cookie. Each request resolves the record, refreshes the sliding TTL, and rejects sessions whose user version is stale.
Prerequisites
- Node.js 20 LTS or later
- A reachable Redis 7+ instance
- HTTPS in front of the service
Dependencies
- ioredis ^5.4.1 — Session record storage
- express ^4.21.0 — HTTP middleware
Directory structure
session-store/
├── src/session.ts
├── src/middleware.ts
├── src/login.ts
├── src/redis.ts
├── tests/session.test.ts
└── package.json
Phases
1. Create session
Generate a random id, store a record, and set an HttpOnly cookie.
const id = base64url(crypto.randomBytes(32));
await redis.set('sess:' + sha256(id), JSON.stringify(record), 'EX', ttl);
res.cookie('sid', id, { httpOnly: true, secure: true, sameSite: 'lax' });
Expected behavior: Opaque ids mean a stolen cookie cannot be decoded, only replayed.
2. Resolve middleware
Look up the record, reject stale user versions, and refresh the TTL.
const rec = JSON.parse((await redis.get('sess:' + sha256(sid))) ?? 'null');
if (!rec || rec.userVersion !== currentVersion) return res.status(401).end();
await redis.expire(key, ttl);
Expected behavior: Sliding expiry keeps active users logged in while idle sessions expire.
3. Rotation on privilege change
Issue a new id and delete the old record after a role or password change.
const newId = base64url(crypto.randomBytes(32));
await renameSession(oldId, newId, record);
Expected behavior: Rotation closes session-fixation and post-compromise windows.
4. Logout and revoke
Delete the record and clear the cookie.
await redis.del('sess:' + sha256(sid));
res.clearCookie('sid');
Expected behavior: Server-side deletion revokes a session even if the cookie persists elsewhere.
5. Session index per user
Maintain a set of active session ids per user for bulk revocation.
await redis.sadd('user:' + userId + ':sessions', key);
Expected behavior: A password change should end every session for that user.
Testing
Assert that a missing or tampered cookie is rejected, that the TTL refreshes on activity, that a privilege change invalidates the old id, and that bulk revocation ends all sessions for a user.
Troubleshooting
Users logged out randomly
Redis evicted keys under memory pressure; raise the limit or separate the session instance.
Cookie rejected by the browser
SameSite and Secure were set inconsistently with the deployment scheme; align them with HTTPS.
Deployment
Run Redis with persistence appropriate to the session SLA and set the TTL above the longest expected idle period. Terminate TLS at the proxy and require the Secure cookie flag.
Known limitations
- The store is not distributed across regions.
- No device or IP binding is implemented.
- Session data is limited to what fits comfortably in Redis.
Project overview
A session cookie that carries readable state cannot be revoked, because the client already holds it. This build keeps the state on the server and gives the client only an opaque id, so a session can be ended instantly and a privilege change can force a rotation.
You will create a random identifier on login, store a record keyed by a hash of that id, set an HttpOnly cookie, resolve the record on each request, refresh a sliding TTL, and reject a session whose user version is stale.
Keying the record by a hash rather than the raw id is what makes a database dump useless: a leaked store yields hashes, not sessions. The same reasoning as an API key applies, because a session id is a bearer credential by another name.
The user version is the mechanism that forces a rotation on privilege change. When rights change, the version increments, and any session holding the old version is rejected, which means elevation or revocation takes effect immediately rather than at the next expiry.
Each phase adds one property: creation, resolution, rotation, revocation, and a per-user index, so the store is safe and operable at every stage.
It is for teams whose cookie carries a token that cannot be revoked, and who have needed to log a user out immediately and discovered they could not. The store makes revocation a delete rather than a hope that the token expires soon.
It is also a reminder that a session id is a password the server hands out, so it deserves the same entropy, the same hashing at rest, and the same careful handling in logs as any other credential.
What you will build
The service provides five capabilities, each with a test that drives a real cookie:
- Create session
- Resolve middleware
- Rotation on privilege change
- Logout and revoke
- Session index per user
Session creation issues an opaque id; middleware resolves it and refreshes the sliding TTL; a privilege change rotates the id; logout and bulk revocation end sessions; and a per-user index lets every session for a user be found and cleared.
The design assumes the client is untrusted and the network is hostile, so the cookie is opaque, HttpOnly, and Secure, and the server never treats the cookie value as anything but a lookup key.
Architecture and data model
On login the server creates a random 256-bit id, stores a record in Redis keyed by a hash of the id, and sets an HttpOnly cookie. Each request resolves the record, refreshes the sliding TTL, and rejects sessions whose user version is stale.
On login the server generates a high-entropy id, stores a record keyed by a hash of the id, and sets an HttpOnly cookie. The record carries the user, the user version, and a last-seen time used for the sliding TTL.
Each request hashes the cookie value, looks up the record, checks that the user version is current, and refreshes the TTL on activity. A missing, tampered, or stale-version record is rejected as unauthenticated.
A privilege change increments the user version, which invalidates every session that has not rotated. Rotation issues a new id and retires the old one, so a session that survives an elevation is a session the server issued after the change.
A per-user index stores the set of live session ids for a user, so a bulk revocation can clear them without scanning the entire keyspace.
Because the id is opaque, the client cannot be trusted to report anything about its session, and the server derives everything from the record. That asymmetry is what makes the store the single authority on whether a request is authenticated.
The absolute lifetime cap is deliberately separate from the sliding TTL, so an active session still ends eventually and cannot be kept alive indefinitely by constant use.
Prerequisites and dependencies
You need Redis or an equivalent store for session records and a cookie-signing or hashing secret.
- Node.js 20 LTS or later
- A reachable Redis 7+ instance
- HTTPS in front of the service
The dependencies cover the HTTP surface, the session store, and the cookie handling:
ioredis(^5.4.1) — Session record storageexpress(^4.21.0) — HTTP middleware
pnpm init
pnpm add ioredis express
pnpm add -D typescript tsx vitest
Set the TTL above the longest expected idle period, and choose a persistence mode that matches how much session loss you can tolerate. Record the cookie flags in configuration so Secure and HttpOnly are never accidentally relaxed.
Directory structure
Separate session creation, the resolve middleware, rotation, revocation, and the per-user index so each is testable with a fake store:
session-store/
├── src/session.ts
├── src/middleware.ts
├── src/login.ts
├── src/redis.ts
├── tests/session.test.ts
└── package.json
Phase 1: Create session
Generate a random id, store a record, and set an HttpOnly cookie.
const id = base64url(crypto.randomBytes(32));
await redis.set('sess:' + sha256(id), JSON.stringify(record), 'EX', ttl);
res.cookie('sid', id, { httpOnly: true, secure: true, sameSite: 'lax' });
Opaque ids mean a stolen cookie cannot be decoded, only replayed.
Create the id from a cryptographic random source and store only its hash, so a dump of the store does not yield a usable session. Entropy here is the whole security margin, so a predictable id defeats every other control.
Phase 2: Resolve middleware
Look up the record, reject stale user versions, and refresh the TTL.
const rec = JSON.parse((await redis.get('sess:' + sha256(sid))) ?? 'null');
if (!rec || rec.userVersion !== currentVersion) return res.status(401).end();
await redis.expire(key, ttl);
Sliding expiry keeps active users logged in while idle sessions expire.
Resolve the session on each request by hashing the cookie and looking up the record, and reject a record whose user version is stale. Resolution is the one place that decides whether a request is authenticated.
Phase 3: Rotation on privilege change
Issue a new id and delete the old record after a role or password change.
const newId = base64url(crypto.randomBytes(32));
await renameSession(oldId, newId, record);
Rotation closes session-fixation and post-compromise windows.
Rotate the id when privilege changes, so a session created before an elevation does not silently carry the old rights. Rotation is the cheap way to make an authorization change immediate.
Phase 4: Logout and revoke
Delete the record and clear the cookie.
await redis.del('sess:' + sha256(sid));
res.clearCookie('sid');
Server-side deletion revokes a session even if the cookie persists elsewhere.
Support logout and bulk revocation, and clear the per-user index on both, so a user or an operator can end every session without touching unrelated keys.
Phase 5: Session index per user
Maintain a set of active session ids per user for bulk revocation.
await redis.sadd('user:' + userId + ':sessions', key);
A password change should end every session for that user.
Refresh the sliding TTL on activity, and cap the absolute lifetime so a session cannot live forever even if it is used constantly.
Testing
Assert that a missing or tampered cookie is rejected, that the TTL refreshes on activity, that a privilege change invalidates the old id, and that bulk revocation ends all sessions for a user.
Assert that a missing or tampered cookie is rejected, because the resolve path is the boundary that everything else trusts.
Assert that the TTL refreshes on activity and that an idle session expires, since a sliding TTL that never expires is a permanent credential with a friendly name.
Assert that a privilege change invalidates the old id, which is the property that makes elevation immediate rather than eventual.
Assert that bulk revocation ends every session for a user, using the per-user index, because revocation that misses a session is worse than no revocation at all.
Test the sliding TTL with a controllable clock rather than by waiting, because a TTL test that sleeps is slow and flaky and usually ends up being deleted.
pnpm vitest run
Deployment and operations
Run Redis with persistence appropriate to the session SLA and set the TTL above the longest expected idle period. Terminate TLS at the proxy and require the Secure cookie flag.
Run Redis with persistence appropriate to how much session loss is acceptable, and set the TTL above the longest expected idle period so a normal pause does not log a user out.
Terminate TLS at the proxy and require the Secure cookie flag, because a session cookie sent over plain HTTP is a credential handed to the network.
Monitor session creation and revocation rates, because a burst is either a login storm or a client bug that is creating sessions in a loop.
Warm or size the store for the login peak, since a session store under memory pressure behaves like a random logout service.
Plan the session store as a dependency with its own capacity and failure mode, because a session outage is not a feature degradation but a total logout for every user at once.
Security considerations
Never store the raw id; store a hash. Use SameSite and HttpOnly, rotate on authentication events, and bind sessions to a coarse client fingerprint only if the UX trade-off is acceptable.
Never store or log the raw session id, so a store dump or a log archive does not contain a working credential.
Set HttpOnly and Secure on the cookie, and consider SameSite, because a session cookie accessible to script turns any script injection into a session theft.
Reject a session whose user version is stale rather than silently upgrading it, since silently honouring a stale session defeats the point of the version.
Bound the number of concurrent sessions per user, so a credential-stuffing attempt cannot create an unbounded set of live sessions.
Never expose whether a session exists versus whether it expired, and compare the stored hash in constant time where the store allows it, since that distinction helps an attacker enumerate ids.
Observability
Track active sessions, resolution latency, and rejection reasons. Alert on a spike in stale-version rejections, which can indicate a misconfigured deploy.
Track active sessions, creations, and revocations, because a rising active count with a flat login rate usually means sessions are not expiring.
Chart the share of requests rejected for a stale version, because a spike indicates a privilege change that clients have not yet handled.
Measure resolve latency, since the session lookup is on every request and any slowdown is felt across the whole API.
Alert on a sudden drop in active sessions, because it often means the store was flushed or restarted without persistence.
Track the ratio of resolved sessions to created sessions, because a low ratio means sessions are being created and abandoned, which is either a client bug or an attack.
Track the number of sessions per user, because a user with a sudden spike is either sharing a device or the target of a credential-stuffing attempt.
Troubleshooting
Users logged out randomly. Redis evicted keys under memory pressure; raise the limit or separate the session instance.
Cookie rejected by the browser. SameSite and Secure were set inconsistently with the deployment scheme; align them with HTTPS.
If users are logged out randomly, check the store persistence and the TTL, because a flushed store and a too-short TTL produce the same symptom from the outside.
If a privilege change has no effect on a live session, check the version comparison in the resolve path, because a resolve that ignores the version will keep honouring elevated rights.
If a session resolves to the wrong user, check the record key and the hashing, because a collision in the key would map one user’s session onto another user’s record.
If the store grows without bound, check the TTL and the absolute cap, because a session that refreshes indefinitely never frees its record.
Limitations and next steps
- The store is not distributed across regions.
- No device or IP binding is implemented.
- Session data is limited to what fits comfortably in Redis.
Sessions are opaque and server-side, so every request pays a store lookup; a signed stateless token would avoid the lookup at the cost of instant revocation.
The store is a single Redis, so a multi-region deployment would need replication or a regional session partition with a documented affinity.
There is no device management or session naming beyond the per-user index, so a user can revoke all sessions but not identify which device is which.
The example does not model session fixation prevention beyond id rotation, which is sufficient here but would need review in a richer login flow.
There is no cross-region session replication, so a user moving between regions either loses a session or requires an explicit affinity the example does not model.