VNX-JWT-002: JWT token signed without expiration
A JWT is issued with no expiry: jwt.sign / new SignJWT (JS/TS), jwt.encode (Python), Jwts.builder() / JWT.create() (Java), jwt.NewWithClaims (Go), JWT.encode (Ruby) and JWT::encode (PHP) where neither the call nor a payload, claims or options variable defined just above sets exp, expiresIn, setExpiration, withExpiresAt or ExpiresAt.
| Field | Value |
|---|---|
| Rule ID | VNX-JWT-002 |
| Severity | Medium |
| SARIF level | warning |
| Kind | sast |
| Languages | Python, JavaScript / TypeScript, Java, Go, Ruby, PHP |
| CWE | CWE-613 |
| CAPEC | CAPEC-60 |
| ATT&CK | T1550.001 |
| CWSS vector | CWSS:1.0/TI:M/AP:A/AL:A/IC:N/FC:LT/RP:N/RL:A/AV:I/AS:N/IN:A/SC:A/BI:M/DI:H/EX:M/EC:N/P:C |
| Tags | jwt session-management authentication |
| Source | internal/sast/rules/vnx-jwt-002.rego |
Overview
This rule detects token-issuing calls (jsonwebtoken jwt.sign() and jose new SignJWT(), PyJWT jwt.encode(), jjwt Jwts.builder() and Auth0 JWT.create(), golang-jwt jwt.NewWithClaims(), ruby-jwt JWT.encode(), firebase/php-jwt JWT::encode()) where neither the call itself nor a payload, claims or options variable it uses (assigned within 25 lines above) sets an expiry. Test files are not reported, and a payload built in another function or file is a documented gap. 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).
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")
// FLAGGED: builder chain with no setExpiration()
return Jwts.builder()
.setSubject(user)
.signWith(SignatureAlgorithm.HS512, key)
.compact();
// FLAGGED: MapClaims with no "exp"
t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": userID})
Not flagged: { expiresIn: '15m' }, an exp key in the payload literal, payload["exp"] = ... before the call, an options constant carrying expiresIn, RegisteredClaims{ExpiresAt: ...}, and test files.
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.
References
- CWE-613: Insufficient Session Expiration
- RFC 7519 – JSON Web Token – Section 4.1.4 exp Claim
- OWASP Session Management Cheat Sheet
- Auth0 – Refresh Tokens: When to Use Them and How They Interact with JWTs
- MITRE ATT&CK T1550.001 – Use Alternate Authentication Material: Application Access Token
Suppressing this rule
Put a nosec comment on the line, or on line 1 to cover the whole file. Use the comment marker of the file’s language (#, //, -- or ;):
# nosec vnx-jwt-002
// nosec vnx-jwt-002
Or record an ignore rule: vulnetix ignore add --rule vnx-jwt-002 --reason "...". See Suppressing findings.