How to Implement Secure Session Management

Share
How-To Guide

How to Implement Secure Session Management

Server-side sessions done right

TL;DR

TL;DR (25 minutes): Generate 32+ byte random session IDs, store hashed IDs in your database with user info and expiry, use httpOnly/secure/sameSite cookies, regenerate session ID on login/privilege change, implement sliding expiration (extend on activity), and allow users to view and revoke their sessions.

Prerequisites:

  • Database or Redis for session storage
  • Basic understanding of cookies
  • Authentication system to protect

Why This Matters

Sessions are how your app "remembers" logged-in users. Weak session management leads to session hijacking, fixation attacks, and account takeovers. Server-side sessions are generally more secure than JWTs because you can instantly revoke them.

Step-by-Step Guide

1

Generate secure session IDs

import crypto from 'crypto';

// Generate cryptographically secure session ID
function generateSessionId(): string {
  // 32 bytes = 256 bits of entropy
  return crypto.randomBytes(32).toString('hex');
}

// Hash the session ID before storing
function hashSessionId(sessionId: string): string {
  return crypto.createHash('sha256').update(sessionId).digest('hex');
}

// NEVER do this:
// const sessionId = Date.now().toString();  // Predictable!
// const sessionId = Math.random().toString(); // Not cryptographically secure!
2

Create session storage schema

// Prisma schema
model Session {
  id           String   @id @default(cuid())
  sessionHash  String   @unique  // Store hashed session ID
  userId       String
  user         User     @relation(fields: [userId], references: [id])
  userAgent    String?
  ipAddress    String?
  createdAt    DateTime @default(now())
  expiresAt    DateTime
  lastActiveAt DateTime @default(now())

  @@index([userId])
  @@index([expiresAt])
}

// SQL equivalent
CREATE TABLE sessions (
  id VARCHAR(36) PRIMARY KEY,
  session_hash VARCHAR(64) UNIQUE NOT NULL,
  user_id VARCHAR(36) NOT NULL REFERENCES users(id),
  user_agent TEXT,
  ip_address VARCHAR(45),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  expires_at TIMESTAMP NOT NULL,
  last_active_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_user_id (user_id),
  INDEX idx_expires_at (expires_at)
);
3

Create and store sessions

import { addDays } from 'date-fns';

interface CreateSessionOptions {
  userId: string;
  userAgent?: string;
  ipAddress?: string;
  rememberMe?: boolean;
}

async function createSession(options: CreateSessionOptions) {
  const sessionId = generateSessionId();
  const sessionHash = hashSessionId(sessionId);

  const expiresAt = options.rememberMe
    ? addDays(new Date(), 30)  // 30 days for "remember me"
    : addDays(new Date(), 7);   // 7 days default

  await prisma.session.create({
    data: {
      sessionHash,
      userId: options.userId,
      userAgent: options.userAgent,
      ipAddress: options.ipAddress,
      expiresAt
    }
  });

  // Return the unhashed session ID for the cookie
  return { sessionId, expiresAt };
}

// Set the session cookie
function setSessionCookie(res: Response, sessionId: string, expiresAt: Date) {
  res.cookie('sessionId', sessionId, {
    httpOnly: true,       // Can't be accessed by JavaScript
    secure: process.env.NODE_ENV === 'production',  // HTTPS only in prod
    sameSite: 'lax',      // Protects against CSRF
    expires: expiresAt,
    path: '/'
  });
}
4

Validate sessions

interface ValidatedSession {
  user: User;
  sessionId: string;
}

async function validateSession(sessionId: string): Promise {
  if (!sessionId) return null;

  const sessionHash = hashSessionId(sessionId);

  const session = await prisma.session.findUnique({
    where: { sessionHash },
    include: { user: true }
  });

  // Session not found
  if (!session) return null;

  // Session expired
  if (session.expiresAt < new Date()) {
    await prisma.session.delete({ where: { id: session.id } });
    return null;
  }

  // Update last active time (sliding expiration)
  await prisma.session.update({
    where: { id: session.id },
    data: {
      lastActiveAt: new Date(),
      // Optionally extend expiration on activity
      expiresAt: addDays(new Date(), 7)
    }
  });

  return { user: session.user, sessionId };
}

// Auth middleware
async function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const sessionId = req.cookies.sessionId;
  const session = await validateSession(sessionId);

  if (!session) {
    res.clearCookie('sessionId');
    return res.status(401).json({ error: 'Not authenticated' });
  }

  req.user = session.user;
  req.sessionId = session.sessionId;
  next();
}
5

Regenerate session on privilege change

// Critical: Always regenerate session ID on:
// - Login
// - Password change
// - Role/permission change
// - Any security-sensitive action

async function regenerateSession(req: Request, res: Response) {
  const oldSessionId = req.cookies.sessionId;

  if (oldSessionId) {
    // Delete old session
    const oldHash = hashSessionId(oldSessionId);
    await prisma.session.delete({
      where: { sessionHash: oldHash }
    }).catch(() => {}); // Ignore if already deleted
  }

  // Create new session
  const { sessionId, expiresAt } = await createSession({
    userId: req.user.id,
    userAgent: req.headers['user-agent'],
    ipAddress: req.ip
  });

  setSessionCookie(res, sessionId, expiresAt);
  return sessionId;
}

