VNX-JWT-002 – JWT Token Signed Without Expiration
Overview
This rule detects jwt.sign() (Node.js/jsonwebtoken) and jwt.encode() (Python/PyJWT) calls where no expiresIn option or exp claim is present. A JWT without expiration is valid forever — from the moment it is issued until the signing secret is rotated or the token is explicitly blocklisted (which requires server-side state, defeating much of the purpose of JWTs). This maps to CWE-613 (Insufficient Session Expiration).
Severity: Medium | CWE: CWE-613 – Insufficient Session Expiration
Why This Matters
JWTs are commonly used as stateless session tokens — the server does not store them, so there is no built-in revocation mechanism. This is by design, but it places extra importance on short lifetimes. If a token without expiration is leaked from a user’s device, a log file, a network capture, or a third-party service, the attacker has a permanent credential.
A sliding session attack exploits this directly: an attacker who steals one token can replay it months or years later, long after the legitimate user has changed their password. Because JWTs are stateless, a password change does not invalidate previously issued tokens unless the application explicitly tracks and rejects them. Short-lived access tokens (15 minutes to 1 hour) combined with a refresh token pattern are the standard mitigation: the refresh token is stored securely and exchanged for new access tokens, and can be revoked server-side if needed.
What Gets Flagged
// FLAGGED: jwt.sign() without expiresIn
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET);
# FLAGGED: jwt.encode() without exp claim
token = jwt.encode({"sub": user_id, "role": "admin"}, SECRET_KEY, algorithm="HS256")
Remediation
Add
expiresInto alljwt.sign()calls. Use short durations for access tokens:// SAFE: 15-minute access token const accessToken = jwt.sign( { userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '15m' } );Add an
expclaim to Pythonjwt.encode()calls:# SAFE: token expires in 15 minutes from datetime import datetime, timedelta, timezone import jwt payload = { "sub": str(user_id), "role": user.role, "iat": datetime.now(timezone.utc), "exp": datetime.now(timezone.utc) + timedelta(minutes=15) } token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")Implement a refresh token pattern for sessions that need to last longer than the access token lifetime. Issue a short-lived access token (15 minutes) and a longer-lived refresh token (7–30 days) stored in an httpOnly cookie. The refresh token can be revoked server-side in a blocklist or by rotating the stored token hash.
Choose token lifetimes based on sensitivity. High-value operations (payment, admin actions) warrant shorter-lived tokens. Adjust based on your threat model.