JSON Web Token
Stateless bearer credentials, storage tradeoffs, and the security landscape of token-based authentication
Lead Summary
A JSON Web Token (JWT) is a compact, self-contained bearer credential that encodes user identity and authorization claims as a Base64-encoded JSON payload, cryptographically signed by the issuer. Because the server embeds all necessary context inside the token itself, it can validate incoming requests without looking anything up in a database — making JWTs inherently stateless. This property has made them the dominant form of access token in modern Single Page Applications (SPAs), REST APIs, and distributed systems.
The same compactness that makes JWTs attractive also concentrates risk: where to keep a token inside a browser, how to handle expiry, and what happens when one is stolen are questions with no single right answer. This article maps the full space of those tradeoffs, from storage mechanisms and token lifetimes to the Backend-for-Frontend pattern that eliminates browser-side token handling entirely.
Core Concepts
Three token roles
In an OpenID Connect (OIDC) authentication flow, three distinct token types appear alongside each other:
- Access token — a short-lived JWT presented to APIs to prove authorization. It carries the claims the server needs to make authorization decisions.
- ID token — a JWT that carries standardized identity claims (
sub,aud,iss,exp, plus anoncefor replay prevention). OIDC adds this identity layer on top of OAuth 2.0 so an application can establish user identity without a separate user-info API call. - Refresh token — a longer-lived credential used to obtain new access tokens. Unlike access tokens, refresh tokens are typically opaque (not JWTs) and require a server round-trip to validate.
Statelessness and its consequences
Token-based authentication is stateless: the server holds no record of which users are logged in or which tokens were issued. Every request is self-contained — the server verifies the signature and claims embedded in the token and makes its decision immediately, without a database lookup.
The flip side is no built-in revocation. Once a JWT is issued, it cannot be individually invalidated — if the token is compromised or the user logs out, the token remains accepted until its exp claim passes. Opaque tokens solve this problem by requiring a server introspection call, but that call adds latency and infrastructure complexity. The tradeoff is performance against revocation control.
JWT vs. opaque tokens
| Property | JWT | Opaque token |
|---|---|---|
| Validation | Local, no round-trip | Requires introspection endpoint |
| Revocation | Impossible without denylist | Immediate |
| Payload size | Grows with each claim | Fixed (opaque identifier) |
| Latency | Low | Adds network call |
A hybrid approach — short-lived JWTs for access tokens, opaque tokens for refresh tokens — is increasingly common. It captures stateless API performance while preserving server-side revocation control over long-lived sessions.
Mechanism & Process
The Authorization Code + PKCE flow
Modern guidance consistently recommends the Authorization Code flow for public clients such as SPAs. Unlike the now-deprecated Implicit flow, it never exposes the access token in the browser URL or URL fragment, keeping it out of browser history, referrer headers, and server logs.
PKCE (Proof Key for Code Exchange) strengthens the Authorization Code flow against interception. The client generates a random code_verifier, hashes it to produce a code_challenge, and sends the challenge with the initial authorization request. Even if an attacker intercepts the authorization code in transit, they cannot exchange it without the original code_verifier, which is never transmitted to the authorization server.
The state parameter prevents CSRF attacks against the OAuth flow itself: the application generates an unguessable value, passes it with the authorization redirect, and verifies it matches on return. In OIDC flows, the nonce serves a complementary but distinct role — it binds the ID token to a specific authentication request, preventing token replay rather than CSRF.
Token lifecycle
- The user authenticates; the authorization server returns an access token (short-lived JWT) and a refresh token.
- The client presents the access token as a
Bearercredential in theAuthorizationheader of API requests. - The server validates every request independently: it verifies the signature, checks
iss,aud, andexpclaims, and enforces authorization. - When the access token expires, the server returns
401 Unauthorized. A well-built HTTP interceptor catches this, silently exchanges the refresh token for a fresh access token, and retries the original request — transparent to the user. - If the refresh token is also expired or revoked, the client falls back to re-authentication.
Recommended token lifetimes
Access token lifetime should be 5–15 minutes; refresh tokens 7–14 days. The layered expiry model — short access, medium refresh, optional absolute session ceiling — limits exposure windows without forcing excessive re-authentication.
Token Storage in the Browser
Choosing where to keep a JWT in the browser is the central practical security decision for SPA developers. No option is free of tradeoffs.
localStorage and sessionStorage
localStorage is fundamentally vulnerable to XSS. Any JavaScript running in the page's context — including code injected through a third-party dependency — can read stored tokens with a trivial localStorage.getItem() call. A single XSS vulnerability anywhere in the application, or in any of its dependencies, exposes all stored tokens. This applies equally to sessionStorage.
Stealing JWTs in localStorage via XSS is a well-documented attack with no reliable mitigations that don't involve removing the token from localStorage entirely.
In-memory storage
Storing access tokens in JavaScript variables prevents them from persisting across page navigations. An XSS payload that executes in the same JavaScript context can still read in-memory values, but it cannot retrieve a token that has already been cleared. The limitation is also the protection: the token vanishes on page refresh, forcing the application to restore it on load.
Web Workers
Web Workers execute in a separate global context inaccessible to main-window JavaScript. A token stored exclusively inside a Worker cannot be read by an injected script operating on the DOM. Communication between the worker and the main thread happens via postMessage, introducing some complexity but a genuine isolation boundary.
HttpOnly cookies
An HttpOnly cookie cannot be read by any JavaScript — including malicious injected scripts. Browsers enforce this at the protocol level, making it the strongest client-side protection against XSS-based token theft. When combined with the Secure flag (HTTPS-only transmission) and SameSite (restricts cross-origin sending), HttpOnly cookies represent the most secure client-side storage mechanism available in browsers.
The tradeoff is that cookies are automatically sent by the browser with every matching request, including cross-origin requests triggered by a malicious third-party site. This is the root of CSRF vulnerability, and it means any application using cookies for authentication must implement CSRF protection.
The Hybrid Pattern
The pattern most widely recommended for modern SPAs combines the security properties of both approaches:
Store the short-lived access token in JavaScript memory. Store the refresh token in an HttpOnly, Secure, SameSite cookie set by the backend.
This separation creates a defense-in-depth model:
- Access token in memory — inaccessible to XSS attacks that read persistent storage; cleared on page unload, limiting the exposure window.
- Refresh token in HttpOnly cookie — inaccessible to JavaScript entirely; automatically sent by the browser to the refresh endpoint; protected by
SecureandSameSiteflags.
On page refresh, the application calls the refresh endpoint on load. The browser automatically sends the HttpOnly cookie, the server validates it, and issues a fresh access token into memory — restoring session state without user interaction.
Some SSR frameworks use a variant: the access token is returned in the response body while the refresh token is stored in an HttpOnly cookie, combining token statelessness with automatic browser cookie handling.
Security Threats and Mitigations
XSS
XSS allows attackers to inject and execute malicious JavaScript in the browser, granting them access to the DOM and any JavaScript-readable session data. Stored tokens, open sessions, and keystrokes are all exposed. XSS can also defeat CSRF token protection by reading the CSRF token from the page and including it in forged requests — meaning CSRF protection alone is insufficient when XSS is present.
Primary mitigations: use HttpOnly cookies for long-lived credentials; keep access tokens in memory and short-lived; implement Content Security Policy; audit dependencies.
CSRF
CSRF exploits the browser's automatic cookie attachment. A malicious third-party page loads a resource or submits a form to the victim's authenticated origin; the browser attaches the session cookie automatically, and the server has no way to distinguish the forged request from a legitimate one.
Bearer tokens in the Authorization header are inherently CSRF-resistant: browsers do not automatically inject them into cross-origin requests. A malicious site cannot forge a credentialed Bearer token request unless it has already stolen the token value (which is an XSS problem, not CSRF).
CSRF mitigations for cookie-based flows:
| Technique | How it works |
|---|---|
SameSite=Lax/Strict | Browser refuses to send cookie with cross-origin POST requests |
| Synchronizer token | Server-issued per-session token matched on each state-changing request |
| Custom header | Requires JavaScript to set a header; cross-origin scripts cannot set custom headers without CORS |
| Double-submit (signed) | HMAC-signed token pair; unsigned double-submit is insufficient |
| Fetch Metadata | Server inspects Sec-Fetch-Site header; requires no client-side token generation |
Token theft and revocation
Because JWTs cannot be revoked individually, a stolen access token remains valid until expiry. Short lifetimes (5–15 minutes) limit the damage window.
Refresh token rotation provides an additional layer: each refresh token is single-use. When the client exchanges it, the server issues a new refresh token and invalidates the old one. If an attacker attempts to reuse a stolen, already-rotated token, the server detects the replay — and RFC 9700 recommends invalidating the entire token family in response, forcing full re-authentication.
Sender-constrained tokens (DPoP)
RFC 9700, published January 2025, recommends Demonstrating Proof of Possession (DPoP) to bind access tokens to the specific client making the request. Each request includes a cryptographic proof that ties the token to the client's private key. A token stolen from transit or storage cannot be used without the corresponding key, making theft substantially less rewarding.
The Backend-for-Frontend Pattern
The architecturally cleanest resolution to browser-side token storage is to remove tokens from the browser entirely. The Backend-for-Frontend (BFF) pattern achieves this:
- The frontend communicates exclusively with a dedicated backend service — the BFF.
- The BFF acts as a confidential OAuth client, performing all token exchanges with the authorization server.
- Access tokens, refresh tokens, and ID tokens never leave the BFF server, typically stored in Redis or a similar fast store.
- The BFF issues the frontend a simple, first-party HttpOnly session cookie containing only a session identifier.
- When the SPA makes API requests, it sends this session cookie. The BFF validates the session, retrieves the actual access token from server storage, and proxies the request downstream.
This model is security-equivalent to traditional server-rendered applications — the strongest current tier for SPA authentication security. XSS on the client cannot steal OAuth tokens that the browser never possessed.
BFF implementation requirements are non-negotiable: the session cookie must carry HttpOnly, Secure, and SameSite=Strict, and the BFF must implement its own CSRF protection against forged session-cookie requests.
Cookies are browser-specific. Cookie-based authentication adds complexity for non-browser clients such as mobile apps or third-party integrations. The BFF pattern is most appropriate when the frontend is exclusively browser-based.
JWT Claims on the Client
Although client-side authorization is never a security boundary, JWT claims can be decoded and used safely for UI purposes. Displaying the user's name, applying feature flags, or rendering role-conditional UI elements are valid uses. These non-sensitive claims can be read from the JWT payload without requiring an additional server round-trip.
Client-side expiry checks (exp claim inspection) are also useful for triggering proactive refresh flows before an API call fails — but they carry no security weight. Server-side validation is always required: the server must verify the cryptographic signature, check iss and aud, and apply authorization logic on every request, independently of whatever the client previously checked.
Client-side checks — hiding UI elements, disabling buttons, or validating tokens in JavaScript — can all be bypassed by a user with developer tools or by API calls made directly. They improve UX; they do not enforce access control.
JWTs in Special Contexts
Server-Side Rendering
In SSR environments, cookies flow automatically from the browser to the server with each request, while Bearer tokens do not. This fundamental difference in HTTP semantics shapes authentication strategy: SSR frameworks handle cookie-based authentication transparently, whereas Bearer token patterns require manually attaching tokens to server-side outbound requests — and each server request must use a fresh, isolated cookie context to prevent cross-request state pollution.
Frameworks like Next.js expose auth() in server components and useSession() in client components via Auth.js, offering consistent session access patterns across rendering contexts. Supabase's @supabase/ssr package provides middleware that automatically refreshes tokens; its getUser() method validates against the auth server on each call.
Local-first and offline systems
In local-first architectures where clients synchronize data and may operate offline, JWT-based authorization presents a specific risk: offline token validation cannot detect revocations that occurred server-side. A user whose account is suspended continues to operate offline with a valid cached token until it expires.
Short TTLs (60–300 seconds for sync tokens) bound offline access duration. For higher-security contexts, a hybrid model adds an optional online revocation check alongside local signature verification.
A critical trust boundary applies to systems using sync rules: JWT-embedded claims must be used for permission filtering, not client-supplied parameters. JWT parameters are cryptographically signed and cannot be forged; client parameters are transmitted directly by client code and can be set to arbitrary values.
Controversies & Debates
The iframe silent renewal pattern is deprecated
Older OIDC libraries (auth0-spa-js, oidc-client-ts) used hidden iframes with response_mode=web_message for silent token renewal. This approach is increasingly unreliable: strict CSP with frame-ancestors 'none' blocks iframe loading, and SameSite=Lax on authorization server cookies prevents SSO cookies from being sent in cross-origin iframes. Failures often go undetected, silently logging users out. RFC 9700 explicitly deprecates this pattern in favor of token handler backends.
localStorage is still widely used despite security guidance
Despite clear security guidance against it, storing JWTs in localStorage remains widespread in tutorials and production codebases. The appeal is simplicity: no backend coordination, no cookie configuration, no CSRF surface. The security cost is a complete loss of protection against XSS — and XSS vulnerabilities in JavaScript-heavy applications are a realistic risk, particularly through third-party dependencies.
Further Reading
- OWASP JWT Testing Guide — authoritative testing methodology for JWT implementations
- RFC 9700 — OAuth 2.0 Security Best Current Practice — January 2025 IETF standard, covers DPoP, rotation requirements, and deprecation of implicit flow
- Curity: JWT Security Best Practices Checklist for APIs — practical checklist covering signature algorithms, claim validation, and transmission security
- Auth0: What Are Refresh Tokens and How to Use Them Securely — detailed guide to refresh token lifecycle and rotation
- Auth0: The Backend-for-Frontend Pattern — architecture overview for BFF-based token isolation
- Curity: Best Practices for Storing Access Tokens in the Browser — in-depth comparison of in-memory, Worker, and cookie storage
- OWASP CSRF Prevention Cheat Sheet — complete reference for CSRF mitigations, including Synchronizer Token, Double-Submit, SameSite, and Fetch Metadata
- Hasura: Your GraphQL guide to handling JWTs — practical patterns for JWT use in GraphQL APIs, including client-side expiry checks and refresh flow implementation