Skip to main content

ADR-0041: Redis for Session Storage

Status

Accepted - 2025-01-26


Context

User sessions (JWT claims, permissions, preferences) need fast, distributed storage with auto-expiry.


Decision

Redis for session storage with 24-hour TTL and sliding expiration.

Rationale

  1. Fast: <1ms session lookup
  2. Distributed: Works across API replicas
  3. Auto-Expiry: TTL-based cleanup
  4. Already Using Redis: No additional infrastructure

Alternatives Considered

Alternative 1: Database Sessions (PostgreSQL)

Rejected - Slower (10-50ms), requires cleanup job

Alternative 2: In-Memory Sessions (Node.js)

Rejected - Lost on restart, doesn't work across replicas

Alternative 3: JWT-Only (Stateless)

Rejected - Can't revoke sessions instantly, large token size


Implementation

Session Data Structure

// src/auth/session.ts
export interface Session {
sessionId: string;
userId: string;
orgId: string;
accountId: string;
role: string;
permissions: string[];
email: string;
createdAt: string;
lastAccessedAt: string;
expiresAt: string;
}

Create Session

// src/auth/createSession.ts
import { v4 as uuidv4 } from 'uuid';

export async function createSession(user: User): Promise<Session> {
const sessionId = uuidv4();
const now = new Date();
const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours

const session: Session = {
sessionId,
userId: user.id,
orgId: user.orgId,
accountId: user.accountId,
role: user.role,
permissions: user.permissions,
email: user.email,
createdAt: now.toISOString(),
lastAccessedAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
};

// Store in Redis (24 hour TTL)
const key = CacheKeys.session(sessionId);
await redis.setex(key, 86400, JSON.stringify(session));

return session;
}

Get Session

export async function getSession(sessionId: string): Promise<Session | null> {
const key = CacheKeys.session(sessionId);

const data = await redis.get(key);
if (!data) return null;

const session = JSON.parse(data);

// Sliding expiration: extend TTL on access
await redis.expire(key, 86400); // Reset to 24 hours

// Update last accessed
session.lastAccessedAt = new Date().toISOString();
await redis.setex(key, 86400, JSON.stringify(session));

return session;
}

Delete Session (Logout)

export async function deleteSession(sessionId: string): Promise<void> {
const key = CacheKeys.session(sessionId);
await redis.del(key);

logger.info({ sessionId }, 'Session deleted');
}

Middleware Integration

Fastify Session Middleware

// src/middleware/session.ts
import { FastifyRequest, FastifyReply } from 'fastify';

export async function sessionMiddleware(req: FastifyRequest, reply: FastifyReply) {
// Extract session ID from cookie or Authorization header
const sessionId = req.cookies.sessionId || extractSessionFromHeader(req);

if (!sessionId) {
return reply.status(401).send({ error: 'No session' });
}

// Load session
const session = await getSession(sessionId);

if (!session) {
return reply.status(401).send({ error: 'Invalid session' });
}

// Attach to request
req.session = session;
req.user = {
id: session.userId,
orgId: session.orgId,
accountId: session.accountId,
role: session.role,
permissions: session.permissions,
};

// Set org context for RLS
await db.execute(sql`SET app.current_org_id = ${session.orgId}`);
}

// Register globally
app.addHook('onRequest', sessionMiddleware);

Session Revocation

Revoke Single Session

export async function revokeSession(sessionId: string): Promise<void> {
await deleteSession(sessionId);

logger.warn({ sessionId }, 'Session revoked');
}

Revoke All Sessions for User

export async function revokeAllUserSessions(userId: string): Promise<void> {
const pattern = `session:*`;
let cursor = '0';
let revokedCount = 0;

do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;

for (const key of keys) {
const data = await redis.get(key);
if (data) {
const session = JSON.parse(data);
if (session.userId === userId) {
await redis.del(key);
revokedCount++;
}
}
}
} while (cursor !== '0');

logger.warn({ userId, revokedCount }, 'All user sessions revoked');
}

Revoke All Sessions for Org (Security Incident)

export async function revokeAllOrgSessions(orgId: string): Promise<void> {
const pattern = `session:*`;
let cursor = '0';
let revokedCount = 0;

do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;

for (const key of keys) {
const data = await redis.get(key);
if (data) {
const session = JSON.parse(data);
if (session.orgId === orgId) {
await redis.del(key);
revokedCount++;
}
}
}
} while (cursor !== '0');

logger.error({ orgId, revokedCount }, 'All org sessions revoked (security incident)');
}

Session Monitoring

Active Sessions Count

export async function getActiveSessionsCount(): Promise<number> {
const pattern = `session:*`;
let cursor = '0';
let count = 0;

do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
count += keys.length;
} while (cursor !== '0');

return count;
}

Sessions Per User

export async function getUserSessionsCount(userId: string): Promise<number> {
const pattern = `session:*`;
let cursor = '0';
let count = 0;

do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;

for (const key of keys) {
const data = await redis.get(key);
if (data) {
const session = JSON.parse(data);
if (session.userId === userId) count++;
}
}
} while (cursor !== '0');

return count;
}

Sliding Expiration

Session TTL resets on every access:

export async function touchSession(sessionId: string): Promise<void> {
const key = CacheKeys.session(sessionId);

// Check if session exists
const exists = await redis.exists(key);
if (!exists) return;

// Reset TTL to 24 hours
await redis.expire(key, 86400);

logger.debug({ sessionId }, 'Session TTL refreshed');
}

// Call on every request
app.addHook('onRequest', async (req) => {
if (req.session) {
await touchSession(req.session.sessionId);
}
});

Security Features

Session Fingerprinting

Detect session hijacking:

export async function createSessionWithFingerprint(
user: User,
req: FastifyRequest
): Promise<Session> {
const fingerprint = {
userAgent: req.headers['user-agent'],
ip: req.ip,
};

const session = await createSession(user);

// Store fingerprint
await redis.hset(`session:${session.sessionId}:meta`, {
userAgent: fingerprint.userAgent,
ip: fingerprint.ip,
});

return session;
}

// Validate fingerprint on request
export async function validateSessionFingerprint(
sessionId: string,
req: FastifyRequest
): Promise<boolean> {
const meta = await redis.hgetall(`session:${sessionId}:meta`);

if (!meta) return false;

// Check user agent matches
if (meta.userAgent !== req.headers['user-agent']) {
logger.warn({ sessionId }, 'Session hijack suspected: user agent mismatch');
await revokeSession(sessionId);
return false;
}

return true;
}

Dashboard Metrics

Session Metrics:
- Active sessions (total)
- New sessions/hour
- Session duration (avg, p50, p99)
- Revoked sessions/day
- Failed session validations/hour

Consequences

Positive

  • Fast: <1ms session lookup
  • Distributed: Works across replicas
  • Revocable: Instant logout
  • Auto-Cleanup: TTL-based expiry

Negative

  • Redis Dependency: Sessions lost if Redis down
  • Memory Usage: 1KB per session

Mitigations

  • Use Upstash with replication (99.99% SLA)
  • Graceful degradation (return 503 if Redis down)
  • Monitor session count (alert if >10,000)

Validation Checklist

  • Sessions stored in Redis with 24h TTL
  • Sliding expiration on access
  • Session revocation endpoints
  • Fingerprinting for hijack detection
  • Active session monitoring
  • Graceful degradation if Redis down

References