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
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!
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)
);
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: '/'
});
}
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();
}
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 });
}
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
}
});
}
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
- Check cookie attributes: Use browser DevTools to verify httpOnly, secure, sameSite
- Test session fixation: Set a session ID before login, verify it changes after login
- Test revocation: Logout and verify the session cookie is cleared and old session is invalid
- 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