JWT Token Expiration Best Practices
JSON Web Tokens (JWTs) carry their own expiration time in the exp claim. This self-contained design is a core strength—any party can verify token validity without querying a central store. But it also creates challenges: once issued, a token cannot be revoked before its expiration, and poorly chosen expiration windows expose your system to abuse. Getting token expiration right requires balancing security, user experience, and operational complexity.
Setting the exp Claim
The exp (expiration) claim is a NumericDate value—the number of seconds since the Unix epoch (1970-01-01 00:00:00Z). JWT libraries handle the encoding automatically:
import jwt from 'jsonwebtoken'
const token = jwt.sign(
{ sub: 'user123', role: 'editor' },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // 15 minutes
)
The expiresIn option accepts human-readable strings ('15m', '1h', '7d') or numeric values in seconds. The library computes the exp claim by adding this duration to the current time.
Recommended expiration windows by token type:
| Token Type | Recommended Lifetime | Rationale |
|---|---|---|
| Access token | 5–15 minutes | Short window limits damage if stolen |
| Refresh token | 7–30 days | Balances security with user convenience |
| Email verification | 1–24 hours | Time-limited action confirmation |
| API key (service-to-service) | 90–365 days | Long-lived; rotate on schedule |
The Short-Lived Access Token Pattern
Industry best practice splits authentication into two tokens:
- Access token: Short-lived (5–15 minutes), carries authorization claims, sent with every API request.
- Refresh token: Long-lived (7–30 days), carries only an identifier, used exclusively to obtain new access tokens.
// Issue both tokens at login
const accessToken = jwt.sign(
{ sub: userId, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
)
const refreshToken = jwt.sign(
{ sub: userId, jti: crypto.randomUUID() },
REFRESH_SECRET,
{ expiresIn: '7d' }
)
When the access token expires, the client sends the refresh token to a dedicated endpoint to obtain a new access token without requiring the user to re-authenticate. This pattern minimizes the window during which a stolen access token is valid while maintaining a seamless user experience.
Refresh Token Rotation
Refresh token rotation issues a new refresh token with every use, invalidating the previous one. If an attacker steals a refresh token and uses it, the legitimate client's next refresh attempt fails because its token was already rotated. This detection mechanism alerts you to potential compromise.
async function refreshTokenHandler(oldRefreshToken: string) {
// Verify the old token
const payload = jwt.verify(oldRefreshToken, REFRESH_SECRET)
// Check if it's in the revoked list (reuse detection)
if (await isTokenRevoked(payload.jti)) {
// Potential theft: revoke entire family
await revokeTokenFamily(payload.sub)
throw new Error('Token reuse detected')
}
// Revoke the old refresh token
await revokeToken(payload.jti)
// Issue new token pair
const newAccessToken = jwt.sign(
{ sub: payload.sub, role: payload.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
)
const newRefreshToken = jwt.sign(
{ sub: payload.sub, jti: crypto.randomUUID() },
REFRESH_SECRET,
{ expiresIn: '7d' }
)
return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}
Common Pitfalls
Setting expiration too far out. An access token that lasts 24 hours means a stolen token grants access for a full day. Keep access tokens under 15 minutes.
Ignoring clock skew. JWT validation compares exp against the server's clock. If client and server clocks differ by more than the token lifetime, valid tokens may be rejected or expired tokens accepted. Most JWT libraries accept a clockTolerance option (typically 30 seconds) to handle minor skew.
Not storing refresh tokens securely. Refresh tokens must be stored server-side (in a database or Redis) so they can be revoked. Stateless refresh tokens cannot be invalidated, defeating the purpose of rotation.
Using the same secret for access and refresh tokens. Always use separate signing keys. If an access token secret is compromised, refresh tokens should remain secure.
Missing the iat (issued at) claim. Including iat alongside exp enables you to calculate the token's age and enforce maximum token age policies independent of expiration.
Token Revocation Strategies
Since JWTs cannot be revoked by design, you need supplementary mechanisms:
- Token blocklist: Store revoked token IDs (
jticlaims) in Redis with TTLs matching their remaining lifetime. Check the blocklist on every request. - Versioned claims: Include a
tokenVersionclaim that maps to a user record. Incrementing the version invalidates all outstanding tokens for that user. - Short lifetimes + rotation: The simplest strategy—keep access tokens so short that revocation is rarely needed, and rotate refresh tokens for detection.
For decoding and inspecting JWT expiration times, the JWT Decoder tool displays all claims including exp, iat, and nbf in a readable format.