Session Management Best Practices: Secure Session Handling

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."

Four flags, and the defaults are wrong on all four. Set them explicitly:

Secure session cookie configuration
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 FlagPurposeSetting
HttpOnlyPrevents XSS theftAlways true
SecureHTTPS onlyAlways true in production
SameSiteCSRF protection"lax" or "strict"
DomainCookie scopeSpecific domain, not broad
PathURL 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:

Session regeneration
// 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:

Session timeout middleware
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:

StorageProsConsBest For
RedisFast, TTL supportExtra infrastructureMost applications
DatabaseNo extra depsSlower, needs cleanupSimple apps
MemorySimpleLost on restart, no scaleDevelopment only
JWT (client)StatelessCannot revoke easilyAPI-only services
Database session store
// 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:

Complete logout implementation
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:

Session binding and validation
// 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.

Best Practices

Session Management Best Practices: Secure Session Handling