Session Management Patterns: Cookies, JWTs, and the "Logged Out from All Devices" Button
If your SaaS has user accounts in 2026, session management is the layer between "user logged in" and "user can do things." The naive implementation is the bug factory: stateless JWTs that can't be revoked; sessions that never expire; "remember me" with insecure cookies; race conditions in token refresh; impossible-to-implement "log out everywhere" feature. Most indie SaaS leans on a managed auth library (NextAuth / Auth.js, Clerk, Supabase Auth, Better Auth) and inherits decisions they don't fully understand. That works until a security audit, a "log out from all devices" customer request, or a session-fixation vulnerability surfaces. The fix is understanding the patterns: server-side sessions vs JWTs, refresh-token rotation, secure cookie attributes, revocation strategies, multi-device tracking.
A working session strategy answers: server-side session vs JWT (most apps: server-side; SPA-only: JWT with caveats), where to store (cookie / localStorage), how long sessions live (access token short; refresh token longer), how to refresh (rotation; secure handling), how to revoke (deny-list / session table), how to handle multi-device, how to handle "log out everywhere," and how to test (CSRF / session-fixation / token-replay).
This guide is the implementation playbook for sessions. Companion to Password Reset & Magic Link, Two-Factor Auth, SSO & Enterprise Auth, Social Login & OAuth, API Keys, and CAPTCHA & Bot Protection.
Why Session Management Matters
Get the failure modes clear first.
Help me understand session failures.
The 8 categories:
**1. JWT can't be revoked**
Compromised token = attacker has access until expiry. No way to log out.
**2. Sessions never expire**
Stolen device 6 months ago = still logged in.
**3. CSRF (cross-site request forgery)**
Attacker tricks logged-in user into making request.
**4. Session fixation**
Attacker sets known session ID; user logs in; attacker reuses.
**5. Token leak via XSS**
Token in localStorage; XSS reads; attacker steals.
**6. Refresh-token replay**
Attacker captures refresh token; replays endlessly.
**7. "Log out everywhere" doesn't work**
User suspects compromise; clicks button; old sessions stay valid.
**8. Multi-device chaos**
Phone + laptop + tablet sessions; user can't see; can't manage.
For my product:
- Auth library used
- Session strategy today
- Worst failure mode
Output:
1. Top failure modes
2. Risk level
3. Mitigation priorities
The biggest unforced error: using stateless JWTs without revocation. Industry shifted away from this 2020-2024 because the security trade-offs aren't worth the "scaling" benefits at most app sizes. Server-side sessions are the modern default.
The Big Choice: Server-Side Sessions vs JWTs
Help me decide.
The two paradigms:
**Server-side sessions (recommended for most)**:
- Server creates session record (DB / Redis)
- Returns opaque session ID to client (in cookie)
- Each request: server looks up session ID
Pros:
- Easy revocation (delete session row)
- Easy "log out everywhere" (delete all user's sessions)
- Easy session inspection (admin can see active sessions)
- No payload size concerns
- Clear expiry semantics
Cons:
- Requires shared session store across servers
- Slight DB / Redis lookup per request
**Stateless JWTs**:
- Server signs token with claims (user ID, expiry, scopes)
- Returns token to client
- Each request: server verifies signature; trusts claims
Pros:
- No session store needed
- Token contains all info
- Distributed-system friendly
- Microservice-friendly
Cons:
- Can't revoke (token valid until expiry; usually 15-30 min)
- "Log out everywhere" requires deny-list (defeats stateless purpose)
- Token grows with claims
- Compromised token = active until expiry
**The 2026 reality**:
- Server-side sessions for 80% of SaaS
- JWTs for: API tokens (machine-to-machine), short-lived OAuth flows
- Hybrid: short-lived JWT (access token; 15 min) + long-lived session (refresh token; days)
**Stateless myth**: "JWTs scale better." For 99% of apps, server-side sessions scale fine (Redis can handle millions of sessions easily).
For my app:
- Auth library
- Constraints
Output:
1. Pattern pick
2. Storage
3. Migration if changing
The 2026 default for new projects: server-side sessions for web apps; JWTs for stateless API tokens. Some libraries (NextAuth / Auth.js) use stateless-by-default; understand the trade-off and switch to DB strategy if you need revocation.
Cookies: The Right Way
Help me set cookies right.
The required attributes:
**HttpOnly**
JS can't read cookie. Prevents XSS theft.
```typescript
res.cookies.set('session', sessionId, {
httpOnly: true,
});
Secure Only sent over HTTPS. Prevents intercept on HTTP.
secure: true, // Required for production; use false in localhost dev
SameSite Prevents CSRF.
Options:
Strict: cookie ONLY sent on same-site requests; safestLax: cookie sent on top-level navigation; balanced; default for most browsersNone: cookie sent everywhere; requires Secure; for cross-site embedding
sameSite: 'lax', // Default for most apps
Path
Limits cookie to specific paths. Default / is fine for most.
Domain Limits to specific domain. Default current domain is fine.
MaxAge / Expires
maxAge: 60 * 60 * 24 * 30, // 30 days in seconds
For long-lived sessions: 30-90 days typical. For short-lived: 24 hours or less.
Full secure cookie example (Next.js 16):
import { cookies } from 'next/headers';
const cookieStore = await cookies();
cookieStore.set('session', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
Anti-patterns:
- Storing tokens in localStorage (XSS = stolen)
- Cookie without HttpOnly (JS-readable)
- SameSite=None without Secure (most browsers reject)
- Long-lived (1 year+) sessions for sensitive apps
For my cookies:
- Library defaults
- Verify each attribute
Output:
- Cookie config
- Audit current
- Fixes
The discipline: **httpOnly + secure + sameSite=lax minimum**. Forgetting any one is a vulnerability. Most modern auth libraries default to safe; verify.
## The Schema for Server-Side Sessions
Help me design the schema.
The basic schema:
CREATE TABLE sessions (
id VARCHAR(64) PRIMARY KEY, -- random; SHA-256 hash
user_id UUID NOT NULL REFERENCES users,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
last_active_at TIMESTAMPTZ DEFAULT NOW(),
ip INET,
user_agent TEXT,
device_name VARCHAR(255), -- e.g. "Alice's iPhone"; user-friendly
revoked_at TIMESTAMPTZ
);
CREATE INDEX ON sessions (user_id);
CREATE INDEX ON sessions (expires_at);
Session ID generation:
import { randomBytes } from 'node:crypto';
function generateSessionId(): string {
return randomBytes(32).toString('base64url');
}
256 bits of entropy; unguessable.
Storage choice:
For session lookups (every request):
- Postgres with proper index: fine up to high traffic; simple
- Redis: faster; cache-style; auto-expire via TTL
- Vercel Edge Config / Upstash: edge-cacheable
Most indie SaaS: Postgres works. Switch to Redis at scale (~10K req/sec sustained).
Hash before storage:
For extra security, hash the session ID before storing (similar to password reset tokens):
import { createHash } from 'crypto';
const rawSessionId = generateSessionId();
const hashedSessionId = createHash('sha256').update(rawSessionId).digest('hex');
// Cookie holds raw; DB holds hash
res.cookies.set('session', rawSessionId, { ... });
await db.session.create({ data: { id: hashedSessionId, ... } });
If DB leaks: attacker has hashes; can't reverse to session IDs.
For my app:
- Storage choice
- Schema fits
Output:
- Schema
- Storage backend
- Hash strategy
The non-obvious detail: **hash session IDs in DB**. Many auth libraries don't by default. If they don't: post-DB-leak, attacker hijacks active sessions. With hash: useless.
## Refresh Tokens and Rotation
Help me handle refresh.
The pattern:
- Access token: short-lived (15-30 min); used for API requests
- Refresh token: long-lived (7-30 days); used to get new access token
When access token expires:
- Client uses refresh token to get new access token
- Server validates refresh token + issues new access token
- (Recommended) Server also issues NEW refresh token; OLD token invalid
This is refresh token rotation:
async function refreshSession(oldRefreshToken: string) {
const session = await db.session.findUnique({ where: { id: hash(oldRefreshToken) } });
if (!session || session.revoked_at) {
// Stolen token? Or already rotated.
// Mark all of user's sessions as compromised.
await db.session.updateMany({
where: { user_id: session.user_id, revoked_at: null },
data: { revoked_at: new Date() },
});
throw new Error('Compromised token detected');
}
// Mark old as rotated (track for replay detection)
await db.session.update({
where: { id: hash(oldRefreshToken) },
data: { revoked_at: new Date() },
});
// Issue new
const newRefreshToken = generateSessionId();
const newAccessToken = generateAccessToken(session.user_id);
await db.session.create({
data: {
id: hash(newRefreshToken),
user_id: session.user_id,
previous_session_id: hash(oldRefreshToken),
// ...
},
});
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
Replay detection:
If old refresh token is used AFTER it's been rotated → stolen. Trigger: invalidate ALL of user's sessions; force re-login.
This catches refresh-token theft when rotation is enabled.
Concurrent rotation race:
If two clients rotate simultaneously, one will lose. Handle gracefully (retry with valid token).
For my app: [refresh strategy]
Output:
- Rotation logic
- Replay detection
- Race handling
The discipline: **rotate refresh tokens on every use**. Without rotation, a stolen refresh token works forever. With rotation, theft is detected on second use.
## "Log Out Everywhere" Implementation
Help me implement log-out-everywhere.
User-facing feature:
Settings → Active Sessions →
- See all logged-in devices
- "Log out [device]" per session
- "Log out everywhere" button
Implementation:
// "Log out from all devices"
async function logoutEverywhere(userId: string) {
await db.session.updateMany({
where: { user_id: userId, revoked_at: null },
data: { revoked_at: new Date() },
});
// Force fresh login on next request
// Clear current session cookie
cookieStore.delete('session');
}
UI showing active sessions:
const activeSessions = await db.session.findMany({
where: {
user_id: userId,
revoked_at: null,
expires_at: { gt: new Date() },
},
orderBy: { last_active_at: 'desc' },
});
// Display:
// - Current session (highlighted)
// - Device name (parsed from User-Agent or user-set)
// - Location (from IP geolocation)
// - Last active (relative time)
// - "Log out" button per session
Session check on every request:
// Middleware
const session = await db.session.findUnique({ where: { id: hashedSessionId } });
if (!session || session.revoked_at || session.expires_at < new Date()) {
return redirect('/login');
}
// Update last_active_at occasionally (debounced; don't write every request)
if (Date.now() - session.last_active_at.getTime() > 5 * 60 * 1000) {
await db.session.update({
where: { id: session.id },
data: { last_active_at: new Date() },
});
}
Automatic session expiry:
Daily cron deletes expired sessions:
DELETE FROM sessions WHERE expires_at < NOW() - INTERVAL '7 days';
(Keep 7 days post-expiry for forensics if needed.)
For my product: [implementation]
Output:
- Active-sessions UI
- Logout-everywhere endpoint
- Cron cleanup
The single most-impactful feature for security-conscious users: **active sessions visible + manageable**. Showing "you're logged in on iPhone, MacBook, Chrome on Windows" gives users control + spots breaches.
## CSRF Protection
Help me handle CSRF.
CSRF (Cross-Site Request Forgery): attacker tricks logged-in user into making a request.
Example: malicious site has <form action="https://yourbank.com/transfer" method="POST">. User visits; auto-submitted; bank does the transfer (because cookie is sent automatically).
Defenses:
1. SameSite cookies (primary defense in 2026):
sameSite: 'lax', // or 'strict'
Lax cookies: NOT sent on cross-site POST (only on top-level navigation).
This blocks most CSRF. Sufficient for many apps.
2. CSRF tokens (additional layer):
Embed unique token in forms; verify on submit.
// On page render
const csrfToken = generateCSRFToken(session);
// Include in form: <input name="csrf_token" value={csrfToken} />
// On submit
const submittedToken = req.body.csrf_token;
if (submittedToken !== expectedTokenForSession(session)) {
throw new Error('CSRF token mismatch');
}
Frameworks:
- Next.js: built-in for Server Actions
- Express:
csurfmiddleware - Rails / Django: built-in
3. Custom header (for AJAX):
API requires custom header (X-Requested-With: XMLHttpRequest).
Browsers don't send custom headers cross-origin without CORS preflight.
4. Double-submit cookie:
Cookie value also submitted in form; verify match.
The 2026 default:
- SameSite=Lax cookies (covers 95%)
- CSRF token for state-changing operations (forms / mutations)
- Don't rely on Origin / Referer headers alone (proxies strip)
For my app: [audit]
Output:
- SameSite config
- CSRF token strategy
- Test cases
The discipline most miss: **SameSite=Lax is necessary but not sufficient**. Add CSRF tokens for mutation endpoints. Most modern frameworks do this automatically; verify.
## Multi-Device Considerations
Help me handle multi-device.
The 2026 reality: users have phone + laptop + tablet + sometimes work + personal devices.
Scenarios:
Device A logs in; Device B already logged in:
- Both sessions exist
- Both work
- (Optional) Notify other devices: "New login from [device] at [location]"
User changes password:
- Revoke all sessions except current
- Force re-login on other devices
User enables 2FA:
- Revoke all sessions except current
- Force fresh login (with 2FA) on others
Suspicious session detected (different country / unusual time):
- Email user
- (Optional) require re-login
Implementation:
// On password change
await db.session.updateMany({
where: {
user_id: userId,
revoked_at: null,
NOT: { id: currentSessionId },
},
data: { revoked_at: new Date() },
});
// Email user
await sendEmail(user.email, 'Password changed; logged out other devices');
Session limits:
Some apps cap concurrent sessions:
- Free tier: 2 devices
- Paid tier: unlimited
When limit hit + new login: oldest session revoked or new login blocked.
Trusted devices:
For 2FA: "trust this device for 30 days" → user doesn't re-2FA on that device. Implementation: device-fingerprint + flag on session.
For my app: [scope]
Output:
- Multi-device UX
- Per-event revocation rules
- Suspicious-login detection
The trust-builder: **email user on every new device login**. Slight friction; massive trust signal. Catches account takeover within minutes vs hours / days.
## Auth Library Choices
Help me pick a library.
The 2026 landscape:
NextAuth / Auth.js:
- Popular for Next.js
- v5 (Auth.js) supports DB sessions
- Configurable; flexible
- Use DB strategy (not JWT) for revocation
Better Auth:
- Modern; type-safe
- DB sessions by default
- Growing fast
Clerk:
- Managed auth (paid)
- Best DX; handles everything
- Good for fast-shipping
- $25/mo+; free tier limited
Lucia Auth:
- Lightweight; flexible
- Good for full-control teams
- v3 simplified
Supabase Auth:
- Bundled with Supabase
- Postgres-native
- Solid for Supabase ecosystem
Workos:
- Enterprise auth (SSO / SCIM)
- Popular for B2B SaaS
Iron Session:
- Stateless server-side sessions in encrypted cookies
- No DB needed
- Good for simple apps
Recommendation by stage:
- Pre-PMF: Clerk (fastest to ship)
- Post-PMF: Better Auth OR Auth.js with DB strategy
- Need SSO/SAML: WorkOS
- Supabase already: Supabase Auth
For my stack: [pick]
Output:
- Library
- Setup
- Migration
The 2026 default for new Next.js projects: **Better Auth** (modern; type-safe) or **Auth.js** (established; flexible). Use DB session strategy in either for revocation.
## Common Session Mistakes
Help me avoid mistakes.
The 10 mistakes:
1. JWT without deny-list Can't revoke; "log out everywhere" broken.
2. localStorage for tokens XSS = stolen.
3. Missing httpOnly / Secure / SameSite CSRF + XSS vulnerable.
4. Sessions never expire Stolen device 6 months ago = still logged in.
5. No refresh-token rotation Stolen refresh token = endless access.
6. Plain session IDs in DB DB leak = active session hijack.
7. No "log out everywhere" feature Compromise → no clean recovery.
8. No new-device notification Account takeover invisible to user.
9. Password change doesn't revoke sessions Old session lingers.
10. No expiry cleanup Sessions table grows forever.
For my app: [risks]
Output:
- Top 3 risks
- Mitigations
- Audit
The single most-painful mistake: **not revoking sessions on password change**. User changes password (suspecting compromise); attacker still has valid session; account takeover continues. Always revoke on critical security events.
## What Done Looks Like
A working session system:
- Server-side sessions (DB-backed)
- Session ID hashed in DB; raw in cookie
- Cookie: httpOnly + secure + sameSite=lax
- Access token short-lived (if using); refresh-token rotation enabled
- Replay detection on rotated tokens
- Sessions table with user_id / created_at / expires_at / last_active / IP / user_agent / device_name
- "Active sessions" UI in settings
- "Log out everywhere" button
- Auto-revoke on password change / 2FA enable
- Email notification on new-device login
- CSRF protection (SameSite + tokens for mutations)
- Daily cron cleaning expired sessions
- 30-90 day session lifetime by default
The proof you got it right: a user who suspects breach clicks "Log out everywhere"; all sessions revoke instantly; attacker locked out. New-device login generates email immediately. Customer support hasn't seen "I'm still logged in after password change" tickets.
## See Also
- [Password Reset & Magic Link](password-reset-magic-link-chat.md) — companion auth flow
- [Two-Factor Auth](two-factor-auth-chat.md) — 2FA atop sessions
- [SSO & Enterprise Auth](sso-enterprise-auth-chat.md) — SSO sessions
- [Social Login & OAuth](social-login-oauth-chat.md) — OAuth → session
- [API Keys](api-keys-chat.md) — API authentication (different from session)
- [CAPTCHA & Bot Protection](captcha-bot-protection-chat.md) — companion abuse layer
- [Cookie Consent](cookie-consent-chat.md) — adjacent compliance
- [Audit Logs](audit-logs-chat.md) — log session events
- [Logging Strategy & Structured Logs](logging-strategy-structured-logs-chat.md) — log discipline
- [VibeReference: Auth Providers](https://vibereference.dev/auth-and-payments/auth-providers) — Clerk / Auth0 / WorkOS / Supabase / Better Auth comparison
- [VibeReference: Authentication](https://vibereference.dev/auth-and-payments/authentication) — broader auth context
- [VibeReference: Better Auth](https://vibereference.dev/auth-and-payments/better-auth) — Better Auth deep-dive
- [VibeReference: Passkeys](https://vibereference.dev/auth-and-payments/passkeys) — passkey-based auth