// Usage in login
async function login(req: Request, res: Response) {
  // ... verify credentials ...

  // Create session
  const { sessionId, expiresAt } = await createSession({
    userId: user.id,
    userAgent: req.headers['user-agent'],
    ipAddress: req.ip,
    rememberMe: req.body.rememberMe
  });

  setSessionCookie(res, sessionId, expiresAt);
  return res.json({ success: true });
}

// Usage in password change
async function changePassword(req: Request, res: Response) {
  // ... update password ...

  // Regenerate session (invalidates old session)
  await regenerateSession(req, res);

  // Optionally: revoke all other sessions
  await revokeAllUserSessions(req.user.id, req.cookies.sessionId);

  return res.json({ success: true });
}
6

Implement session revocation

// Logout - revoke current session
async function logout(req: Request, res: Response) {
  const sessionId = req.cookies.sessionId;

  if (sessionId) {
    const sessionHash = hashSessionId(sessionId);
    await prisma.session.delete({
      where: { sessionHash }
    }).catch(() => {});
  }

  res.clearCookie('sessionId');
  return res.json({ success: true });
}

// Revoke all sessions except current
async function revokeAllUserSessions(userId: string, exceptSessionId?: string) {
  const exceptHash = exceptSessionId ? hashSessionId(exceptSessionId) : null;

  await prisma.session.deleteMany({
    where: {
      userId,
      ...(exceptHash && { sessionHash: { not: exceptHash } })
    }
  });
}

// List user's active sessions
async function listUserSessions(userId: string) {
  return prisma.session.findMany({
    where: {
      userId,
      expiresAt: { gt: new Date() }
    },
    select: {
      id: true,
      userAgent: true,
      ipAddress: true,
      createdAt: true,
      lastActiveAt: true
    },
    orderBy: { lastActiveAt: 'desc' }
  });
}

// Revoke specific session
async function revokeSession(userId: string, sessionDbId: string) {
  await prisma.session.deleteMany({
    where: {
      id: sessionDbId,
      userId  // Ensure user can only revoke their own sessions
    }
  });
}
7

Clean up expired sessions

// Run this periodically (cron job, scheduled function)
async function cleanupExpiredSessions() {
  const result = await prisma.session.deleteMany({
    where: {
      expiresAt: { lt: new Date() }
    }
  });

  console.log(`Cleaned up ${result.count} expired sessions`);
}

// For high-traffic apps, consider using Redis instead
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

async function createSessionRedis(userId: string, sessionId: string, ttl: number) {
  const sessionHash = hashSessionId(sessionId);
  const sessionData = JSON.stringify({
    userId,
    createdAt: Date.now()
  });

  await redis.set(`session:${sessionHash}`, sessionData, 'EX', ttl);
}

async function validateSessionRedis(sessionId: string) {
  const sessionHash = hashSessionId(sessionId);
  const data = await redis.get(`session:${sessionHash}`);

  if (!data) return null;

  // Extend TTL on activity
  await redis.expire(`session:${sessionHash}`, 7 * 24 * 60 * 60);

  return JSON.parse(data);
}

Session Security Checklist:

  • Use 32+ bytes of cryptographically secure randomness for session IDs
  • Store hashed session IDs, not raw values
  • Set httpOnly, secure, and sameSite on cookies
  • Regenerate session ID on login and privilege changes
  • Implement absolute and idle timeout
  • Bind sessions to user agent and/or IP (optional, can cause issues)
  • Allow users to view and revoke their sessions
  • Clean up expired sessions regularly

How to Verify It Worked

  1. Check cookie attributes: Use browser DevTools to verify httpOnly, secure, sameSite
  2. Test session fixation: Set a session ID before login, verify it changes after login
  3. Test revocation: Logout and verify the session cookie is cleared and old session is invalid
  4. Test expiration: Create a session with short expiry, wait, verify it's rejected

Common Errors & Troubleshooting

Session not persisting across requests

Check cookie domain, path, and secure settings. In development without HTTPS, set secure: false.

Session immediately expires

Check that expiresAt is set correctly and your server/client clocks are synchronized.

Can't logout (session persists)

Ensure you're deleting the session from the database AND clearing the cookie.

Performance issues with many sessions

Add indexes on sessionHash, userId, and expiresAt. Consider moving to Redis for high-traffic apps.

Sessions vs JWTs - which should I use?

Server-side sessions are generally better for web apps: instant revocation, smaller cookies, no token expiration complexity. JWTs are better for stateless APIs, microservices, or mobile apps where you need offline token validation.

Should I bind sessions to IP address?

It adds security but can break for users on mobile networks or with changing IPs. Consider logging IP changes and notifying users rather than immediately invalidating sessions.

How do I handle concurrent sessions?

Allow multiple sessions by default (users on multiple devices). Let users view and revoke sessions. Optionally limit to N concurrent sessions for sensitive apps.

Related guides:Secure Login Form · JWT Security · CSRF Protection

How-To Guides

How to Implement Secure Session Management