The Problem
Sam leads security at a 20-person SaaS. Users stay logged in forever — sessions never expire. A user's account was compromised; they changed their password but the attacker's session remained active. There's no way to see active sessions or revoke specific ones. Users log in from 5 devices but can't see which are active. Shared accounts (a team using one login) can't be detected. They need session management: track all active sessions per user, device fingerprinting, revoke individual sessions, auto-expire inactive sessions, concurrent session limits, and security alerts on suspicious logins.
Step 1: Build the Session Manager
import { Redis } from "ioredis";
import { pool } from "../db";
import { randomBytes, createHash } from "node:crypto";
const redis = new Redis(process.env.REDIS_URL!);
interface Session { id: string; userId: string; token: string; deviceFingerprint: string; userAgent: string; ip: string; country: string; lastActiveAt: number; createdAt: number; expiresAt: number; }
const MAX_SESSIONS_PER_USER = 5;
const SESSION_TTL = 86400 * 30; // 30 days
const INACTIVE_TTL = 86400 * 7; // 7 days inactive
export async function createSession(params: { userId: string; userAgent: string; ip: string; country?: string }): Promise<{ sessionId: string; token: string }> {
const sessionId = `sess-${randomBytes(12).toString("hex")}`;
const token = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(token).digest("hex");
const fingerprint = createHash("md5").update(params.userAgent + params.ip).digest("hex").slice(0, 12);
const now = Date.now();
const session: Session = { id: sessionId, userId: params.userId, token: tokenHash, deviceFingerprint: fingerprint, userAgent: params.userAgent, ip: params.ip, country: params.country || "unknown", lastActiveAt: now, createdAt: now, expiresAt: now + SESSION_TTL * 1000 };
await redis.setex(`session:${tokenHash}`, SESSION_TTL, JSON.stringify(session));
await redis.sadd(`user:sessions:${params.userId}`, tokenHash);
// Enforce max sessions
const sessions = await redis.smembers(`user:sessions:${params.userId}`);
if (sessions.length > MAX_SESSIONS_PER_USER) {
const allSessions = await Promise.all(sessions.map(async (t) => { const d = await redis.get(`session:${t}`); return d ? JSON.parse(d) : null; }));
const sorted = allSessions.filter(Boolean).sort((a, b) => a.lastActiveAt - b.lastActiveAt);
const toRevoke = sorted.slice(0, sessions.length - MAX_SESSIONS_PER_USER);
for (const s of toRevoke) await revokeSession(params.userId, s.token);
}
// Security alert: new device/country
const knownFingerprints = await redis.smembers(`user:devices:${params.userId}`);
if (!knownFingerprints.includes(fingerprint)) {
await redis.sadd(`user:devices:${params.userId}`, fingerprint);
if (knownFingerprints.length > 0) {
await redis.rpush("notification:queue", JSON.stringify({ type: "new_device_login", userId: params.userId, ip: params.ip, userAgent: params.userAgent, country: params.country }));
}
}
return { sessionId, token };
}
export async function validateSession(token: string): Promise<Session | null> {
const tokenHash = createHash("sha256").update(token).digest("hex");
const data = await redis.get(`session:${tokenHash}`);
if (!data) return null;
const session: Session = JSON.parse(data);
if (Date.now() - session.lastActiveAt > INACTIVE_TTL * 1000) { await revokeSession(session.userId, tokenHash); return null; }
session.lastActiveAt = Date.now();
await redis.setex(`session:${tokenHash}`, SESSION_TTL, JSON.stringify(session));
return session;
}
export async function revokeSession(userId: string, tokenHash: string): Promise<void> {
await redis.del(`session:${tokenHash}`);
await redis.srem(`user:sessions:${userId}`, tokenHash);
}
export async function revokeAllSessions(userId: string, exceptToken?: string): Promise<number> {
const sessions = await redis.smembers(`user:sessions:${userId}`);
let revoked = 0;
for (const tokenHash of sessions) {
if (exceptToken && tokenHash === createHash("sha256").update(exceptToken).digest("hex")) continue;
await revokeSession(userId, tokenHash);
revoked++;
}
return revoked;
}
export async function getActiveSessions(userId: string): Promise<Array<{ id: string; device: string; ip: string; country: string; lastActive: string; current: boolean }>> {
const sessions = await redis.smembers(`user:sessions:${userId}`);
const result = [];
for (const tokenHash of sessions) {
const data = await redis.get(`session:${tokenHash}`);
if (!data) continue;
const s: Session = JSON.parse(data);
result.push({ id: s.id, device: parseDevice(s.userAgent), ip: s.ip, country: s.country, lastActive: new Date(s.lastActiveAt).toISOString(), current: false });
}
return result.sort((a, b) => new Date(b.lastActive).getTime() - new Date(a.lastActive).getTime());
}
function parseDevice(ua: string): string {
if (ua.includes("iPhone")) return "iPhone";
if (ua.includes("Android")) return "Android";
if (ua.includes("Mac")) return "Mac";
if (ua.includes("Windows")) return "Windows";
if (ua.includes("Linux")) return "Linux";
return "Unknown";
}
Results
- Compromised session revoked — user changes password →
revokeAllSessionsexcept current; attacker's session dead immediately; no lingering access - 5-device limit — 6th login revokes oldest session; shared account abuse detectable; compliance satisfied
- New device alert — first login from Android when user always uses Mac → security email; user confirms or revokes; account takeover caught early
- Inactive expiry — 7 days no activity → session auto-expires; forgotten sessions on public computers cleaned up; attack surface reduced
- Session dashboard — user sees "Mac/Chrome — San Francisco — Active 2 min ago" + "iPhone/Safari — New York — Active 3 days ago"; revoke any session in one click