Session Management and Token Storage
- session-management
- redis
- cookies
- authentication
- security
- nestjs
- nodejs
- typescript
- saas
In Multi-Factor Authentication (MFA) I covered proving who a user is at login: passwords plus a second factor, verified once, at one moment in time. That verification is cheap to reason about because it happens once. Everything after it, keeping that user "logged in" for the next hour, day, or month, is a different problem, and it's the one that actually determines how secure your product feels in practice.
This is part of the Full Stack SaaS Masterclass, a build-it-for-real series that takes a multi-tenant SaaS from an empty folder to production. JWT Authentication in NestJS and Refresh Tokens Done Right already covered issuing tokens and rotating them safely. This article covers the layer above that: modeling a session as a first-class thing, what a cookie should actually look like, and how you give a user the ability to end one.
Most teams get the token mechanics right and skip the session layer entirely, because "the JWT is the session" feels sufficient until someone asks a question your token can't answer: which devices is this user logged in on, and can we kick one of them out right now?
Stateless tokens and server-side sessions are not the same decision
A JWT access token, on its own, isn't a session. It's a claim with an expiry. There's no record anywhere of "this user is currently logged in from this browser," just a signed string that happens to still be valid. That's fine for authorization on a single request, but it's a poor foundation for anything that needs to reason about a user's logged-in state as a whole: showing them their active devices, forcing a logout after a password change, or capping how many concurrent sessions a plan allows.
The refresh token table from the previous article is actually where your real session state lives, whether you've thought of it that way or not. Each row is a session: it has a start time, an expiry, a revocation flag, and it maps to one continuous login. The access token is just a short-lived, disposable credential that session is currently backing.
Once you see it that way, the design question changes. "Stateless JWT" versus "stateful session" was never really the choice; the real question is where the session record lives and what it holds. Postgres is the durable, auditable choice. Redis earns its place once that session data is read or written on nearly every request, since a database round trip for something like "touch the last-active timestamp" adds up at scale.
Modeling a session in Redis
Redis fits session data well for a specific reason: TTLs are native to it. A session that should die after 30 days of inactivity is just a key with an expiry, and Redis handles the cleanup for you. No cron job pruning expired rows, no WHERE expires_at < now() scan.
Here's a session store that sits alongside the refresh token table rather than replacing it. The Postgres row remains the durable audit trail; Redis holds the fast-access session metadata used on the request path and in the "your active sessions" UI.
// src/sessions/session.store.ts
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
export interface SessionRecord {
userId: string;
familyId: string;
deviceLabel: string;
ipAddress: string;
userAgent: string;
createdAt: string;
lastSeenAt: string;
}
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
@Injectable()
export class SessionStore {
constructor(private readonly redis: Redis) {}
private sessionKey(familyId: string): string {
return `session:${familyId}`;
}
private userIndexKey(userId: string): string {
return `user-sessions:${userId}`;
}
async create(record: SessionRecord): Promise<void> {
const key = this.sessionKey(record.familyId);
await this.redis
.multi()
.hset(key, record as unknown as Record<string, string>)
.expire(key, SESSION_TTL_SECONDS)
.sadd(this.userIndexKey(record.userId), record.familyId)
.exec();
}
async touch(familyId: string): Promise<void> {
const key = this.sessionKey(familyId);
await this.redis
.multi()
.hset(key, 'lastSeenAt', new Date().toISOString())
.expire(key, SESSION_TTL_SECONDS)
.exec();
}
async listForUser(userId: string): Promise<SessionRecord[]> {
const familyIds = await this.redis.smembers(this.userIndexKey(userId));
const pipeline = this.redis.pipeline();
familyIds.forEach((id) => pipeline.hgetall(this.sessionKey(id)));
const results = await pipeline.exec();
return (results ?? [])
.map(([, data]) => data as unknown as SessionRecord)
.filter((data) => data && Object.keys(data).length > 0);
}
async revoke(userId: string, familyId: string): Promise<void> {
await this.redis
.multi()
.del(this.sessionKey(familyId))
.srem(this.userIndexKey(userId), familyId)
.exec();
}
async revokeAll(userId: string): Promise<void> {
const familyIds = await this.redis.smembers(this.userIndexKey(userId));
const pipeline = this.redis.pipeline();
familyIds.forEach((id) => pipeline.del(this.sessionKey(id)));
pipeline.del(this.userIndexKey(userId));
await pipeline.exec();
}
}
The touch call is where sliding expiration happens: every time the refresh endpoint successfully rotates a token, you extend the Redis TTL and update lastSeenAt. The familyId ties this record back to the refresh token family from the previous article, so revoking a session here and revoking it in Postgres are the same operation applied to the same identifier. That's deliberate: two stores, one key space, no ambiguity about which one is authoritative for what.
The multi() pipeline matters more than it looks. Updating a hash and its expiry as two separate round trips leaves a window where a crash between them produces a session with no TTL, which then lives forever. Batch the write and the expiry together.
Cookie attributes that actually matter
The token itself is only half the story. How it travels between browser and server determines most of your real-world attack surface, and this is the part that gets rushed.
Set every session-carrying cookie with httpOnly, secure, and an explicit sameSite. httpOnly keeps JavaScript from reading it, closing off the most common XSS-driven theft path. secure refuses to send it over plain HTTP. sameSite=lax is a reasonable default for most SaaS apps: it blocks the cookie from being sent on cross-site requests that aren't top-level navigations, covering a large share of CSRF vectors without breaking normal links into your app. Reach for sameSite=strict only if the app never needs the cookie sent from an external link.
Where teams get tripped up is domain and path scoping, especially once per-tenant subdomains enter the picture (a topic the next article covers directly). Setting domain=.yourapp.com makes the cookie valid across every subdomain, including ones you don't control the content of. If a customer can host arbitrary content on a subdomain you issue them, a session cookie scoped that broadly is exposed to it. Scope the cookie no wider than it needs to be, and consider the __Host- prefix, which forces secure, forbids a domain attribute, and pins path=/, removing a whole category of misconfiguration by browser enforcement.
Session fixation is worth naming explicitly because it's easy to forget: if you accept a session identifier that existed before login and simply "attach" a user to it, an attacker who planted that identifier in the victim's browser inherits the authenticated session once the victim logs in. The fix is simple and cheap: always issue a brand new session identifier at the moment of successful login, never reuse whatever was present beforehand, authenticated or not.
Giving users visibility: active sessions and remote logout
A "Manage active sessions" screen becomes close to mandatory once you're selling to a security-conscious buyer. It's one of the first things a security review asks about, right alongside MFA. If sessions are modeled the way described above, the feature is mostly UI work on top of listForUser and revoke.
// src/sessions/sessions.controller.ts
import { Controller, Get, Delete, Param, UseGuards, Req } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { SessionStore } from './session.store';
@Controller('sessions')
@UseGuards(JwtAuthGuard)
export class SessionsController {
constructor(private readonly sessionStore: SessionStore) {}
@Get()
async listSessions(@Req() req: any) {
return this.sessionStore.listForUser(req.user.userId);
}
@Delete(':familyId')
async revokeSession(@Req() req: any, @Param('familyId') familyId: string) {
await this.sessionStore.revoke(req.user.userId, familyId);
return { revoked: familyId };
}
}
Pair the endpoint with the same revocation you already have on the Postgres side: revoking in Redis should always be accompanied by marking the corresponding refresh token family as revoked there too, so a stale refresh call still gets rejected even if it arrives before the access token expires naturally. Label sessions with something a human can recognize, browser and rough location derived from the request at creation time, not raw user agent strings, so the list is actually useful instead of a wall of unreadable identifiers.
Production pitfalls
Redis eviction policy silently logging people out. If your session store shares a Redis instance with a cache configured for allkeys-lru eviction, session keys can be evicted under memory pressure exactly like cache entries, because Redis doesn't know the difference. Run sessions on a separate logical database or a dedicated instance, and set maxmemory-policy noeviction (or volatile-ttl if you're comfortable with expiry-aware eviction) for anything holding session state you don't want silently dropped.
Treating session touch as free. Extending a TTL on every single request, rather than on refresh, adds write load that scales with traffic rather than with logins. Touch the session on refresh and on meaningful activity checkpoints, not on every access-token-authenticated request.
Forgetting the Redis side exists during incident response. If you revoke a user in Postgres but forget the Redis mirror, the "active sessions" list keeps showing a session Postgres would already reject on its next refresh. The refresh token check still wins, so this isn't a security hole. It's just a confusing thing to have on screen during an incident, and it can cost you minutes you don't have while you're trying to figure out what actually happened.
No plan for Redis unavailability. If session reads sit on the hot path for every request, a Redis outage takes your app down with it. Decide upfront whether Redis is a source of truth (fail closed, no login without it) or an accelerator over Postgres (fail open, degrade to a slower path). Most SaaS products should choose the latter for session reads and reserve fail-closed behavior for the revocation check itself.
Key takeaways
- A JWT access token is not a session by itself; the session is the durable record (Postgres, Redis, or both) that a chain of access tokens is backed by.
- Redis suits session data because TTLs are native to it, which turns cleanup into a non-problem instead of a scheduled job.
- Scope cookies with `httpOnly`, `secure`, and an explicit `sameSite`, and never scope `domain` wider than the app actually needs, especially with per-tenant subdomains in play.
- Always issue a new session identifier at login, regardless of what existed before, to close off session fixation.
- A user-facing "active sessions" screen with per-device revocation is close to table stakes for B2B SaaS and is cheap to build once sessions are modeled properly.
- Keep Redis eviction policy in mind explicitly; a session store sharing space with an LRU cache can lose sessions in ways that look like random logouts.
Frequently asked questions
Do I need both Redis and a database for sessions, or is one enough?
Either can work alone, but they serve different needs. Postgres gives a durable, auditable record that survives a restart. Redis gives fast reads and native TTL expiry for data touched often. A common pattern: Postgres as source of truth for refresh tokens, Redis as a fast mirror for session metadata and revocation checks.
What's the difference between session expiration and refresh token rotation?
Rotation, covered in the previous article, is how a single ongoing session renews its access token safely and detects theft. Session expiration is the outer boundary: how long that login can persist, absolute or sliding, before re-authentication is required from scratch.
Should session expiry be absolute or sliding?
Sliding expiration is friendlier for daily-use products, since an active user is never unexpectedly logged out. Absolute expiration guarantees a maximum session lifetime regardless of activity. Many SaaS products combine both: sliding renewal with a hard absolute cap.
How does session management change with per-tenant subdomains?
Cookie `domain` scoping becomes a real decision instead of a default. A cookie scoped too broadly across `*.yourapp.com` can be exposed to tenant-controlled subdomain content. Scope cookies to the specific host serving the session, and revisit this closely once tenant subdomains arrive, which the next article covers.
What should the "active sessions" list actually show a user?
Enough to recognize a device without exposing raw technical detail: approximate browser and OS, a rough location derived from IP at session creation, and last-active time. Skip raw user agent strings and IP addresses; they're accurate but not meaningful to most users scanning for something unfamiliar.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.