JWTs are stateless — they cannot be individually revoked before their expiry timestamp. A token with a 365-day lifetime handed to a compromised client, leaked via a log line, or obtained through an XSS payload is valid for a year with no mechanism to invalidate it. CWE-613 (Insufficient Session Expiration) and CWE-347 (Improper Verification of Cryptographic Signature) are both relevant when tokens are issued without meaningful expiry. NIST 800-63B §7.1 requires that session credentials be time-bounded. OWASP A02 covers cryptographic failure modes that include issuing tokens with no or excessive expiry.
High because a JWT with no expiry or a multi-year expiry cannot be invalidated after account suspension, role change, or credential compromise — the token remains valid regardless of server-side state.
Set short access token expiry and pair it with rotating refresh tokens rather than issuing long-lived access tokens:
import { SignJWT } from 'jose'
// Access token: 15 minutes
const accessToken = await new SignJWT({ sub: userId, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(secret)
// Refresh token: 7 days, rotate on each use
const refreshToken = await new SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret)
For API tokens issued to users (not internal session tokens), store a hash server-side and allow revocation via a token management interface. Never issue tokens with expiresIn: '100y' or no expiry.
ID: saas-authentication.social-auth.jwt-reasonable-expiry
Severity: high
What to look for: If the application issues JWTs (JSON Web Tokens) for authentication or API access, examine the token signing configuration. Look for expiresIn or exp settings in jwt.sign calls, NextAuth JWT configuration, or API token generation code. Check both access tokens and ID tokens. Tokens that never expire (expiresIn: '100y' or no expiry set) are a critical concern. Count all instances found and enumerate each.
Pass criteria: JWTs have an expiry set. Access tokens expire within 15 minutes to 1 hour. Longer-lived tokens (session JWTs, refresh tokens) are acceptable up to 7 days with refresh rotation. Tokens with no expiry are not issued. At least 1 implementation must be confirmed.
Fail criteria: JWTs are issued without an expiry (exp claim is missing or set to a value years in the future). Access tokens have a lifetime longer than 24 hours without a corresponding refresh mechanism.
Skip (N/A) when: Application does not use JWTs — session-based auth only with no JWT issuance. Signal: no jsonwebtoken, jose, or JWT-related library, and no custom token signing code.
Detail on fail: "JWT access tokens issued with expiresIn: '365d' — tokens are valid for a full year and cannot be revoked once issued" or "jsonwebtoken.sign() called without expiresIn — tokens have no expiry".
Remediation: JWTs are stateless and cannot be individually revoked — the only way to enforce logout or account suspension is through expiry. Short expiry limits the damage window:
import { SignJWT } from 'jose'
const accessToken = await new SignJWT({ sub: userId, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m') // Access token: 15 minutes
.sign(secret)
const refreshToken = await new SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d') // Refresh token: 7 days, rotate on use
.sign(secret)
Use short access token lifetimes paired with refresh token rotation rather than long-lived access tokens.