Disclosure: This article contains affiliate links. We may earn a commission at no extra cost to you.
Quick Answer: Session expiration using Unix timestamps requires storing two server-side times:createdAtfor absolute expiration andlastActivityAtfor idle timeout. On each request, compare both against current time; invalidate if either threshold is exceeded. Combine with server-side session destruction and HTTP-only cookie clearing for complete revocation. Idle timeouts typically range 15–30 minutes, absolute timeouts 4–8 hours.
Building a secure auth system means you need a domain your users can trust. We use and recommend Namecheap — reliable DNS, free WhoisGuard, and straightforward pricing.
Your production sessions are bleeding money. A compromised session ID that never expires, or one that your frontend thinks expired but the server forgot to validate, turns into unauthorized API calls, leaked data, and compliance violations. The difference between secure session management and catastrophic failure often hinges on a single timestamp comparison that's missing or incorrectly implemented on the server side.
The Complete Session Lifecycle: Issue, Validate, Refresh, and Revoke
A robust session system requires four distinct phases, each backed by Unix timestamps:
- Issue: Create session with
createdAtandlastActivityAtset toDate.now() - Validate: Check both idle and absolute expiration on every authenticated request
- Refresh: Update
lastActivityAtif session is still valid (sliding expiration) - Revoke: Destroy server-side session and clear client cookie atomically
This architecture satisfies OWASP recommendations and NIST guidelines for well-defined session lifetimes tied to reauthentication periods.
Implementing Server-Side Session Validation with Unix Timestamps
Server-side validation is non-negotiable. The client cookie is merely a carrier; the server must be the absolute authority on session validity. Store sessions in a persistent or distributed session store (Redis, database, or in-memory for small deployments).
// Session validation middleware for Node.js/Express
const IDLE_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
const ABSOLUTE_TIMEOUT_MS = 8 * 60 * 60 * 1000; // 8 hours
// In-memory session store (production: use Redis or database)
const sessions = new Map();
function validateSessionExpiry(sessionId, now = Date.now()) {
const session = sessions.get(sessionId);
// Session does not exist
if (!session) {
return { valid: false, reason: 'not_found' };
}
// Check idle timeout: if no activity in IDLE_TIMEOUT_MS, expire
const idleExpired = now - session.lastActivityAt > IDLE_TIMEOUT_MS;
if (idleExpired) {
sessions.delete(sessionId);
return { valid: false, reason: 'idle_timeout' };
}
// Check absolute timeout: session must not exceed ABSOLUTE_TIMEOUT_MS from creation
const absoluteExpired = now - session.createdAt > ABSOLUTE_TIMEOUT_MS;
if (absoluteExpired) {
sessions.delete(sessionId);
return { valid: false, reason: 'absolute_timeout' };
}
// Session is valid
return { valid: true, session };
}
// Express middleware to enforce session expiry
app.use((req, res, next) => {
const sessionId = req.cookies.sid;
if (!sessionId) {
return next();
}
const validation = validateSessionExpiry(sessionId);
if (!validation.valid) {
// Clear invalid session cookie
res.clearCookie('sid', {
httpOnly: true,
sameSite: 'lax',
secure: true,
});
// Respond with 401 for expired sessions
return res.status(401).json({
error: 'session_expired',
reason: validation.reason,
timestamp: Date.now(),
});
}
// Session is valid; attach to request
req.sessionId = sessionId;
req.session = validation.session;
// Update lastActivityAt for sliding timeout
req.session.lastActivityAt = Date.now();
next();
});
// → Middleware logs: Session validated at Unix ms 1733529600000
This middleware enforces both idle and absolute timeouts on every request. The lastActivityAt update implements sliding expiration—the session stays alive as long as the user remains active.
Redis-Backed Session Storage for Distributed Systems
In production, store sessions in Redis with TTL semantics. Redis automatically evicts expired keys, and distributed systems benefit from centralized session state.
// Session management with Redis
const redis = require('redis');
const client = redis.createClient();
const IDLE_TIMEOUT_SECONDS = 15 * 60; // 15 minutes
const ABSOLUTE_TIMEOUT_SECONDS = 8 * 60 * 60; // 8 hours
async function createSession(userId) {
const sessionId = require('crypto').randomBytes(32).toString('hex');
const now = Math.floor(Date.now() / 1000); // Unix seconds
const sessionData = {
userId,
createdAt: now,
lastActivityAt: now,
};
// Store session with Redis TTL = absolute timeout
await client.setEx(
`session:${sessionId}`,
ABSOLUTE_TIMEOUT_SECONDS,
JSON.stringify(sessionData)
);
return sessionId;
}
async function validateAndRefreshSession(sessionId) {
const sessionJson = await client.get(`session:${sessionId}`);
if (!sessionJson) {
return { valid: false, reason: 'not_found' };
}
const session = JSON.parse(sessionJson);
const now = Math.floor(Date.now() / 1000);
// Check idle timeout
if (now - session.lastActivityAt > IDLE_TIMEOUT_SECONDS) {
await client.del(`session:${sessionId}`);
return { valid: false, reason: 'idle_timeout' };
}
// Check absolute timeout
if (now - session.createdAt > ABSOLUTE_TIMEOUT_SECONDS) {
await client.del(`session:${sessionId}`);
return { valid: false, reason: 'absolute_timeout' };
}
// Update lastActivityAt for sliding expiration
session.lastActivityAt = now;
// Re-set with remaining TTL (absolute timeout - age)
const ttl = ABSOLUTE_TIMEOUT_SECONDS - (now - session.createdAt);
await client.setEx(
`session:${sessionId}`,
ttl,
JSON.stringify(session)
);
return { valid: true, session };
}
async function revokeSession(sessionId) {
// Delete from Redis immediately
await client.del(`session:${sessionId}`);
// Caller must clear client cookie as well
}
// → createSession returns: a1b2c3d4... (64-char hex ID)
// → validateAndRefreshSession returns: { valid: true, session: {...} }
Redis handles TTL automatically, but you must still check idle timeout explicitly because Redis TTL doesn't know about your idle threshold—only absolute timeout. This pattern is production-grade and scales across multiple servers.
JWT Token Expiration with Unix Timestamps and Claim Validation
JWTs embed expiration in the exp claim (Unix seconds). Unlike server-side sessions, tokens are stateless, but you must validate all relevant claims, not just signature and expiry. Use the JWT decoder tool to inspect and debug token claims in real time.
// JWT validation with full claim checking
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const TOKEN_EXPIRY_SECONDS = 15 * 60; // 15 minutes for access token
function issueAccessToken(userId) {
const now = Math.floor(Date.now() / 1000); // Unix seconds
const token = jwt.sign(
{
sub: userId, // Subject claim (user ID)
iat: now, // Issued at
exp: now + TOKEN_EXPIRY_SECONDS, // Expiration time
iss: 'myapp.com', // Issuer
aud: 'api.myapp.com', // Audience
type: 'access', // Token type
},
SECRET,
{ algorithm: 'HS256' }
);
return token;
// → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
}
function validateToken(token) {
try {
// jwt.verify checks signature, exp, nbf automatically
const payload = jwt.verify(token, SECRET, {
algorithms: ['HS256'],
issuer: 'myapp.com',
audience: 'api.myapp.com',
});
// Additional claim validation
if (payload.type !== 'access') {
return { valid: false, reason: 'invalid_token_type' };
}
// Check if token issued in the future (clock skew tolerance: 10 seconds)
const now = Math.floor(Date.now() / 1000);
if (payload.iat > now + 10) {
return { valid: false, reason: 'token_not_yet_valid' };
}
return { valid: true, payload };
} catch (err) {
if (err.name === 'TokenExpiredError') {
return { valid: false, reason: 'token_expired', expiredAt: err.expiredAt };
}
if (err.name === 'JsonWebTokenError') {
return { valid: false, reason: 'invalid_signature' };
}
return { valid: false, reason: 'unknown_error' };
}
}
// Usage
const token = issueAccessToken('user123');
const validation = validateToken(token);
if (validation.valid) {
console.log('Token valid. User:', validation.payload.sub);
} else {
console.log('Token invalid:', validation.reason);
}
// → Token valid. User: user123
Notice that jwt.verify() automatically checks exp and rejects expired tokens. The `options` parameter validates iss and aud automatically. Always validate all claims relevant to your application—signature alone is insufficient.
Refresh Token Flow with Secure Rotation
Refresh tokens have longer lifetimes and are used to issue new access tokens. Implement rotation and revocation to limit attack surface if a token is compromised.
// Refresh token management with rotation
const crypto = require('crypto');
const REFRESH_TOKEN_EXPIRY_SECONDS = 7 * 24 * 60 * 60; // 7 days
const REFRESH_TOKEN_STORAGE = new Map(); // Production: database
function issueRefreshToken(userId) {
const refreshTokenId = crypto.randomBytes(32).toString('hex');
const now = Math.floor(Date.now() / 1000);
const tokenData = {
userId,
issuedAt: now,
expiresAt: now + REFRESH_TOKEN_EXPIRY_SECONDS,
rotationCount: 0,
revoked: false,
};
// Store refresh token metadata server-side
REFRESH_TOKEN_STORAGE.set(refreshTokenId, tokenData);
return refreshTokenId;
// → e8f1c9d2a7b4e6f3... (64-char random token)
}
function rotateRefreshToken(oldRefreshTokenId) {
const tokenData = REFRESH_TOKEN_STORAGE.get(oldRefreshTokenId);
if (!tokenData) {
return { success: false, reason: 'token_not_found' };
}
const now = Math.floor(Date.now() / 1000);
// Check if old token is expired
if (now > tokenData.expiresAt) {
REFRESH_TOKEN_STORAGE.delete(oldRefreshTokenId);
return { success: false, reason: 'token_expired' };
}
// Check if token has been revoked
if (tokenData.revoked) {
return { success: false, reason: 'token_revoked' };
}
// Issue new refresh token
const newRefreshTokenId = crypto.randomBytes(32).toString('hex');
tokenData.rotationCount += 1;
tokenData.issuedAt = now;
tokenData.expiresAt = now + REFRESH_TOKEN_EXPIRY_SECONDS;
// Update storage with new token, keep old for grace period
REFRESH_TOKEN_STORAGE.set(newRefreshTokenId, { ...tokenData });
// Optionally revoke old token after grace period (60 seconds)
setTimeout(() => {
REFRESH_TOKEN_STORAGE.delete(oldRefreshTokenId);
}, 60 * 1000);
return {
success: true,
newRefreshTokenId,
accessToken: issueAccessToken(tokenData.userId),
};
}
function revokeRefreshToken(refreshTokenId) {
const tokenData = REFRESH_TOKEN_STORAGE.get(refreshTokenId);
if (!tokenData) {
return { success: false, reason: 'token_not_found' };
}
// Mark as revoked, do not delete immediately
tokenData.revoked = true;
tokenData.revokedAt = Math.floor(Date.now() / 1000);
return { success: true };
}
// Usage example
const refreshToken = issueRefreshToken('user456');
const rotation = rotateRefreshToken(refreshToken);
if (rotation.success) {
console.log('New access token:', rotation.accessToken.substring(0, 20) + '...');
console.log('New refresh token:', rotation.newRefreshTokenId.substring(0, 20) + '...');
}
// → New access token: eyJhbGciOiJIUzI1NiIsI...
// → New refresh token: a4d8f2c1e9b6... (continuation)
Refresh token rotation forces attackers to continuously compromise new tokens. Revocation prevents further use of stolen tokens. Always store refresh tokens server-side so you can revoke them instantly, unlike JWTs which are stateless.
Python Implementation: Session Validation with Flask and PyJWT
Python developers often use Flask and PyJWT. Here's a complete implementation pattern:
# Flask session management with PyJWT
import jwt
import json
import redis
import time
from functools import wraps
from flask import Flask, request, jsonify
from datetime import datetime, timedelta
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
# Redis connection for session storage
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
IDLE_TIMEOUT_SECONDS = 15 * 60 # 15 minutes
ABSOLUTE_TIMEOUT_SECONDS = 8 * 60 * 60 # 8 hours
def create_session(user_id):
"""Issue a new session with Unix timestamps"""
now = int(time.time())
session_data = {
'user_id': user_id,
'created_at': now,
'last_activity_at': now,
}
session_id = jwt.encode(
{
'sub': user_id,
'iat': now,
'exp': now + ABSOLUTE_TIMEOUT_SECONDS,
},
app.config['SECRET_KEY'],
algorithm='HS256'
)
# Store in Redis with TTL
redis_client.setex(
f'session:{session_id}',
ABSOLUTE_TIMEOUT_SECONDS,
json.dumps(session_data)
)
return session_id
# → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
def validate_session(session_id):
"""Validate and refresh session on each request"""
# Retrieve from Redis
session_json = redis_client.get(f'session:{session_id}')
if not session_json:
return {'valid': False, 'reason': 'not_found'}
session_data = json.loads(session_json)
now = int(time.time())
# Check idle timeout
if now - session_data['last_activity_at'] > IDLE_TIMEOUT_SECONDS:
redis_client.delete(f'session:{session_id}')
return {'valid': False, 'reason': 'idle_timeout'}
# Check absolute timeout
if now - session_data['created_at'] > ABSOLUTE_TIMEOUT_SECONDS:
redis_client.delete(f'session:{session_id}')
return {'valid': False, 'reason': 'absolute_timeout'}
# Update last activity for sliding expiration
session_data['last_activity_at'] = now
ttl = ABSOLUTE_TIMEOUT_SECONDS - (now - session_data['created_at'])
redis_client.setex(
f'session:{session_id}',
ttl,
json.dumps(session_data)
)
return {'valid': True, 'session': session_data}
def require_session(f):
"""Decorator to enforce valid session on protected routes"""
@wraps(f)
def decorated_function(*args, **kwargs):
session_id = request.cookies.get('sid')
if not session_id:
return jsonify({'error': 'session_missing'}), 401
validation = validate_session(session_id)
if not validation['valid']:
response = jsonify({
'error': 'session_invalid',
'reason': validation['reason'],
'timestamp': int(time.time())
})
response.delete_cookie('sid')
return response, 401
request.session = validation['session']
return f(*args, **kwargs)
return decorated_function
# Protected endpoint example
@app.route('/api/profile', methods=['GET'])
@require_session
def get_profile():
"""Requires valid session; returns user profile"""
user_id = request.session['user_id']
return jsonify({'user_id': user_id, 'name': 'John Doe'})
# → GET /api/profile returns: {'user_id': 'user123', 'name': 'John Doe'}
This Flask pattern mirrors the Node.js implementation: Redis stores sessions, Unix timestamps control expiry, and the decorator enforces validation on every request.
Common Mistakes and How to Fix Them
Mistake: Relying on Client-Side Cookie Expiry Alone
// ✗ WRONG: Client-side expiry is cosmetic
res.cookie('sid', sessionId, {
maxAge: 15 * 60 * 1000, // Only tells browser when to delete
httpOnly: false, // Browser can read this cookie
});
// Attacker can modify maxAge or localStorage to keep session "alive"
// ✓ RIGHT: Server validates on every request
res.cookie('sid', sessionId, {
maxAge: 15 * 60 * 1000,
httpOnly: true, // JavaScript cannot access
sameSite: 'lax', // CSRF protection
secure: true, // HTTPS only
});
// Server-side validation is mandatory
const validation = validateSessionExpiry(sessionId);
if (!validation.valid) {
res.clearCookie('sid', { httpOnly: true, secure: true });
return res.status(401).json({ error: 'expired' });
}
The client cookie's maxAge or Expires` attribute is a hint to the browser, not enforced by the server. An attacker with access to the client machine can modify localStorage, manipulate DevTools, or send requests with an old cookie that the server hasn't yet invalidated. Server-side validation is the only authority.
Mistake: Not Checking Absolute Timeout Alongside Idle Timeout
// ✗ WRONG: Only idle timeout allows session to live forever
if (now - session.lastActivityAt > IDLE_TIMEOUT_MS) {
// Expired
}
// Attacker can make a request every 14 minutes to keep session alive indefinitely
// ✓ RIGHT: Both idle and absolute checks
const idleExpired = now - session.lastActivityAt > IDLE_TIMEOUT_MS;
const absoluteExpired = now - session.createdAt > ABSOLUTE_TIMEOUT_MS;
if (idleExpired || absoluteExpired) {
sessions.delete(sessionId);
res.status(401).json({ error: 'expired' });
}
OWASP explicitly recommends combining idle and absolute timeouts. Without absolute timeout, an attacker can keep a stolen session active indefinitely by triggering requests at intervals shorter than the idle threshold.
Mistake: Decoding JWT Without Validating Claims
# ✗ WRONG: Authlib decode() does not validate claims by default
from authlib.jose import JsonWebToken
jwt_obj = JsonWebToken(['HS256'])
claims = jwt_obj.decode(token, key)
# No validation of exp, iat, iss, or aud
user_id = claims['sub'] # Could be from expired or wrong-issuer token
# ✓ RIGHT: Explicitly validate all claims
claims = jwt_obj.decode(token, key)
claims.validate() # Validates exp, nbf, and other standard claims
# Also check custom claims
if claims.get('iss') != 'myapp.com':
raise ValueError('Wrong issuer')
if claims.get('type') != 'access':
raise ValueError('Wrong token type')
user_id = claims['sub'] # Now safe
Authlib's decode() parses the JWT without validating payload claims. The validate() call checks standard claims like exp and nbf. Custom claims (issuer, audience, token type) must be verified manually.
Mistake: Not Clearing Cookies on Logout
// ✗ WRONG: Delete session server-side but cookie remains
function logout(req, res) {
sessions.delete(req.sessionId);
res.json({ message: 'logged out' });
// Browser still has 'sid' cookie; next request still sends it
}
// ✓ RIGHT: Destroy server-side AND clear cookie
function logout(req, res) {
// Destroy server session immediately
sessions.delete(req.sessionId);
// Clear cookie with matching attributes
res.clearCookie('sid', {
httpOnly: true,
sameSite: 'lax',
secure: true,
path: '/',
});
res.json({ message: 'logged out', timestamp: Date.now() });
}
If you don't clear the cookie, the browser continues sending it on subsequent requests. The server will reject it (session not found), but the extra round-trip wastes resources. More importantly, if there's a race condition or server bug, the session might still be valid.
Comparison: Session Management Strategies
| Strategy | Server-Side Storage | Idle Timeout | Revocation | Scalability |
|---|---|---|---|---|
| Server Sessions + Redis | Redis (distributed) | Sliding, checked on every request | Immediate (delete key) | High (stateless servers) |
| JWT (Stateless) | None (embedded in token) | Fixed exp claim, no refresh |
Slow (requires blacklist for early revocation) | Very high (no server state) |
| JWT + Refresh Token | Refresh tokens only | Access token is short-lived, refresh rotates | Immediate revocation of refresh token | High (minimal state) |
| In-Memory Sessions | Single server memory | Sliding, checked on every request | Immediate (delete) | Low (single server only) |
Redis-backed sessions offer the best balance: instant revocation, sliding expiration, and horizontal scalability. JWTs alone are stateless but lack revocation granularity. The JWT + refresh token hybrid combines benefits of both.
Frequently Asked Questions
How do I expire a session using Unix timestamps?
Store two Unix timestamps with each session: createdAt (session birth) and lastActivityAt (last user request). On every authenticated request, compare both against current time: if now - lastActivityAt > idle_timeout or now - createdAt > absolute_timeout, the session is expired. Delete it from the server store and return 401. Update lastActivityAt to the current time for sliding expiration.
What is the difference between absolute and sliding session expiration?
Absolute expiration: Session ends at a fixed time from creation (e.g., 8 hours), regardless of activity. Sliding expiration: Session timeout is reset on each user action, so it expires only after a period of inactivity (e.g., 15 minutes with no requests). Combine both: use absolute to cap maximum session lifetime and sliding idle timeout to handle inactive users. This prevents indefinite session hijacking while keeping active users logged in.
How to validate JWT expiration with Unix timestamps?
Check the exp claim (Unix seconds) against current time: if now > exp, reject the token. Libraries like PyJWT and jsonwebtoken validate expiration automatically when you call jwt.decode() or jwt.verify(), raising an ExpiredSignatureError or TokenExpiredError. Also validate iat (issued-at) to reject tokens issued in the future, and check iss, aud, and custom claims. Use the JWT decoder tool to inspect token claims and expiry visually.
How to implement session refresh tokens securely?
Issue short-lived access tokens (15 minutes) and longer-lived refresh tokens (7 days). Store refresh token metadata server-side so you can revoke them instantly. When access token expires, send the refresh token to a dedicated endpoint; validate it server-side, check that it hasn't been revoked, then issue a new access token and optionally rotate the refresh token (replace old token with new one after a grace period). This limits exposure if an access token is compromised and allows immediate revocation of refresh tokens if a user logs out or is banned.
Key Takeaways
- Always validate session expiry on the server side on every request; client-side cookie expiry is cosmetic and can be bypassed.
- Combine idle timeout (checked against
lastActivityAt) and absolute timeout (checked againstcreatedAt) to prevent indefinite session hijacking. - For server sessions, store them in Redis or a database with Unix timestamps; delete them immediately on logout and clear the HTTP-only cookie atomically.
- For JWTs, validate all claims (
exp,iat,iss,aud, custom claims), not just the signature; use short-lived access tokens with rotating refresh tokens for better revocation control. - Implement sliding expiration by updating
lastActivityAton each valid request, extending the idle timeout window without changing the absolute expiration. - Use the timestamp API and timestamp debugger to verify Unix timestamp calculations in real time.