Insufficient Session Expiration
Description
Insufficient Session Expiration occurs when a web application permits an attacker to reuse old session credentials or session IDs for authorization. This happens when sessions don't expire after a reasonable period of inactivity, don't expire upon logout, or when session tokens remain valid indefinitely. Long-lived sessions increase the window of opportunity for session hijacking attacks. Even after users believe they have logged out, their sessions may remain valid for attackers who have captured the session token.
Risk
Long-lived sessions significantly increase attack surface. Stolen session tokens remain usable for extended periods. Shared or public computers may retain valid sessions. Logout functionality that doesn't properly invalidate sessions gives users false security. Compliance frameworks require session timeout controls. Session tokens exposed in logs, browser history, or through XSS remain exploitable until they expire. Organizations have suffered breaches from old session tokens being exploited months after theft.
Solution
Implement absolute session timeouts (maximum session lifetime regardless of activity). Implement idle timeouts (expire after period of inactivity). Invalidate sessions server-side on logout. Regenerate session IDs on privilege changes (login, role change). Use secure session storage with proper expiration. Set appropriate cookie expiration. Consider implementing session binding to prevent token reuse from different contexts. For sensitive operations, require re-authentication.
Common Consequences
| Impact | Details |
|---|---|
| Access Control | Scope: Session Hijacking Long-lived sessions extend the window for captured tokens to be exploited. |
| Confidentiality | Scope: Unauthorized Access Attackers can access user accounts using old but still valid sessions. |
| Non-Repudiation | Scope: Accountability Actions taken with hijacked sessions cannot be reliably attributed. |
Example Code + Solution Code
Vulnerable Code
# VULNERABLE: No session timeout
from flask import Flask, session
app = Flask(__name__)
app.secret_key = 'secret'
# No session lifetime configured - sessions never expire!
@app.route('/login', methods=['POST'])
def login():
# ... authenticate user ...
session['user_id'] = user.id
session['logged_in'] = True
# Session lives forever!
return redirect('/dashboard')
@app.route('/logout')
def logout():
# Only clears client-side session data
session.clear()
# Server doesn't track invalidation
# Old session cookie could still work!
return redirect('/login')
# VULNERABLE: Permanent sessions
@app.route('/login', methods=['POST'])
def login_permanent():
session.permanent = True # Session lives for 31 days by default
session['user_id'] = user.id
# Way too long for sensitive applications
return redirect('/dashboard')
// VULNERABLE: No session timeout in Spring
@Configuration
public class SessionConfig {
// No session timeout configured
// Sessions live until server restart
}
// VULNERABLE: Logout doesn't invalidate session
@Controller
public class AuthController {
@PostMapping("/logout")
public String logout(HttpSession session) {
// Just removes attributes, doesn't invalidate
session.removeAttribute("user");
// Session ID still valid!
return "redirect:/login";
}
}
// VULNERABLE: Long session timeout
@Configuration
public class InsecureSessionConfig implements WebMvcConfigurer {
@Bean
public ServletContextInitializer servletContextInitializer() {
return servletContext -> {
// 7 days session timeout - too long!
servletContext.setSessionTimeout(10080);
};
}
}
// VULNERABLE: JWT without expiration
const jwt = require('jsonwebtoken');
function generateToken(user) {
// No expiration set!
return jwt.sign({ userId: user.id }, secret);
}
// VULNERABLE: Long-lived tokens
function generateTokenWithExpiry(user) {
return jwt.sign(
{ userId: user.id },
secret,
{ expiresIn: '365d' } // Valid for a year!
);
}
// VULNERABLE: Express session without timeout
app.use(session({
secret: 'keyboard cat',
resave: false,
saveUninitialized: true,
cookie: {
secure: true
// No maxAge - session cookie but no server-side expiry!
}
}));
// VULNERABLE: Logout doesn't destroy session
app.post('/logout', (req, res) => {
req.session.user = null; // Just nulls the user
// Session still exists and could be reused
res.redirect('/login');
});
Fixed Code
# SAFE: Proper session timeouts
from flask import Flask, session
from datetime import timedelta
app = Flask(__name__)
app.secret_key = os.urandom(32)
# Configure session lifetime
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=30) # Absolute timeout
app.config['SESSION_REFRESH_EACH_REQUEST'] = True # Reset on activity
# Track session validity server-side
active_sessions = {} # Use Redis in production
@app.route('/login', methods=['POST'])
def login():
# ... authenticate user ...
# Regenerate session ID on login
session.clear()
session.permanent = True
session['user_id'] = user.id
session['created_at'] = datetime.utcnow().isoformat()
session['session_id'] = str(uuid.uuid4())
# Track on server
active_sessions[session['session_id']] = {
'user_id': user.id,
'created_at': datetime.utcnow(),
'last_activity': datetime.utcnow()
}
return redirect('/dashboard')
@app.before_request
def check_session_validity():
if 'session_id' in session:
server_session = active_sessions.get(session['session_id'])
if not server_session:
# Session invalidated server-side
session.clear()
return redirect('/login')
# Check idle timeout (15 minutes)
idle_time = datetime.utcnow() - server_session['last_activity']
if idle_time > timedelta(minutes=15):
invalidate_session(session['session_id'])
session.clear()
return redirect('/login?reason=idle_timeout')
# Check absolute timeout (8 hours)
session_age = datetime.utcnow() - server_session['created_at']
if session_age > timedelta(hours=8):
invalidate_session(session['session_id'])
session.clear()
return redirect('/login?reason=session_expired')
# Update last activity
server_session['last_activity'] = datetime.utcnow()
@app.route('/logout')
def logout():
session_id = session.get('session_id')
if session_id:
invalidate_session(session_id)
session.clear()
response = redirect('/login')
# Clear session cookie
response.delete_cookie('session')
return response
def invalidate_session(session_id):
active_sessions.pop(session_id, None)
// SAFE: Proper session configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.invalidSessionUrl("/login?expired")
.maximumSessions(1) // Prevent concurrent sessions
.maxSessionsPreventsLogin(false)
.expiredUrl("/login?expired")
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.invalidateHttpSession(true) // Properly invalidate
.deleteCookies("JSESSIONID")
.clearAuthentication(true);
}
}
// SAFE: Session timeout configuration
@Configuration
public class SessionConfig {
@Bean
public ServletContextInitializer servletContextInitializer() {
return servletContext -> {
// 30 minute timeout
servletContext.setSessionTimeout(30);
};
}
}
// SAFE: Proper logout handler
@Controller
public class SecureAuthController {
@PostMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(false);
if (session != null) {
// Log logout event
auditService.logLogout(session.getAttribute("userId"));
// Invalidate session on server
session.invalidate();
}
// Clear cookies
Cookie sessionCookie = new Cookie("JSESSIONID", null);
sessionCookie.setMaxAge(0);
sessionCookie.setPath("/");
response.addCookie(sessionCookie);
return "redirect:/login?logout";
}
@PostMapping("/login")
public String login(HttpServletRequest request, @RequestBody LoginRequest loginRequest) {
// Invalidate existing session
HttpSession oldSession = request.getSession(false);
if (oldSession != null) {
oldSession.invalidate();
}
// Authenticate
User user = authService.authenticate(loginRequest);
// Create new session with new ID
HttpSession newSession = request.getSession(true);
newSession.setAttribute("userId", user.getId());
newSession.setAttribute("createdAt", System.currentTimeMillis());
newSession.setMaxInactiveInterval(1800); // 30 minutes
return "redirect:/dashboard";
}
}
// SAFE: JWT with proper expiration and refresh
const jwt = require('jsonwebtoken');
// Short-lived access token
function generateAccessToken(user) {
return jwt.sign(
{ userId: user.id, type: 'access' },
accessSecret,
{ expiresIn: '15m' } // 15 minutes
);
}
// Longer-lived refresh token (stored securely)
function generateRefreshToken(user) {
const token = jwt.sign(
{ userId: user.id, type: 'refresh', tokenId: uuid.v4() },
refreshSecret,
{ expiresIn: '7d' }
);
// Store in database for revocation capability
saveRefreshToken(user.id, token);
return token;
}
// Token refresh endpoint
app.post('/token/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const payload = jwt.verify(refreshToken, refreshSecret);
// Check if token is revoked
if (await isTokenRevoked(payload.tokenId)) {
return res.status(401).json({ error: 'Token revoked' });
}
// Generate new access token
const accessToken = generateAccessToken({ id: payload.userId });
res.json({ accessToken });
} catch (err) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
// SAFE: Express session with proper timeouts
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true, // Reset expiry on activity
cookie: {
secure: true,
httpOnly: true,
sameSite: 'strict',
maxAge: 30 * 60 * 1000 // 30 minutes
}
}));
// SAFE: Proper logout
app.post('/logout', (req, res) => {
const sessionId = req.sessionID;
req.session.destroy(err => {
if (err) {
console.error('Session destruction error:', err);
}
// Revoke any refresh tokens
revokeUserRefreshTokens(req.user?.id);
// Clear cookie
res.clearCookie('connect.sid', {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'strict'
});
res.json({ success: true });
});
});
// Middleware to check session validity
app.use((req, res, next) => {
if (req.session?.userId) {
const sessionAge = Date.now() - (req.session.createdAt || 0);
const maxAge = 8 * 60 * 60 * 1000; // 8 hours absolute
if (sessionAge > maxAge) {
return req.session.destroy(() => {
res.status(401).json({ error: 'Session expired' });
});
}
}
next();
});
Exploited in the Wild
GitHub Session Token Theft (Multiple)
Long-lived GitHub personal access tokens have been stolen from developer machines and used months later to access private repositories.
JWT Token Reuse Attacks
Multiple applications using JWTs without expiration or with very long expiration have suffered from token reuse attacks where stolen tokens remained valid indefinitely.
Corporate VPN Session Persistence
VPN solutions with insufficient session expiration have allowed attackers with stolen session cookies to maintain persistent access to corporate networks.
Tools to test/exploit
-
Burp Suite — test session timeout behavior.
-
OWASP ZAP — session management testing.
-
JWT.io — decode and analyze JWT expiration.
-
Browser DevTools — inspect session cookies.
CVE Examples
-
CVE-2021-22882 — UniFi Network insufficient session expiration.
-
CVE-2020-8945 — GPGME session token vulnerability.
-
CVE-2019-11358 — jQuery session handling issues.
References
-
MITRE. "CWE-613: Insufficient Session Expiration." https://cwe.mitre.org/data/definitions/613.html
-
OWASP. "Session Management Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html