
Explore how JSON Web Tokens work, their role in OAuth 2.0/OIDC, secure storage choices, refresh token rotation, XSS/CSRF considerations, and scenarios where server‑side sessions may be a better fit.
What is a JWT?
A JSON Web Token (JWT) is a standard method for securely transmitting information between parties as a JSON object. Because the information is digitally signed, it can be verified and trusted. JWTs are primarily used in authentication and authorization workflows to provide a self-contained, stateless way to verify identity without requiring constant database lookups.
A signed JWT consists of three distinct parts separated by periods (.):
- Header: Typically consists of two parts: the token type (
JWT) and the cryptographic algorithm being used, such as HMAC SHA-256 (HS256) or RSA. - Payload (Claims): Contains the claims, which are statements about an entity (typically the user) and additional data. These can include registered claims (e.g.,
sub,iat,exp), public claims, or private claims defined by the application. - Signature: Used to verify that the sender of the JWT is who they say they are and to ensure that the message was not altered along the way. The signature is created by taking the encoded header, the encoded payload, a secret, and the algorithm specified in the header, then signing it.
To ensure tokens are safe for transport, JWTs utilize base64url encoding. Standard base64 encoding is often unsuitable for web transport because it contains characters like +, /, and =, which are not URL-safe and require percent-encoding in HTTP headers or URIs. Base64url encoding addresses this by replacing the non-safe characters with URL-friendly alternatives and omitting padding characters. This format allows the token to be transmitted seamlessly via:
- HTTP Authorization headers (e.g.,
Authorization: Bearer <token>) - URI query parameters
- HTML form data
The resulting string structure is Header.Payload.Signature. By leveraging base64url encoding, developers can safely include these tokens in diverse web environments without risking character corruption or needing to perform additional normalization steps at the application layer.
Using Signed JWTs for Authentication
A JSON Web Signature (JWS) is a JWT that includes a cryptographic signature to guarantee the integrity of its claims. The JOSE header declares the signing algorithm—commonly HS256, which is HMAC‑SHA‑256. The server creates the signature by applying the HMAC function to the Base64URL‑encoded header and payload, using a secret key that must remain confidential.
// Header
{
"typ": "JWT",
"alg": "HS256"
}
// Payload (claims)
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1700000000,
"exp": 1700003600
}
// Signature (pseudo‑code)
signature = Base64UrlEncode(
HMAC_SHA256(
Base64UrlEncode(header) + "." + Base64UrlEncode(payload),
secretKey
)
)
The resulting token has three dot‑separated parts: header.payload.signature. Because the signature can be verified with the shared secret, any tampering of the payload will cause verification to fail.
Typical login flow with a signed JWT:
- User submits credentials to
/auth/login. - The authentication service validates the credentials against its identity store.
- On success, it builds a payload containing the user identifier (
sub) and any required claims, then signs it with HS256. - The signed token is returned in the response body (often JSON) and the
Set‑Cookieheader if a cookie is preferred.
After receipt, the client must store the token securely. Recommended storage locations, aligned with OWASP’s Authentication Cheat Sheet, are:
- HttpOnly, Secure cookies – protects against XSS and ensures the token is automatically sent with each request to the same origin.
- In‑memory variables – suitable for single‑page applications that can avoid persistent storage altogether.
When making an API call, the client includes the token in the Authorization header using the Bearer scheme:
GET /api/resource HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
On the server side, each protected endpoint must:
- Extract the token from the
Authorizationheader (or cookie). - Validate the signature with the same HS256 secret.
- Check standard claims such as
exp(expiration) andnbf(not before) to enforce token freshness, as recommended by NIST SP 800‑63B. - Optionally verify additional claims (e.g., roles) before authorizing the request.
By following this flow, enterprise applications obtain a stateless, tamper‑evident authentication mechanism that scales without repeated database lookups while remaining compliant with widely accepted security standards.
JWTs within OAuth 2.0 and OpenID Connect
In OAuth 2.0 and OpenID Connect (OIDC) the token format is defined by the JSON Web Token (JWT) specification. OAuth 2.0 is an authorization framework that issues an access token—often a JWT—so a client can call a protected API without exposing user credentials. OIDC adds an identity layer that always returns an ID token, which is a JWT containing claims about the authenticated user (e.g., sub, email, name).
A typical OIDC flow therefore produces three distinct tokens:
- ID token – a JWT that asserts who the user is; intended for the client application to read, not for API authorization.
- Access token – usually a JWT that the client presents to a resource server; the server validates the signature and extracts scopes or permissions from its claims.
- Refresh token – an opaque string or JWT used only by the client to obtain new access tokens; it is never sent to the API.
Because JWTs are self‑contained, an API can verify an access token locally by checking the signature (e.g., HS256 or RS256) and the standard claims such as exp (expiration) and aud (audience). The following example shows a signed access token payload:
{
"iss":"https://auth.example.com",
"sub":"1234567890",
"aud":"api.example.com",
"exp":1735689600,
"scope":"read:orders write:orders"
}
Common pitfalls arise when developers treat the ID token as an authorization credential. The ID token is meant for the client to establish user identity; it does not contain scope or audience information required by the API, and its audience is typically the client’s client‑id, not the resource server. Using an ID token for API calls can lead to:
- Incorrect authorization decisions because the API cannot verify required scopes.
- Increased attack surface if the ID token is leaked, as it may expose personally identifiable information.
Best practice: always validate the access_token on the API side and reserve the id_token for client‑side user session handling. Refresh tokens should be stored securely (e.g., HttpOnly cookies) and used only to rotate access tokens, never sent to the API.
Secure Token Storage and Refresh Strategies
When a client receives a signed JWT or an opaque refresh token, the browser must persist it until the next authentication request. The two native storage mechanisms are localStorage and sessionStorage. Both expose a simple key‑value API, but they differ in lifespan and scope: localStorage survives browser restarts and is shared across all tabs of the same origin, whereas sessionStorage is cleared when the tab or window closes and is isolated per tab.
- localStorage
- Convenient for “remember‑me” flows because data persists across sessions.
- Increases the XSS attack surface: any script that runs in the origin can read the token.
- Does not mitigate CSRF; the token is sent manually (e.g., in an Authorization header), so CSRF risk is low if the header is required.
- sessionStorage
- Limits exposure to the lifetime of a single tab, reducing the window for an XSS exploit.
- Still vulnerable to XSS, but the attacker cannot reuse the token after the tab is closed.
- Like
localStorage, it does not automatically submit with cross‑site requests, so CSRF risk remains minimal.
Because both storages are accessible to JavaScript, they are unsuitable when a strict defense‑in‑depth strategy requires isolation from the DOM. An alternative is to store tokens in HttpOnly cookies, which are inaccessible to JavaScript and thus immune to XSS, but they re‑introduce CSRF concerns unless same‑site attributes and anti‑CSRF tokens are employed.
Refresh tokens should be short‑lived and rotated on each use. Rotation limits the impact of a stolen token because the previous token is invalidated after the first successful refresh. The following practices align with OWASP and NIST guidance:
- Set a modest expiration (e.g., minutes to hours) for the refresh token.
- Store the refresh token in
sessionStorageor anHttpOnlycookie withSameSite=Strict. - On each token refresh, issue a new refresh token and revoke the old one on the server side.
- Log and monitor anomalous refresh patterns to detect token replay attacks.
Example of a safe client‑side refresh flow using sessionStorage:
async function refreshAccess() {
const rt = sessionStorage.getItem('refreshToken');
const resp = await fetch('/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: rt })
});
const { accessToken, refreshToken } = await resp.json();
sessionStorage.setItem('accessToken', accessToken);
sessionStorage.setItem('refreshToken', refreshToken);
}
Limitations and When Not to Use JWTs
JSON Web Tokens (JWTs) are self‑contained, signed data structures that travel between a client and a server. Because the payload is only base64url‑encoded, the token is not encrypted; anyone who intercepts it can read the claims unless an additional encryption layer (JWE) is applied. This lack of built‑in confidentiality means that sensitive information such as passwords, personal identifiers, or financial data should never be placed in a JWT payload.
Key technical drawbacks
- Size limits – A JWT includes a header, payload, and signature. Adding many claims (roles, permissions, timestamps, etc.) quickly inflates the token. Large tokens increase request‑header size, can exceed HTTP header limits on some proxies, and add bandwidth overhead for every API call.
- No inherent encryption – The standard only defines signing (JWS). Confidentiality must be added explicitly with JWE, which complicates key management and processing time.
- Stateless revocation difficulty – Because the server does not retain token state, revoking a compromised token requires either short lifetimes with refresh‑token rotation or a token blacklist, both of which re‑introduce server‑side state.
- Complexity in rotation – Managing key rotation for signing algorithms (e.g., HS256, RS256) demands a secure key‑distribution mechanism; a stale key can invalidate all active tokens.
When server‑side sessions are a better fit
- Applications that need to store highly sensitive data (PII, health records) and prefer encryption at rest without adding JWE layers.
- Environments where strict compliance frameworks such as ISO 27001 or NIST require immediate revocation of access after a security event.
- Systems with low‑latency internal networks where the overhead of a database lookup for a session identifier is negligible compared to the cost of transmitting large JWTs.
- Scenarios where the application already maintains a session store (e.g., Redis, Memcached) and can leverage existing session‑management middleware for CSRF protection and idle‑timeout handling.
Practical example
Consider a banking API that returns a JWT containing {"userId":"123","accountNumbers":["A1","A2"],"balance":5000}. The token size exceeds 1 KB, causing the Authorization: Bearer … header to approach typical proxy limits (8 KB). Each request now carries unnecessary data, and any intercepted token reveals account numbers because the token is not encrypted. Switching to a server‑side session that stores only a random session ID (e.g., sid=7f9c3e) eliminates the payload exposure and keeps header size minimal.
In summary, JWTs excel when stateless, short‑lived access tokens are sufficient, but their size, lack of default encryption, and revocation challenges make server‑side sessions the safer, simpler choice for high‑security or compliance‑driven environments.
Best‑Practice Checklist & FAQs
JSON Web Tokens (JWTs) are signed (JWS) structures that let a stateless service verify claims without a database round‑trip. Because the signature is produced with a cryptographic algorithm and a secret (or private key), the token’s integrity is guaranteed as long as the secret is protected and the algorithm is appropriate for the threat model.
Best‑Practice Checklist
- Algorithm selection: Prefer asymmetric algorithms (e.g., RS256, ES256) for distributed services; use symmetric HMAC (HS256) only when the secret never leaves the issuing server. Avoid
alg:noneand weak hashes. - Secret/key management: Store keys in a dedicated secret manager (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) and rotate them regularly. Align rotation policies with NIST SP 800‑57 and ISO 27001 control A.12.6.
- Per‑request validation: Verify the signature,
exp,nbf, andisson every protected endpoint. Do not rely on client‑side checks; implement middleware that follows OWASP ASVS V5.2.1. - Token expiry: Issue short‑lived access tokens (minutes to an hour). Encode the expiration in the
expclaim and reject any request where the current time exceeds it. - Refresh‑token rotation: Store refresh tokens server‑side (or as opaque tokens) and issue a new refresh token on each use. Invalidate the previous token to mitigate replay attacks, as recommended by OAuth 2.0 best practices.
- Scope and audience: Include
audandscopeclaims that limit the token to specific APIs. Validate these claims before authorizing any action. - Secure storage on the client: Prefer http‑only, same‑site cookies for web apps to reduce XSS exposure; avoid localStorage for high‑value tokens.
Frequently Asked Questions
- Can I decode a JWT without the secret key?
- Yes. The header and payload are base64url‑encoded and can be decoded by anyone, but without the secret the signature cannot be verified. Decoding alone does not prove authenticity.
- What should I do when a token expires?
- Return a 401 Unauthorized response and, if a valid refresh token is present, exchange it for a new access token. Do not extend the original token’s
expclaim. - Should JWTs be used for both authentication and authorization?
- Use separate tokens: an ID token (authentication) conveys the user’s identity, while an access token (authorization) carries scopes/permissions. Mixing them can lead to over‑privileged access and violates OIDC guidance.
- Is it safe to store the secret in source code?
- No. Embedding secrets violates SOC 2 and NIST 800‑53 requirements. Use environment variables or secret‑management services and restrict access via least‑privilege IAM policies.
Editorial Policy & Research Methodology
Our findings are based on rigorous internal research, verified industry benchmarks, and direct technical implementation experience from our enterprise client projects. All statistics and technical claims are reviewed by senior engineers before publication to ensure accuracy, transparency, and helpfulness for our readers.
