Introduction
JSON Web Tokens (JWT), pronounced "jot," are an open standard (RFC 7519) that defines a compact, URL-safe means of representing claims between two parties. JWTs are widely used for authentication and authorization in modern web applications, APIs, and microservice architectures.
A JWT encodes a set of claims as a JSON object that is digitally signed (and optionally encrypted). Because the token is self-contained -- carrying all the information needed to verify its authenticity and extract the user's identity -- the server does not need to maintain session state. This statelessness makes JWTs particularly well-suited for distributed systems where multiple servers must independently validate requests.
JWTs have become the de facto standard for token-based authentication in OAuth 2.0 and OpenID Connect flows. However, their widespread adoption has also revealed significant security pitfalls when they are implemented incorrectly. Understanding both the structure and the vulnerabilities of JWTs is essential for any developer working with modern web security.
"JWTs are not a session mechanism. They are a token format. Using them as sessions introduces complexity and failure modes that server-side sessions handle trivially." -- Thomas Ptacek, security researcher, on the common misuse of JWTs as session replacements
JWT Structure
A JWT consists of three parts separated by dots (.): the header, the payload, and the signature. Each part is Base64URL-encoded, producing a compact string suitable for transmission in HTTP headers, URL parameters, or form fields.
# JWT structure:# HEADER.PAYLOAD.SIGNATUREeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiaWF0IjoxNjE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c# Decoded:# Header: {"alg": "HS256", "typ": "JWT"}# Payload: {"sub": "1234567890", "name": "Jane Doe", "iat": 1616239022}# Signature: HMAC-SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)Header
The header (also called the JOSE header) typically contains two fields: alg, which specifies the signing algorithm (such as HS256 or RS256), and typ, which identifies the token type (always "JWT"). The header may also contain a kid (key ID) to identify which key was used for signing when multiple keys are in rotation.
Payload
The payload contains the claims -- statements about the user and additional metadata. Claims are categorized as registered (standardized names defined in RFC 7519), public (defined in the IANA JSON Web Token Claims registry), and private (custom claims agreed upon by the parties). The payload is Base64URL-encoded, not encrypted -- anyone can decode it and read the claims. Never include sensitive data like passwords or secret keys in the payload.
Signature
The signature is computed over the encoded header and payload using the algorithm specified in the header and a secret key (for HMAC) or private key (for RSA/ECDSA). The signature ensures that the token has not been tampered with and, in the case of asymmetric algorithms, proves the identity of the issuer.
// Creating and verifying a JWT (Node.js with jsonwebtoken)const jwt = require('jsonwebtoken');// Signing (creating) a tokenconst token = jwt.sign( { sub: 'user123', name: 'Jane Doe', role: 'admin' }, process.env.JWT_SECRET, // secret key for HMAC { algorithm: 'HS256', expiresIn: '1h', // sets the 'exp' claim issuer: 'api.example.com' });// Verifying a tokentry { const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'], // CRITICAL: always specify allowed algorithms issuer: 'api.example.com' }); console.log(decoded.sub); // 'user123'} catch (err) { console.error('Token invalid:', err.message);}JWS vs. JWE
The term "JWT" is often used loosely, but the RFC actually defines two distinct serialization formats: JWS (JSON Web Signature) and JWE (JSON Web Encryption). Most implementations that people call "JWT" are technically JWS tokens -- signed but not encrypted.
| Property | JWS (Signed) | JWE (Encrypted) |
|---|---|---|
| Structure | 3 parts (header.payload.signature) | 5 parts (header.key.iv.ciphertext.tag) |
| Payload Readable | Yes (Base64URL-encoded, not encrypted) | No (encrypted) |
| Integrity | Yes (signature verification) | Yes (authenticated encryption) |
| Confidentiality | No | Yes |
| Use Case | Authentication tokens, API authorization | Sensitive data transmission |
| Performance | Fast (signing only) | Slower (encryption + signing) |
JWS is appropriate when you need to verify the integrity and origin of the claims but the claims themselves are not confidential (e.g., a user ID and role). JWE is necessary when the payload contains sensitive information that must be hidden from intermediaries or the token holder. In some architectures, a JWS is nested inside a JWE to achieve both signing and encryption.
Registered Claims
RFC 7519 defines seven registered claims. These are not required but are recommended to provide a set of useful, interoperable claims. All time-based claims use Unix timestamps (seconds since the epoch).
| Claim | Full Name | Description | Example |
|---|---|---|---|
| iss | Issuer | Identifies the principal that issued the token | "api.example.com" |
| sub | Subject | Identifies the principal that is the subject | "user123" |
| aud | Audience | Identifies the recipients the token is intended for | "frontend.example.com" |
| exp | Expiration Time | Time after which the token must not be accepted | 1716239022 |
| nbf | Not Before | Time before which the token must not be accepted | 1616239022 |
| iat | Issued At | Time at which the token was issued | 1616239022 |
| jti | JWT ID | Unique identifier for the token (prevents replay) | "a1b2c3d4" |
Proper validation of these claims is critical for security. At minimum, every JWT verification should check exp (reject expired tokens), iss (reject tokens from unexpected issuers), and aud (reject tokens intended for different services). Failure to validate these claims is a common source of vulnerabilities.
Signing Algorithms
The choice of signing algorithm has significant security and architectural implications. JWT supports both symmetric (shared secret) and asymmetric (public/private key pair) algorithms.
| Algorithm | Type | Key Size | Use Case | Notes |
|---|---|---|---|---|
| HS256 | Symmetric (HMAC) | 256-bit secret | Single server or trusted parties | Both signing and verification use the same key |
| HS384 | Symmetric (HMAC) | 384-bit secret | Higher security symmetric | Larger MAC output |
| HS512 | Symmetric (HMAC) | 512-bit secret | Maximum symmetric security | Largest MAC output |
| RS256 | Asymmetric (RSA) | 2048+ bit key pair | Distributed systems, OIDC | Sign with private, verify with public key |
| ES256 | Asymmetric (ECDSA) | P-256 curve | Modern applications | Smaller keys, faster than RSA |
| EdDSA | Asymmetric (EdDSA) | Ed25519 | High-performance applications | Fastest asymmetric option |
Asymmetric algorithms are preferred for distributed architectures where multiple services need to verify tokens but only one service should be able to issue them. The authorization server signs tokens with its private key, and resource servers verify using the corresponding public key (often distributed via a JWKS endpoint). This eliminates the need to share a secret key across services.
Common Vulnerabilities
JWT implementations have been plagued by a set of well-documented vulnerabilities. Most arise not from flaws in the JWT specification itself but from incorrect implementation and insufficient validation.
The "alg: none" Attack: The JWT specification allows a token to have "alg": "none", indicating an unsecured token with no signature. If a server's JWT library accepts tokens with alg: none, an attacker can forge arbitrary tokens by stripping the signature and setting the algorithm to none.
# The alg:none attack# Original valid token (signed with HS256):eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6InVzZXIifQ.signature# Attacker modifies the header to {"alg":"none"} and changes role to admin:eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIn0.# If the server accepts alg:none, the forged token is treated as valid# with no signature verification at allAlgorithm Confusion Attack: When a server is configured to verify tokens with an RSA public key, an attacker can change the algorithm in the header from RS256 to HS256. The server's JWT library may then use the RSA public key (which is public knowledge) as the HMAC secret key, allowing the attacker to forge valid signatures.
# Algorithm confusion attack# Server is configured to verify with RSA public key# Attacker changes header from {"alg":"RS256"} to {"alg":"HS256"}# Attacker signs the token using HMAC with the RSA public key as the secret# Vulnerable libraries treat the public key as an HMAC secret and accept the token# DEFENSE: Always specify allowed algorithms in verificationjwt.verify(token, publicKey, { algorithms: ['RS256'] });# Never allow the token's own header to dictate the verification algorithm"The JWT specification is not broken, but the ecosystem of JWT libraries has historically been riddled with critical vulnerabilities. The alg:none and algorithm confusion attacks should have been impossible, but they affected nearly every major JWT library at some point." -- Tim McLean, who discovered the algorithm confusion vulnerability in 2015
Other common JWT vulnerabilities include:
- Weak signing keys: Using short or predictable strings as HMAC secrets. Keys must be at least as long as the hash output (256 bits for HS256) and generated from a cryptographically secure random source.
- Missing expiration: Tokens without an
expclaim remain valid indefinitely, creating a permanent credential if leaked. - Token leakage via URLs: Including JWTs in query parameters exposes them in server logs, browser history, and Referer headers.
- Insufficient claim validation: Failing to verify
iss,aud, orexpclaims allows token misuse across services. - Client-side trust: Making authorization decisions based on unverified token contents on the client side.
Token Storage
Where a JWT is stored on the client has significant security implications. There is no perfect solution -- each storage location presents a different risk profile.
| Storage | XSS Risk | CSRF Risk | Persistence | Notes |
|---|---|---|---|---|
| localStorage | High (accessible to JS) | None (not sent automatically) | Persistent | Convenient but vulnerable to XSS |
| sessionStorage | High (accessible to JS) | None | Tab only | Lost on tab close |
| HttpOnly Cookie | None (not accessible to JS) | High (sent automatically) | Configurable | Requires CSRF protection |
| Memory (variable) | Low (not in persistent storage) | None | None | Lost on page refresh |
The most common recommendation for high-security applications is to store access tokens in memory (JavaScript variables) and use HttpOnly, Secure, SameSite cookies for refresh tokens. This protects access tokens from XSS (not accessible to scripts from persistent storage) and protects refresh tokens from both XSS (HttpOnly) and CSRF (SameSite). The tradeoff is that access tokens are lost on page refresh, requiring a silent re-authentication using the refresh token.
Token Lifecycle and Revocation
One of the fundamental challenges of JWTs is revocation. Because tokens are stateless and self-contained, the server has no built-in mechanism to invalidate a token before its expiration. If a user logs out or their permissions change, any previously issued tokens remain valid until they expire.
Common strategies for handling token revocation:
- Short-lived access tokens: Set
expto 5-15 minutes. Even if a token is stolen, the exposure window is small. Use refresh tokens to obtain new access tokens. - Token denylist: Maintain a server-side list of revoked token IDs (
jticlaims). This reintroduces statefulness but only for the small set of revoked tokens. - Token versioning: Store a version number in the user's database record. Include the version in the JWT. When the user logs out or changes their password, increment the version. Tokens with old versions are rejected.
- Refresh token rotation: Issue a new refresh token with every access token refresh. If a refresh token is used twice, assume it was stolen and revoke the entire token family.
// Refresh token rotation pattern// 1. Client sends refresh token to /auth/refresh// 2. Server validates refresh token and checks it has not been used before// 3. Server issues new access token AND new refresh token// 4. Server marks the old refresh token as used// 5. If a used refresh token is presented again:// - Assume token theft (attacker replayed the stolen token)// - Revoke ALL tokens in the family// - Force re-authenticationapp.post('/auth/refresh', async (req, res) => { const { refreshToken } = req.body; const stored = await db.findRefreshToken(refreshToken); if (!stored) return res.status(401).json({ error: 'Invalid token' }); if (stored.used) { // Token reuse detected -- possible theft await db.revokeTokenFamily(stored.familyId); return res.status(401).json({ error: 'Token reuse detected' }); } await db.markAsUsed(stored.id); const newAccessToken = jwt.sign({ sub: stored.userId }, secret, { expiresIn: '15m' }); const newRefreshToken = crypto.randomUUID(); await db.saveRefreshToken(newRefreshToken, stored.userId, stored.familyId); res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });});Best Practices
- Always specify allowed algorithms in the verification function. Never let the token's header determine which algorithm to use.
- Use asymmetric algorithms (RS256, ES256) for distributed systems where multiple services verify tokens.
- Keep tokens short-lived. Access tokens should expire in 5-15 minutes. Use refresh tokens for longer sessions.
- Validate all registered claims:
exp,iss,aud, andnbfat minimum. - Use strong, randomly generated keys. HMAC keys must be at least as long as the hash output (32 bytes for HS256).
- Never store sensitive data in the payload. The payload is only Base64URL-encoded, not encrypted. Use JWE if confidentiality is needed.
- Do not pass JWTs in URL query parameters. They will appear in server logs, browser history, and Referer headers.
- Implement token revocation using short lifetimes, denylists, or refresh token rotation.
- Use a well-maintained JWT library that has been audited for the known vulnerability classes (alg:none, algorithm confusion).
- Consider whether you actually need JWTs. For simple server-rendered applications with a single backend, traditional server-side sessions are simpler and avoid the revocation problem entirely.
References
- Jones, M. et al. (2015). RFC 7519: JSON Web Token (JWT). IETF.
- Jones, M. et al. (2015). RFC 7515: JSON Web Signature (JWS). IETF.
- Jones, M. and Hildebrand, J. (2015). RFC 7516: JSON Web Encryption (JWE). IETF.
- Jones, M. (2015). RFC 7518: JSON Web Algorithms (JWA). IETF.
- McLean, T. (2015). "Critical Vulnerabilities in JSON Web Token Libraries." Auth0 Blog.
- Ptacek, T. (2015). "JWT is a Bad Standard That Everyone Should Avoid." Hacker News Discussion.
- Sheffer, Y. et al. (2023). RFC 8725: JSON Web Token Best Current Practices. IETF.
- OWASP Foundation. (2023). OWASP JSON Web Token Cheat Sheet for Java. OWASP.