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
- Fast: <1ms session lookup
- Distributed: Works across API replicas
- Auto-Expiry: TTL-based cleanup
- 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