JWT Best Practices: Signing, Expiry, and Key Rotation
JSON Web Tokens (JWTs) are the most common way to carry authentication state in modern APIs. They're compact, self-contained, and easy to verify — but they're also easy to misuse. This guide covers the practices that keep JWTs secure in production.
Anatomy of a JWT
A JWT has three Base64url-encoded parts separated by dots:
header.payload.signature
- Header: Algorithm and token type (
{"alg": "HS256", "typ": "JWT"}) - Payload: Claims — your data plus standard fields like
exp,iat,sub - Signature: Proof that the token hasn't been tampered with
Choosing a Signing Algorithm
The alg field in the header determines how the token is signed.
HMAC (Symmetric)
- HS256, HS384, HS512 — the same secret key signs and verifies.
- Simple to set up. Good for single-service architectures where only your backend creates and validates tokens.
- Risk: Every service that needs to verify the token must have the secret key. If any one is compromised, an attacker can forge tokens.
RSA / ECDSA (Asymmetric)
- RS256, RS384, RS512 (RSA) or ES256, ES384, ES512 (Elliptic Curve)
- A private key signs; a public key verifies.
- Ideal for microservices: only the auth service holds the private key, while downstream services verify with the public key.
- ES256 is preferred over RS256 — smaller keys, faster verification.
Generate a JWT with the Auth Toolkit API:
curl -X POST https://auth.toolkitapi.io/v1/auth/jwt-generate \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"payload": {"sub": "user_42", "role": "admin"},
"secret": "your-256-bit-secret",
"algorithm": "HS256",
"expires_in": 900
}'
Expiration Best Practices
Always set exp. A token without an expiration is valid forever. If it leaks, there's no way to revoke it (JWTs are stateless).
| Use case | Recommended expires_in |
|---|---|
| Access tokens | 5–15 minutes |
| Refresh tokens | 7–30 days (store securely) |
| Email verification links | 24 hours |
| Password reset tokens | 15–60 minutes |
Short-lived access tokens limit the blast radius of a stolen token. Pair them with longer-lived refresh tokens to avoid forcing users to re-authenticate constantly.
Claims to Include (and Avoid)
Include:
sub— subject (user ID)iat— issued-at timestampexp— expirationiss— issuer (your domain)aud— audience (which service the token is for)
Avoid putting in the payload:
- Passwords or secrets
- Full user profiles (name, email, address)
- Authorization details that change frequently
The payload is encoded, not encrypted. Anyone with the token can decode it and read the claims. Use the Decode JWT endpoint to inspect any token without verifying:
curl -X POST https://auth.toolkitapi.io/v1/auth/jwt-decode \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"token": "eyJhbGciOiJIUzI1NiIs..."}'
Key Rotation
Secrets and keys should be rotated regularly. When you rotate:
- Generate a new key pair (or secret).
- Sign new tokens with the new key. Add a
kid(Key ID) to the header. - Keep the old key in your verification key set for the max token lifetime.
- After all old tokens expire, remove the old key.
The Auth Toolkit's Generate Key Pair endpoint can create RSA or EC keys on demand:
curl -X POST https://auth.toolkitapi.io/v1/auth/generate-keypair \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"algorithm": "EC", "curve": "P-256"}'
Common Pitfalls
Using alg: none
Some libraries accept "alg": "none", which means no signature at all. Always validate the alg header and reject none.
Confusing encoding with encryption
Base64 is encoding, not encryption. The payload is readable by anyone. If you need to hide claims, encrypt the payload separately or use JWE (JSON Web Encryption).
Not validating expiration on the server
Always verify exp server-side. Don't rely on the client to discard expired tokens.
Storing JWTs in localStorage
Tokens in localStorage are accessible to any JavaScript on the page (XSS target). Prefer httpOnly cookies with Secure and SameSite flags.
Verifying Tokens
Always verify the signature before trusting any claims:
curl -X POST https://auth.toolkitapi.io/v1/auth/jwt-verify \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"token": "eyJhbGciOiJIUzI1NiIs...",
"secret": "your-256-bit-secret",
"verify_exp": true
}'
The response tells you exactly what's wrong — expired, invalid signature, or valid:
{
"valid": true,
"payload": {"sub": "user_42", "role": "admin", "exp": 1775268647},
"header": {"alg": "HS256", "typ": "JWT"},
"expired": false,
"error": null
}