TL;DR
If you change one thing, change the session ID at login. That single line is what stops session fixation, and it's the step AI-generated auth code leaves out most often. Beyond that: HttpOnly, Secure and SameSite on the cookie, random IDs, timeouts at both ends, and session state on the server so you can actually revoke it.
"A session is only as secure as its weakest moment. Regenerate on login, timeout on idle, and invalidate on logout."
Best Practice 1: Secure Cookie Settings 5 min
Four flags, and the defaults are wrong on all four. Set them explicitly:
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
name: 'sessionId', // Custom name (not "connect.sid")
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JavaScript cannot access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
domain: '.example.com', // Specific domain
path: '/',
},
}));
| Cookie Flag | Purpose | Setting |
|---|---|---|
| HttpOnly | Prevents XSS theft | Always true |
| Secure | HTTPS only | Always true in production |
| SameSite | CSRF protection | "lax" or "strict" |
| Domain | Cookie scope | Specific domain, not broad |
| Path | URL scope | "/" or specific path |
Best Practice 2: Regenerate Session on Auth Changes 3 min
Session fixation works because the ID a visitor arrives with is the ID they keep after logging in. Break that and the attack has nothing to hold on to:
// CRITICAL: Regenerate session after login
async function login(req, email, password) {
const user = await authenticateUser(email, password);
if (user) {
// Regenerate session ID to prevent fixation
req.session.regenerate((err) => {
if (err) throw err;
// Store user info in new session
req.session.userId = user.id;
req.session.role = user.role;
req.session.loginTime = Date.now();
req.session.save((err) => {
if (err) throw err;
return user;
});
});
}
}
// Also regenerate after privilege changes
async function elevatePrivileges(req, newRole) {
req.session.regenerate((err) => {
if (err) throw err;
req.session.role = newRole;
req.session.save();
});
}
Best Practice 3: Session Timeout Strategy 5 min
You want two clocks, not one. Idle timeout catches the abandoned laptop; absolute timeout caps how long a stolen cookie stays useful:
const IDLE_TIMEOUT = 30 * 60 * 1000; // 30 minutes
const ABSOLUTE_TIMEOUT = 8 * 60 * 60 * 1000; // 8 hours
function sessionTimeoutMiddleware(req, res, next) {
if (!req.session.userId) {
return next();
}
const now = Date.now();
// Check absolute timeout (since login)
if (now - req.session.loginTime > ABSOLUTE_TIMEOUT) {
return req.session.destroy(() => {
res.status(401).json({ error: 'Session expired' });
});
}
// Check idle timeout (since last activity)
if (req.session.lastActivity &&
now - req.session.lastActivity > IDLE_TIMEOUT) {
return req.session.destroy(() => {
res.status(401).json({ error: 'Session expired due to inactivity' });
});
}
// Update last activity
req.session.lastActivity = now;
next();
}
app.use(sessionTimeoutMiddleware);
Best Practice 4: Server-Side Session Storage 10 min
Server-side storage buys you one thing that matters: the ability to end a session you no longer trust, right now, without waiting for a token to expire:
| Storage | Pros | Cons | Best For |
|---|---|---|---|
| Redis | Fast, TTL support | Extra infrastructure | Most applications |
| Database | No extra deps | Slower, needs cleanup | Simple apps |
| Memory | Simple | Lost on restart, no scale | Development only |
| JWT (client) | Stateless | Cannot revoke easily | API-only services |
// Prisma session store example
import { PrismaSessionStore } from '@quixo3/prisma-session-store';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
app.use(session({
store: new PrismaSessionStore(prisma, {
checkPeriod: 2 * 60 * 1000, // Cleanup every 2 min
dbRecordIdIsSessionId: true,
}),
// ... other options
}));
// Session schema in Prisma
// model Session {
// id String @id
// sid String @unique
// data String
// expiresAt DateTime
// }
Best Practice 5: Proper Session Invalidation 5 min
Logout has to destroy the record, not just the cookie. A cookie the browser drops but the server still honours is not a logout:
async function logout(req, res) {
const userId = req.session.userId;
// Destroy the session
req.session.destroy((err) => {
if (err) {
console.error('Session destruction failed:', err);
}
// Clear the cookie
res.clearCookie('sessionId', {
httpOnly: true,
secure: true,
sameSite: 'lax',
domain: '.example.com',
path: '/',
});
res.json({ message: 'Logged out' });
});
}
// Logout from all devices (invalidate all sessions)
async function logoutAll(req, res) {
const userId = req.session.userId;
// With Redis: delete all sessions for user
const keys = await redisClient.keys(`sess:*`);
for (const key of keys) {
const session = await redisClient.get(key);
if (session && JSON.parse(session).userId === userId) {
await redisClient.del(key);
}
}
res.json({ message: 'Logged out from all devices' });
}
Best Practice 6: Session Security Checks 10 min
Some actions deserve a second look even from a valid session. Changing a password or an email address is worth re-checking how recently the person actually logged in:
// Bind session to browser fingerprint
function createSession(req, user) {
req.session.userId = user.id;
req.session.fingerprint = hashFingerprint(
req.ip,
req.headers['user-agent']
);
}
// Validate on each request
function validateSession(req, res, next) {
if (!req.session.userId) {
return next();
}
const currentFingerprint = hashFingerprint(
req.ip,
req.headers['user-agent']
);
// Strict mode: reject if fingerprint changes
if (req.session.fingerprint !== currentFingerprint) {
logger.warn('session.fingerprint_mismatch', {
userId: req.session.userId,
expected: req.session.fingerprint,
actual: currentFingerprint,
});
return req.session.destroy(() => {
res.status(401).json({ error: 'Session invalid' });
});
}
next();
}
// Re-authenticate for sensitive actions
async function requireRecentAuth(req, res, next) {
const AUTH_FRESHNESS = 5 * 60 * 1000; // 5 minutes
if (Date.now() - req.session.loginTime > AUTH_FRESHNESS) {
return res.status(403).json({
error: 'Please re-authenticate',
code: 'REAUTH_REQUIRED',
});
}
next();
}
Official Resources: For comprehensive session management guidance, see OWASP Session Management Cheat Sheet and OWASP Session Fixation Attack.
Should I use sessions or JWTs?
Sessions, for anything with a login. They're easier to revoke, which is the property you'll want on your worst day. JWTs earn their place on stateless APIs that scale without shared storage.
What is session fixation?
The attacker gets you to carry a session ID they already know, then waits for you to log in with it. Now it's an authenticated session and they have the ID. Regenerating at login is the whole fix.
::faq-item{question="How do I handle "remember me" functionality?"} Don't stretch the session. Issue a separate long-lived token, validate it on return, and mint a fresh session from it. Store it hashed and rotate it every time it's used. ::
Further Reading
Put these practices into action with our step-by-step guides.
Check Your Session Security
Scan for session vulnerabilities and misconfigurations.