JWT Best Practices: Security, Storage, and Rotation

Guilliano Molaire Guilliano Molaire Updated May 8, 2026 10 min read

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 SameSite and 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)
  • profile claims if the API does not need names
  • address claims
  • 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:

  1. Add a new rsa-generated provider with a higher priority than the current one.
  2. Keycloak immediately starts signing new tokens with the new key.
  3. Existing tokens remain valid because Keycloak keeps old keys in the JWKS endpoint until they expire.
  4. 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:

  1. Re-fetch the JWKS endpoint.
  2. Retry verification with the new keys.
  3. 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:

  1. Go to Realm Settings > Tokens.
  2. Set Revoke Refresh Token to ON.
  3. 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:

  1. Go to Client > Client Scopes > Dedicated scope.
  2. Add mapper > Audience.
  3. Set Included Client Audience to the target API’s client ID.
  4. 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:

  1. In the client settings, go to Keys.
  2. Upload or generate an encryption key pair.
  3. Under Advanced Settings, set ID Token Encryption to the desired algorithm (e.g., RSA-OAEP with A256GCM).

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

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.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman