JWT Best Practices: Security, Storage, and Rotation
Last updated: March 2026
Introduction
JSON Web Tokens (JWTs) are the de facto standard for API authentication, and for good reason: they are stateless, self-contained, and work across any platform. But that flexibility comes with responsibility. A misconfigured JWT strategy can expose your application to token theft, replay attacks, privilege escalation, and data leakage.
This guide covers the security practices every developer should follow when working with JWTs. We address where to store tokens, how to manage their size, key rotation strategies, claim validation, refresh token rotation, audience restriction, and when to use encrypted tokens (JWE). Each practice includes specific Keycloak configuration where applicable.
Use the JWT Token Analyzer to inspect your tokens and verify that these practices are correctly applied.
Token Storage: Where to Keep Your JWTs
This is the most debated JWT topic. The answer depends on your application architecture.
Option 1: httpOnly Cookies (Recommended for Web Apps)
Store the access token in an httpOnly, Secure, SameSite cookie. This prevents JavaScript from accessing the token, eliminating the most common XSS-based theft vector.
Set-Cookie: access_token=eyJhbG...;
HttpOnly;
Secure;
SameSite=Lax;
Path=/;
Max-Age=300
Pros:
- Immune to XSS-based token theft
- Automatically sent with requests (no JS code needed)
- Browser manages expiration
Cons:
- Vulnerable to CSRF (mitigate with
SameSiteand anti-CSRF tokens) - Cookie size limits (~4KB, which can be tight for JWTs with many claims)
- Requires same-site or subdomain architecture for APIs
Keycloak + BFF Pattern:
For single-page applications, the Backend-for-Frontend (BFF) pattern is the recommended approach. The BFF handles the OIDC flow, stores tokens server-side, and issues a session cookie to the browser:
Browser <--cookie--> BFF (Node/Spring) <--JWT--> API Server
|
Keycloak
See our BFF pattern guide for a complete implementation.
Option 2: In-Memory (Recommended for SPAs without BFF)
Store the access token in a JavaScript variable (closure or module scope). It persists only for the tab’s lifetime.
// auth.js module
let accessToken = null;
export function setToken(token) {
accessToken = token;
}
export function getToken() {
return accessToken;
}
export async function fetchWithAuth(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
if (response.status === 401) {
// Token expired - trigger silent refresh
await refreshToken();
return fetchWithAuth(url, options);
}
return response;
}
Pros:
- Immune to CSRF
- Not accessible to other tabs or windows
- Token disappears when the tab closes
Cons:
- Lost on page refresh (requires silent refresh or iframe-based renewal)
- Still accessible to XSS if your app has injection vulnerabilities
Option 3: localStorage / sessionStorage (Generally Not Recommended)
Storing JWTs in localStorage is simple but exposes tokens to any JavaScript running on the page, including injected scripts from XSS attacks, third-party analytics, and browser extensions.
// Avoid this in production for sensitive applications
localStorage.setItem("access_token", token);
If you must use localStorage (for example, in a low-risk internal tool), mitigate risks by:
- Keeping token lifetimes very short (2-5 minutes)
- Using Content Security Policy (CSP) to restrict script sources
- Implementing Subresource Integrity (SRI) for all scripts
- Running regular XSS audits
Storage Decision Matrix
| Application Type | Recommended Storage | Refresh Strategy |
|---|---|---|
| Traditional web app (SSR) | httpOnly cookie | Server-side refresh |
| SPA with BFF | httpOnly cookie (session) | BFF handles refresh |
| SPA without BFF | In-memory | Silent refresh via iframe/redirect |
| Mobile app | Secure storage (Keychain/Keystore) | Refresh token |
| CLI/desktop app | OS credential store | Refresh token |
| Machine-to-machine | In-memory (short-lived) | Client credentials re-auth |
Token Size Optimization
JWTs are included in every API request. Large tokens increase bandwidth usage, hit cookie size limits, and slow down request processing. Keycloak tokens can easily exceed 2KB with default settings.
Strategies to Reduce Token Size
1. Remove unnecessary claims:
In Keycloak, go to Client Scopes and review each scope’s mappers. Remove mappers you do not need:
allowed-web-origins(rarely needed at the API level)profileclaims if the API does not need namesaddressclaims- Client-specific roles the API does not use
2. Use short claim names:
Configure custom mappers with abbreviated claim names:
| Instead of | Use |
|---|---|
organization_membership |
org |
department_name |
dept |
permission_level |
perm |
3. Move data to the UserInfo endpoint:
Not everything needs to be in the token. Claims that are rarely used or change frequently should be fetched from the UserInfo endpoint on demand.
4. Use opaque access tokens with introspection:
If token size is critical, configure Keycloak to issue opaque (non-JWT) access tokens. The API calls the introspection endpoint to validate them. This trades a network call for smaller requests.
Keycloak Configuration:
In the client settings, under Advanced Settings, you can configure token lifespan and claims. In Client Scopes, create a minimal scope with only the claims your API needs.
Key Rotation
JWT signing keys should be rotated periodically. If a signing key is compromised, rotating it limits the window of exposure.
Keycloak Key Rotation
Keycloak supports automated key rotation. Configure it under Realm Settings > Keys > Providers:
- Add a new
rsa-generatedprovider with a higher priority than the current one. - Keycloak immediately starts signing new tokens with the new key.
- Existing tokens remain valid because Keycloak keeps old keys in the JWKS endpoint until they expire.
- After all old tokens have expired, remove the old key provider.
JWKS Endpoint Caching:
Clients that cache the JWKS response must handle key rotation. When verification fails with a cached key, the client should:
- Re-fetch the JWKS endpoint.
- Retry verification with the new keys.
- Only fail if verification fails with fresh keys.
import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient(
"https://keycloak.example.com/realms/my-realm"
"/protocol/openid-connect/certs"
)
def verify_token(token: str) -> dict:
"""Verify a JWT with automatic JWKS key refresh."""
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="my-api",
issuer="https://keycloak.example.com/realms/my-realm",
)
except jwt.InvalidSignatureError:
# Key may have rotated - clear cache and retry
jwks_client.fetch_data()
signing_key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="my-api",
issuer="https://keycloak.example.com/realms/my-realm",
)
Rotation Schedule
| Environment | Rotation Frequency | Key Overlap Period |
|---|---|---|
| Development | Manual / as needed | N/A |
| Staging | Monthly | 1 day |
| Production | Every 90 days | 7 days |
| High security | Every 30 days | 3 days |
On Skycloak, key rotation is managed automatically, and rotation events are visible in the audit logs.
Claim Validation
Every API that accepts a JWT must validate these claims. Skipping any of them opens attack vectors.
Required Validations
def validate_claims(decoded_token: dict) -> None:
"""Validate all required JWT claims."""
import time
# 1. Issuer (iss) - prevents tokens from other providers
expected_issuer = (
"https://keycloak.example.com/realms/my-realm"
)
if decoded_token.get("iss") != expected_issuer:
raise ValueError(
f"Invalid issuer: {decoded_token.get('iss')}"
)
# 2. Audience (aud) - prevents tokens meant for other services
expected_audience = "my-api"
aud = decoded_token.get("aud")
if isinstance(aud, list):
if expected_audience not in aud:
raise ValueError("API not in token audience")
elif aud != expected_audience:
raise ValueError("Invalid audience")
# 3. Expiration (exp) - prevents expired tokens
if decoded_token.get("exp", 0) < time.time():
raise ValueError("Token expired")
# 4. Not Before (nbf) - prevents premature token use
if decoded_token.get("nbf", 0) > time.time():
raise ValueError("Token not yet valid")
# 5. Issued At (iat) - reject tokens from the far future
iat = decoded_token.get("iat", 0)
if iat > time.time() + 60: # 60s clock skew tolerance
raise ValueError("Token issued in the future")
# 6. Subject (sub) - ensure token has a subject
if not decoded_token.get("sub"):
raise ValueError("Missing subject claim")
# 7. Type (typ) - prevent ID tokens from being used as
# access tokens
if decoded_token.get("typ") != "Bearer":
raise ValueError(
f"Invalid token type: {decoded_token.get('typ')}"
)
Common Validation Mistakes
Accepting any issuer. Always validate iss against a whitelist. In multi-tenant setups, maintain a list of valid realm URLs.
Ignoring audience. Without aud validation, a token meant for Service A can be used to access Service B. Configure audience mappers in Keycloak (see our OIDC guide).
Accepting expired tokens. JWT libraries handle this, but make sure exp validation is not disabled in your configuration. Allow a small clock skew (30-60 seconds) for distributed systems.
Using the wrong algorithm. Always specify allowed algorithms explicitly. Never accept "alg": "none".
# WRONG - accepts any algorithm
jwt.decode(token, key)
# RIGHT - only accept RS256
jwt.decode(token, key, algorithms=["RS256"])
Refresh Token Rotation
Refresh tokens are long-lived credentials used to obtain new access tokens. If stolen, an attacker can maintain access indefinitely. Refresh token rotation detects theft by issuing a new refresh token with each use and invalidating the old one.
How Rotation Detects Theft
1. User authenticates -> gets access_token_1 + refresh_token_1
2. access_token_1 expires
3. App uses refresh_token_1 -> gets access_token_2 + refresh_token_2
(refresh_token_1 is now invalid)
If an attacker stole refresh_token_1:
4. Attacker tries refresh_token_1 -> REJECTED (already used)
5. Keycloak revokes the entire refresh token family
6. User must re-authenticate
Keycloak Configuration
Enable refresh token rotation in Keycloak:
- Go to Realm Settings > Tokens.
- Set Revoke Refresh Token to ON.
- Set Refresh Token Max Reuse to
0(each token is single-use).
Or configure per client under Client > Advanced Settings:
- Access Token Lifespan: 5 minutes
- Client Session Idle: 30 minutes
- Client Session Max: 10 hours
For a deep dive into token lifecycle management, see our guide on JWT token lifecycle: expiration, refresh, and revocation.
Implementing Rotation in Your App
// auth-service.js
class AuthService {
#accessToken = null;
#refreshToken = null;
#refreshPromise = null;
async refreshAccessToken() {
// Prevent concurrent refresh requests
if (this.#refreshPromise) {
return this.#refreshPromise;
}
this.#refreshPromise = this.#doRefresh();
try {
return await this.#refreshPromise;
} finally {
this.#refreshPromise = null;
}
}
async #doRefresh() {
const response = await fetch(
"https://keycloak.example.com/realms/my-realm"
+ "/protocol/openid-connect/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: "my-app",
refresh_token: this.#refreshToken,
}),
},
);
if (!response.ok) {
// Refresh token was revoked or expired
// Force re-authentication
this.#accessToken = null;
this.#refreshToken = null;
window.location.href = "/login";
throw new Error("Session expired");
}
const data = await response.json();
// Store the NEW tokens (old refresh token is now invalid)
this.#accessToken = data.access_token;
this.#refreshToken = data.refresh_token;
return this.#accessToken;
}
}
Audience Restriction
The aud claim limits which services can accept a token. Without it, a token obtained for one API can be used against any API that trusts the same issuer.
Keycloak Configuration
Add an audience mapper to your client:
- Go to Client > Client Scopes > Dedicated scope.
- Add mapper > Audience.
- Set Included Client Audience to the target API’s client ID.
- Enable Add to access token.
For multiple audiences (when one token needs to access multiple services):
{
"aud": ["api-service", "reporting-service"],
"azp": "frontend-app",
"scope": "openid profile"
}
Validating Audience
# In your API
VALID_AUDIENCES = {"my-api", "shared-service"}
def validate_audience(token_claims: dict) -> None:
aud = token_claims.get("aud")
if isinstance(aud, str):
aud = [aud]
if not set(aud or []).intersection(VALID_AUDIENCES):
raise ValueError("Token audience does not match this API")
Token Lifetime Configuration
Access Token Lifetime
Short access tokens limit the window of exposure if a token is stolen.
| Use Case | Recommended Lifetime |
|---|---|
| High-security APIs | 2-5 minutes |
| Standard web apps | 5-15 minutes |
| Internal services | 15-30 minutes |
| Long-running jobs | Use refresh tokens |
Configure in Keycloak: Realm Settings > Tokens > Access Token Lifespan.
ID Token Lifetime
ID tokens should match or be shorter than access tokens. They are used once at login, not for ongoing API access.
Refresh Token Lifetime
| Setting | Recommended Value |
|---|---|
| Refresh Token Lifespan | 30 days |
| SSO Session Idle | 30 minutes |
| SSO Session Max | 10 hours |
| Offline Session Idle | 30 days |
JWE: Encrypted Tokens for Sensitive Claims
Standard JWTs (JWS) are signed but not encrypted. Anyone with the token can read its claims (base64-decode the payload). If your tokens contain sensitive data, use JSON Web Encryption (JWE).
When to Use JWE
- Tokens contain PII (email, phone, address)
- Tokens include internal identifiers that should not leak
- Tokens pass through untrusted intermediaries
- Compliance requires data-at-rest encryption for tokens
Keycloak JWE Configuration
Keycloak can encrypt ID tokens using the client’s public key:
- In the client settings, go to Keys.
- Upload or generate an encryption key pair.
- Under Advanced Settings, set ID Token Encryption to the desired algorithm (e.g.,
RSA-OAEPwithA256GCM).
Note that encrypting access tokens is less common because resource servers need to decrypt them, adding complexity. Consider using opaque access tokens with introspection instead.
Summary Checklist
| Practice | Action | Keycloak Setting |
|---|---|---|
| Token storage | httpOnly cookies or in-memory | N/A (application concern) |
| Token size | Remove unnecessary mappers | Client Scopes > Mappers |
| Key rotation | Rotate every 30-90 days | Realm Settings > Keys |
| Claim validation | Validate iss, aud, exp, nbf, sub, typ | N/A (application concern) |
| Refresh rotation | Invalidate used refresh tokens | Realm Settings > Tokens > Revoke Refresh Token |
| Audience restriction | Scope tokens to specific APIs | Client Scopes > Audience Mapper |
| Short lifetimes | 5-15 min access tokens | Realm Settings > Tokens > Access Token Lifespan |
| Algorithm pinning | Only accept RS256/ES256 | Realm Settings > Keys |
| ID vs Access | Never send ID tokens to APIs | N/A (application concern) |
Further Reading
- RFC 7519 – JSON Web Token
- RFC 7516 – JSON Web Encryption
- Keycloak Token Configuration
- OAuth 2.1 changes (relevant to token best practices)
- OpenID Connect explained
- Token lifecycle management
- DPoP for proof-of-possession
- Skycloak Security
Conclusion
JWT security is not a single setting; it is a combination of practices that work together. Proper token storage prevents theft, short lifetimes limit exposure, refresh token rotation detects compromise, audience restriction prevents lateral movement, and claim validation ensures tokens are used as intended.
Keycloak provides configuration options for most of these practices. The application is responsible for the rest: storage choices, claim validation, and error handling. Apply these practices consistently across your stack, and use the JWT Token Analyzer to audit your tokens regularly.
Want managed Keycloak with security best practices pre-configured? Try Skycloak free for production-ready JWT management, automated key rotation, and enterprise-grade security.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.