A few months after a client shipped passkeys, their security review flagged something embarrassing: an admin who had left the company three weeks earlier still had a valid session on a shared office laptop. Disabling the account in the admin panel had done nothing, because the app issued 30-day JWTs with no server-side check. The login was phishing-resistant. The session was a bearer token that nobody could take back. Authentication gets the attention, but the session is what an attacker actually wants, and it is where most of my hardening work ends up.

I wrote this with help from AI tools and checked every snippet and config myself against Express 5, express-session and current OWASP Session Management guidance. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and I build the free developer tools at PixelValut. Everything here is about hardening sessions in an app you own.

📚 Series Navigation — Passkeys & Session Hardening for Web Apps

  • Part 1: Rolling Out Passkeys Without Locking Users Out
  • Part 2 (This Post): Session Lifecycle Hardening: Rotation, Timeouts & Server-Side Revocation
  • Part 3: CSRF in 2026: SameSite, Fetch Metadata & Origin Checks
  • Part 4: Account Recovery Hardening: The Side Door Next to Your Passkey
  • Part 5: Protecting Login Endpoints: Credential-Stuffing Signals & Adaptive Rate Limits
  • Part 6: Step-Up Authentication for Sensitive Actions

Related earlier posts: Browser Secrets That Leak: Token Storage, Quotas & Outbound Controls · OAuth 2.1, OIDC ID Tokens & PKCE Forensics

How do you harden the session lifecycle in a web app?

Direct answer: Issue a fresh session ID at login and on every privilege change, store sessions server side or keep JWTs short-lived with a revocable refresh token, enforce both idle and absolute timeouts, set HttpOnly, Secure and SameSite cookie flags, and make logout, password change and account disable revoke every active session immediately.

A brass-framed hourglass in low evening light, AI-generated

The lifecycle, stage by stage

I think about a session as five events, each with its own control:

Stage Risk if ignored Control Where I enforce it
Creation Session fixation (attacker-known ID reused after login) Regenerate ID at login Auth handler
Privilege change Low-privilege ID silently becomes admin Regenerate on role change, step-up, impersonation Role/step-up handlers
Idle Unattended laptop stays signed in Idle timeout (15–30 min admin, longer consumer) Session middleware
Lifetime Stolen token valid for weeks Absolute timeout (8–12 h admin, 7–30 days consumer) Session middleware
Termination Logout or disable does nothing server side Server-side revocation of all sessions Session store / token denylist

The numbers are my defaults, not a standard. Pick them per role and write them down so product and security agree before anyone complains about being logged out.

Server-side sessions in Express

For a traditional web app, server-side sessions are the simplest thing that can be revoked. The cookie carries only a random ID; the data lives in Redis.

// session.ts (Express 5, express-session, connect-redis) import session from 'express-session'; import { RedisStore } from 'connect-redis'; const IDLE_MS = 30 * 60 * 1000; // 30 min idle const ABSOLUTE_MS = 12 * 60 * 60 * 1000; // 12 h absolute app.use(session({ store: new RedisStore({ client: redis, prefix: 'sess:' }), name: '__Host-sid', // requires Secure, Path=/, no Domain secret: process.env.SESSION_SECRETS!.split(','), // first signs, rest verify (rotation) resave: false, saveUninitialized: false, rolling: true, // refresh idle expiry on activity cookie: { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: IDLE_MS }, })); // Absolute timeout: rolling cookies alone would let a session live forever app.use((req, res, next) => { const s = req.session as any; if (s.userId && Date.now() - s.createdAt > ABSOLUTE_MS) { return req.session.destroy(() => res.status(401).json({ error: 'session_expired' })); } next(); }); 

The __Host- prefix is an underrated control: browsers refuse the cookie unless it is Secure, has Path=/ and no Domain, so a sibling subdomain cannot overwrite it. The comma-separated secrets let you rotate the signing key without logging everyone out.

Rotation at login and privilege change

async function establishSession(req, user) { await new Promise((ok, fail) => req.session.regenerate(e => (e ? fail(e) : ok(null)))); Object.assign(req.session, { userId: user.id, role: user.role, createdAt: Date.now(), authAt: Date.now(), }); await redis.sAdd(`user_sessions:${user.id}`, req.sessionID); // index for revocation await audit(user.id, 'session_created', { ua: req.get('user-agent') }); } 

Call the same regenerate path when a user is granted a role, completes step-up authentication or starts admin impersonation. Laravel developers get this with $request->session()->regenerate() after Auth::attempt(); the point is to make it a rule in your codebase, not something each handler remembers.

Revocation that actually works

The user_sessions:{id} set is what makes "log out everywhere" possible:

async function revokeAllSessions(userId: string, reason: string) { const ids = await redis.sMembers(`user_sessions:${userId}`); if (ids.length) await redis.del(ids.map(id => `sess:${id}`)); await redis.del(`user_sessions:${userId}`); await audit(userId, 'sessions_revoked', { reason, count: ids.length }); } // Call on: password change, passkey removal, account disable, "sign out all devices" 

If you use JWTs, you cannot revoke an access token you already handed out, so keep it short (5–15 minutes) and put the control on the refresh token: store refresh tokens server side, rotate them on every use, and treat reuse of an old refresh token as a signal to revoke the whole family. That is the pattern OAuth 2.1 recommends for public clients, and it works just as well for your own API.

Checking what your app actually issues

Policies drift. Every few months I sign in to my own staging app, copy the token or cookie from DevTools and check what is really there. Paste a JWT from your test account into a JWT decoder and convert iat and exp with a Unix timestamp converter:

{ "sub": "usr_8f2c", "role": "admin", "iat": 1763107200, "exp": 1765699200 } 

That exp is 30 days after iat, for an admin. In the incident above, this one check would have found the problem months earlier. Only inspect tokens from your own accounts, and never paste production tokens for real users into any tool.

Diagnostic checklist

  • [ ] Session ID regenerated at login, role change, step-up and impersonation.
  • [ ] Idle and absolute timeouts both enforced server side, documented per role.
  • [ ] Cookies use HttpOnly, Secure, SameSite and the __Host- prefix.
  • [ ] Session data is server side, or access JWTs live 15 minutes or less.
  • [ ] Refresh tokens are stored, rotated on use and reuse revokes the family.
  • [ ] Logout destroys the server-side session, not just the cookie.
  • [ ] Password change, passkey removal and account disable revoke all sessions.
  • [ ] Users can see active sessions and sign out other devices.
  • [ ] Signing secrets can rotate without a forced global logout.
  • [ ] Session creation and revocation events are audited.

FAQ

Server-side sessions or JWTs?

For a web app with a single backend, server-side sessions are simpler and revocable. JWTs make sense across many services; keep them short and pair them with revocable refresh tokens.

What idle timeout should I use?

I start at 15–30 minutes for admin and finance roles and hours to days for low-risk consumer apps, then adjust with real complaint and incident data.

Does SameSite=Lax replace CSRF protection?

It helps a lot but is not complete. Part 3 covers SameSite, Fetch Metadata and Origin checks together.

Should logout revoke all devices?

Normal logout should end the current session. Offer "sign out all devices" separately, and trigger it automatically on password or passkey changes.

How do I rotate the session signing secret?

Add the new secret at the front of the list, keep the old one for verification until all sessions issued with it have expired, then remove it.

Is storing sessions in Redis safe?

Yes, if Redis requires authentication, is not exposed publicly and uses TLS between hosts. Treat it like any other database holding credentials.

Next in this series, Part 3 covers CSRF in 2026: SameSite, Fetch Metadata and Origin checks.

Follow & Connect