Session Expiration
Session Expiration at a glance
Overview
Session expiration controls limit how long authenticated state persists, especially after inactivity. When applications omit idle timeouts or set very long lifetimes, stolen cookies or tokens remain usable for weeks. Attackers can replay old cookies to access accounts and perform sensitive operations without reauthentication.
Where it occurs
Common causes are frameworks left at permissive defaults, custom remember-me implementations without rotation, missing server-side TTLs, and ignoring recent-auth requirements for high-value actions.
Impact
A single cookie theft leads to long-term account access, payment method changes, data exfiltration, and difficulty in incident containment because old tokens continue to work.
Prevention
Use short idle timeouts with sliding refresh, plus an absolute lifetime cap. Rotate remember-me tokens and invalidate all sessions on password change and logout. Store server-side session indices and enforce TTLs. Add device binding where feasible and use secure cookie attributes (HttpOnly, Secure, SameSite). Require recent authentication for sensitive actions.
Examples
Switch tabs to view language/framework variants.
Session cookie set without expiry or idle timeout
Sessions persist indefinitely on the server and client, increasing hijack window if tokens leak.
const session = require('express-session');
app.use(session({
secret: 'devsecret',
resave: false,
saveUninitialized: false // BUG: no expiration configured
}));- Line 5: No maxAge or server-side TTL, sessions live too long
Without an idle timeout, stolen sessions remain valid for a long period.
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 30*60*1000 },
rolling: true // refresh expiry on activity
}));- Line 7: Idle timeout via cookie maxAge and rolling refresh
Short idle timeouts and secure cookie flags reduce the window of abuse.
Engineer Checklist
-
Set idle timeout to 15–30 minutes with sliding expiration
-
Set an absolute session lifetime cap, for example 8–24 hours for web sessions
-
Rotate and revoke remember-me tokens and sessions on logout and password change
-
Use secure cookie flags and bind sessions to device where possible
-
Require recent authentication for payment, MFA, and email changes
End-to-End Example
A legacy portal keeps sessions valid for 30 days and does not refresh or revoke them on sensitive changes. An attacker who stole a cookie during a cafe Wi-Fi session can still access the account a month later.
// Node.js/Express - Vulnerable session expiration
const session = require('express-session');
app.use(session({
secret: 'my-secret',
resave: false,
saveUninitialized: false,
cookie: {
// VULNERABLE: No maxAge set - session cookie persists until browser close
// And browser may keep session alive across restarts
secure: false, // Also vulnerable: not requiring HTTPS
httpOnly: true
}
// VULNERABLE: No session store TTL - sessions never expire server-side!
}));
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (user && await bcrypt.compare(password, user.passwordHash)) {
req.session.userId = user._id;
// VULNERABLE: No tracking of last activity time
// No absolute session creation time either
res.json({ message: 'Login successful' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// VULNERABLE: Remember-me with permanent, non-rotating token
app.post('/remember-me', authenticateSession, (req, res) => {
const user = req.user;
// Generate remember token (once, never rotated)
const rememberToken = crypto.randomBytes(32).toString('hex');
// VULNERABLE: Store permanently, no expiration
await User.findByIdAndUpdate(user._id, { rememberToken });
// VULNERABLE: Set permanent cookie (no maxAge)
res.cookie('remember_me', rememberToken, {
httpOnly: true,
// No maxAge - cookie lives forever!
});
res.json({ message: 'Remember me enabled' });
});
// VULNERABLE: Password change doesn't invalidate sessions
app.post('/change-password', authenticateSession, async (req, res) => {
const { currentPassword, newPassword } = req.body;
const user = await User.findById(req.session.userId);
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Current password incorrect' });
}
const newHash = await bcrypt.hash(newPassword, 12);
await User.findByIdAndUpdate(user._id, { passwordHash: newHash });
// VULNERABLE: Old sessions remain valid!
// Attacker who stole session can still access after password change
res.json({ message: 'Password changed' });
});// Node.js/Express - Secure session expiration
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const bcrypt = require('bcrypt');
// SECURE: Configure Redis client for session storage with TTL
const redisClient = redis.createClient({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
});
app.use(session({
store: new RedisStore({
client: redisClient,
// SECURE: Server-side TTL for sessions
ttl: 1800 // 30 minutes in seconds
}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true, // SECURE: Sliding expiration - resets on each request
cookie: {
maxAge: 1800000, // SECURE: 30 minute idle timeout (ms)
httpOnly: true, // SECURE: Prevent XSS access
secure: true, // SECURE: HTTPS only
sameSite: 'strict' // SECURE: CSRF protection
}
}));
// SECURE: Track session creation time for absolute timeout
app.use((req, res, next) => {
if (req.session && !req.session.createdAt) {
req.session.createdAt = Date.now();
}
// SECURE: Enforce absolute session lifetime (12 hours)
const ABSOLUTE_TIMEOUT = 12 * 60 * 60 * 1000; // 12 hours in ms
if (req.session && req.session.createdAt) {
const sessionAge = Date.now() - req.session.createdAt;
if (sessionAge > ABSOLUTE_TIMEOUT) {
// SECURE: Destroy session after absolute timeout
req.session.destroy();
return res.status(401).json({
error: 'Session expired',
reason: 'Maximum session lifetime exceeded'
});
}
}
// SECURE: Update last activity timestamp
if (req.session && req.session.userId) {
req.session.lastActivity = Date.now();
}
next();
});
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (user && await bcrypt.compare(password, user.passwordHash)) {
// SECURE: Regenerate session ID on login to prevent fixation
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
req.session.userId = user._id;
req.session.createdAt = Date.now();
req.session.lastActivity = Date.now();
res.json({ message: 'Login successful' });
});
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// SECURE: Remember-me with rotating tokens
app.post('/remember-me', authenticateSession, async (req, res) => {
const user = req.user;
// SECURE: Generate cryptographically strong token
const rememberToken = crypto.randomBytes(32).toString('hex');
const tokenHash = await bcrypt.hash(rememberToken, 10);
// SECURE: Store token hash with expiration
await User.findByIdAndUpdate(user._id, {
rememberToken: tokenHash,
rememberTokenExpiry: Date.now() + (30 * 24 * 60 * 60 * 1000) // 30 days
});
// SECURE: Set cookie with explicit maxAge
res.cookie('remember_me', rememberToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
});
res.json({ message: 'Remember me enabled' });
});
// Middleware to restore session from remember-me token
app.use(async (req, res, next) => {
if (!req.session.userId && req.cookies.remember_me) {
const token = req.cookies.remember_me;
// Find user with valid remember token
const users = await User.find({
rememberTokenExpiry: { $gt: Date.now() }
});
for (const user of users) {
if (await bcrypt.compare(token, user.rememberToken)) {
// SECURE: Rotate the remember token on use
const newToken = crypto.randomBytes(32).toString('hex');
const newTokenHash = await bcrypt.hash(newToken, 10);
await User.findByIdAndUpdate(user._id, {
rememberToken: newTokenHash,
rememberTokenExpiry: Date.now() + (30 * 24 * 60 * 60 * 1000)
});
// SECURE: Issue new cookie with rotated token
res.cookie('remember_me', newToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000
});
// Create new session
req.session.userId = user._id;
req.session.createdAt = Date.now();
break;
}
}
}
next();
});
// SECURE: Password change invalidates all sessions
app.post('/change-password', authenticateSession, async (req, res) => {
const { currentPassword, newPassword } = req.body;
const user = await User.findById(req.session.userId);
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Current password incorrect' });
}
const newHash = await bcrypt.hash(newPassword, 12);
// SECURE: Increment session version to invalidate old sessions
await User.findByIdAndUpdate(user._id, {
passwordHash: newHash,
sessionVersion: (user.sessionVersion || 0) + 1,
rememberToken: null, // SECURE: Invalidate remember-me
rememberTokenExpiry: null
});
// SECURE: Destroy current session (user will need to re-login)
req.session.destroy();
res.json({ message: 'Password changed. Please login again.' });
});
// SECURE: Logout with explicit session destruction
app.post('/logout', authenticateSession, (req, res) => {
const userId = req.session.userId;
// SECURE: Destroy server-side session
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
// SECURE: Clear remember-me cookie
res.clearCookie('remember_me');
res.clearCookie('connect.sid'); // Session cookie
res.json({ message: 'Logged out successfully' });
});
});
// SECURE: Require recent authentication for sensitive operations
const requireRecentAuth = (req, res, next) => {
const RECENT_AUTH_WINDOW = 5 * 60 * 1000; // 5 minutes
if (!req.session.lastAuthTime) {
return res.status(403).json({
error: 'Recent authentication required',
needsReauth: true
});
}
const timeSinceAuth = Date.now() - req.session.lastAuthTime;
if (timeSinceAuth > RECENT_AUTH_WINDOW) {
return res.status(403).json({
error: 'Recent authentication required',
needsReauth: true
});
}
next();
};
// SECURE: Step-up authentication endpoint
app.post('/reauth', authenticateSession, async (req, res) => {
const { password } = req.body;
const user = await User.findById(req.session.userId);
if (await bcrypt.compare(password, user.passwordHash)) {
req.session.lastAuthTime = Date.now();
res.json({ message: 'Re-authenticated successfully' });
} else {
res.status(401).json({ error: 'Invalid password' });
}
});
// SECURE: Sensitive operations require recent auth
app.post('/billing/update', authenticateSession, requireRecentAuth, async (req, res) => {
// Update payment method - only allowed with recent authentication
const { cardNumber, exp, cvv } = req.body;
await User.findByIdAndUpdate(req.session.userId, {
paymentMethod: { cardNumber, exp }
});
res.json({ message: 'Payment method updated' });
});
app.post('/security/mfa-disable', authenticateSession, requireRecentAuth, async (req, res) => {
// Disable MFA - requires recent authentication
await User.findByIdAndUpdate(req.session.userId, {
mfaEnabled: false,
mfaSecret: null
});
res.json({ message: 'MFA disabled' });
});Discovery
This vulnerability is discovered by logging in, capturing the session token, waiting beyond expected timeout periods, and observing that the session remains valid indefinitely or for excessively long periods without re-authentication.
-
1. Session longevity baseline test
httpAction
Create fresh session and test immediate access to establish baseline
Request
GET https://app.example.com/dashboardHeaders:Cookie: session=<FRESH_SESSION>Response
Status: 200Body:{ "note": "200 OK with full access to authenticated resources" }Artifacts
http_response_status session_data -
2. Long-term session validity test
httpAction
Test session validity after extended period (days/weeks) without activity
Request
GET https://app.example.com/accountHeaders:Cookie: session=<WEEKS_OLD_SESSION>Response
Status: 200Body:{ "note": "Session still valid weeks later, no expiration or idle timeout enforced" }Artifacts
http_response_status session_age last_activity_timestamp -
3. Idle timeout test
httpAction
Test if sessions expire after period of inactivity
Request
GET https://app.example.com/api/userHeaders:Cookie: session=<INACTIVE_SESSION>Response
Status: 200Body:{ "note": "Session remains valid despite 48+ hours of inactivity" }Artifacts
session_validity idle_duration server_session_ttl -
4. Remember-me token persistence test
httpAction
Test persistence of remember-me tokens and whether they rotate on use
Request
GET https://app.example.com/dashboardHeaders:Cookie: remember_token=<REMEMBER_TOKEN>Response
Status: 200Body:{ "note": "Remember token valid for months, never rotates, provides full access" }Artifacts
remember_token_age token_rotation_status session_restoration
Exploit steps
An attacker exploits this by stealing or intercepting session tokens (through XSS, network sniffing, or physical access) and using them for extended periods to maintain unauthorized access, even after the legitimate user has logged out.
-
1. Long-term account access via stolen session
Reuse intercepted session cookie
httpAction
Access account using session cookie stolen weeks ago
Request
GET https://app.example.com/dashboardHeaders:Cookie: session=<STOLEN_OLD_SESSION>Response
Status: 200Body:{ "note": "Full account access despite session age and victim password changes" }Artifacts
account_data_access session_validity_confirmation user_activity_logs -
2. Sensitive operations without re-authentication
Modify payment information
httpAction
Change billing details using old session without recent auth verification
Request
POST https://app.example.com/billing/updateHeaders:Cookie: session=<OLD_SESSION>Content-Type: application/jsonBody:{ "card_number": "4111111111111111", "exp": "12/25", "cvv": "123" }Response
Status: 200Body:{ "note": "Payment method updated without step-up authentication" }Artifacts
payment_method_change billing_records fraud_indicators -
3. Data exfiltration via persistent session
Extract sensitive data over time
httpAction
Use long-lived session to systematically extract user data and documents
Request
GET https://app.example.com/api/documents/exportHeaders:Cookie: session=<PERSISTENT_SESSION>Response
Status: 200Body:{ "note": "Complete data export successful using months-old session" }Artifacts
exported_documents pii_data download_logs -
4. Account takeover via remember-me abuse
Exploit non-rotating remember token
httpAction
Use captured remember-me token for permanent access across password changes
Request
GET https://app.example.com/restore-sessionHeaders:Cookie: remember_token=<CAPTURED_TOKEN>Response
Status: 200Body:{ "note": "New full session created despite victim changing password" }Artifacts
new_session_created remember_token_reuse account_takeover_proof
Specific Impact
The attacker maintains persistent access and can modify billing and MFA settings. Incident response is harder because the same cookie continues to work until manual invalidation.
Users may be billed fraudulently and trust is damaged, leading to support load and potential regulatory reporting.
Fix
Introduce a 30-minute sliding idle timeout with a 12-hour absolute cap. Ensure the server-side store enforces TTLs and that logout and password changes revoke tokens. Add recent-auth checks for high-risk actions and set secure cookie flags.
Detect This Vulnerability in Your Code
Sourcery automatically identifies session expiration vulnerabilities and many other security issues in your codebase.
Scan Your Code for Free