IAMRoadmapIAMRoadmap
General
18 min read

JWT Security Deep Dive: Validate, Avoid Pitfalls, Block Attacks

Explore JWT security with a deep dive into validation best practices, common pitfalls to avoid, and effective strategies to block various attack patterns.

I

IAM Roadmap Team

IAM Security Expert

August 31, 2026

JWTs have become the lingua franca for conveying identity and authorization claims across distributed systems, largely thanks to their stateless nature. But this statelessness, while a boon for scalability, introduces significant security challenges. The core problem we're solving is how to establish and maintain trust in a self-contained token that could have been issued anywhere, by anyone, and then used by any service. It's not enough to decode a JWT; validating its authenticity, integrity, and applicability is paramount. Fail here, and you've got an open door to your systems.

Many developers treat JWTs as magic strings, blindly trusting their contents after a superficial library call. This is a common and dangerous misconception. A JWT is only as secure as its issuance and, more critically, its validation process. We're going to dig into the mechanisms that make JWTs trustworthy, the pitfalls that turn them into footguns, and the attack patterns that exploit validation weaknesses.

JWT Validation: The Unforgiving Gatekeeper

The security of a JSON Web Token (JWT), as defined in RFC 7519, hinges entirely on rigorous validation. This isn't about checking a signature; it's a multi-layered process that ensures the token's integrity, authenticity, and authorization context. Skipping any step here is like leaving the back door unlocked after bolting the front.

Signature Validation: The Absolute Minimum

The first, non-negotiable step is signature validation. A JWT consists of three parts: Header, Payload, and Signature, separated by dots (.): header.payload.signature. The signature ensures that the token's contents haven't been tampered with since it was issued.

The alg (algorithm) header parameter, defined in RFC 7518 (JSON Web Algorithms, JWA), specifies the algorithm used to sign the token. Common algorithms include:

  • HS256 (HMAC with SHA-256): Symmetric key algorithm. Both issuer and verifier use the same secret. Simpler to implement but requires secure sharing of the secret.
  • RS256 (RSA with SHA-256): Asymmetric key algorithm. Issuer signs with a private key, verifier verifies with the corresponding public key. More complex key management but allows public keys to be distributed widely without compromising the signing key.
  • ES256 (ECDSA with P-256 and SHA-256): Elliptic Curve Digital Signature Algorithm. Offers similar security to RSA with smaller key sizes, thus smaller tokens.
  • PS256 (RSASSA-PSS with SHA-256): RSA Probabilistic Signature Scheme. Offers enhanced security over RS256 by adding randomness, making it more resistant to certain cryptographic attacks.

CAUTION

Never, under any circumstances, allow the verification library to default to the alg: "none" algorithm. This is a well-known vulnerability (CVE-2015-9235, though it's more of a design flaw in early libraries than a CVE against the spec) where an attacker can modify the token header to {"alg": "none"} and strip the signature, and if your library isn't explicit about required algorithms, it will "validate" successfully. Always explicitly define the permitted signing algorithms.

For asymmetric algorithms, public keys are often exposed via a JSON Web Key Set (JWKS) endpoint, as defined in RFC 7517. This endpoint, typically at /.well-known/jwks.json for OIDC providers, allows verifiers to fetch the public keys needed to validate tokens without prior arrangement. The kid (key ID) header in the JWT identifies which specific key in the JWKS should be used for validation.

// Example: Basic JWT signature validation in Node.js
import { verify } from 'jsonwebtoken';
import axios from 'axios';

// A real-world IdP's JWKS endpoint
const JWKS_URI = 'https://accounts.google.com/.well-known/openid-configuration/jwks';

async function getPublicKey(kid: string) {
 // In a real application, cache this response and refresh periodically
 const response = await axios.get(JWKS_URI);
 const jwks = response.data.keys;
 const signingKey = jwks.find((key: any) => key.kid === kid);

 if (!signingKey) {
 throw new Error(`Public key with kid ${kid} not found in JWKS.`);
 }

 // Convert JWK to PEM format (library-specific conversion might be needed)
 // For 'jsonwebtoken' library, you often pass the JWK directly or a PEM string.
 // This example assumes a library that can consume JWK directly or a helper
 // to convert to PEM. For 'node-jwks-rsa', it handles this.
 return signingKey; // For 'jsonwebtoken', often need to convert to PEM or use 'jwks-rsa'
}

async function validateJwt(token: string) {
 const decodedHeader = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString());
 const kid = decodedHeader.kid;

 if (!kid) {
 throw new Error('JWT header missing kid parameter. Cannot determine signing key.');
 }

 const publicKey = await getPublicKey(kid);

 try {
 // Specify allowed algorithms explicitly. Never trust the 'alg' header blindly.
 const decoded = verify(token, publicKey, {
 algorithms: ['RS256', 'ES256'], // Only allow these algorithms
 issuer: 'https://accounts.google.com', // Strict issuer validation
 audience: 'YOUR_CLIENT_ID', // Your application's client ID
 // clockTolerance: 5, // Allow for 5 seconds of clock skew
 });
 console.log('JWT validated successfully:', decoded);
 return decoded;
 } catch (error) {
 console.error('JWT validation failed:', error);
 throw error;
 }
}

// Usage example (replace with a real token)
// const exampleToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2NTdkMGEwNzNhN2ZjYjYwZDAwMDcwN2M5YjY3YjY4NjM0NjkwOSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiIxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAiLCJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjIsImVtYWlsIjoiYm9iQGV4YW1wbGUuY29tIn0.signature";
// validateJwt(exampleToken).catch(console.error);

TIP

Use a library like jwks-rsa for Node.js, python-jose for Python, or spring-security-oauth2 for Java. These libraries handle JWKS fetching, caching, and key selection automatically, reducing boilerplate and common errors. They typically integrate with jsonwebtoken or equivalent JWT processing libraries.

Claim Validation: Beyond the Signature

A valid signature only proves the token hasn't been tampered with and was issued by a trusted party. It doesn't mean the token is valid for your specific use case. This is where claim validation comes in. Claims are statements about the entity (typically the user) and additional metadata.

Standard Claims (RFC 7519):

  • iss (Issuer): Identifies the principal that issued the JWT. Crucial to validate. This should match the expected IdP (e.g., https://accounts.google.com).
  • aud (Audience): Identifies the recipient(s) that the JWT is intended for. Frequently missed, but critical. Your service's client ID or URI should be present in this claim. If it's not, the token isn't for you.
  • exp (Expiration Time): Identifies the expiration time on or after which the JWT must not be accepted. Absolutely mandatory. Always check this.
  • nbf (Not Before): Identifies the time before which the JWT must not be accepted. Useful for preventing token use before its intended activation.
  • iat (Issued At): Identifies the time at which the JWT was issued. Useful for calculating token age.
  • jti (JWT ID): Provides a unique identifier for the JWT. Can be used to prevent replay attacks by maintaining a blacklist of used jtis.

Custom Claims:

These are application-specific claims, like scope, roles, permissions, tenant_id, etc. Validate these against your application's authorization policies. Don't blindly trust admin: true from a token if it wasn't issued by your own internal authorization service after proper checks.

// Continuing from the previous example, focusing on claim validation options
const validationOptions = {
 algorithms: ['RS256', 'ES256'], // Explicitly permitted algorithms
 issuer: 'https://accounts.google.com', // Must match the IdP
 audience: 'YOUR_CLIENT_ID', // Must contain your client ID
 maxAge: '1h', // Token must not be older than 1 hour (relative to 'iat')
 clockTolerance: 5, // Allow for 5 seconds of clock skew
 ignoreNotBefore: false, // Enforce 'nbf' claim
 ignoreExpiration: false, // Enforce 'exp' claim
};

try {
 const decoded = verify(token, publicKey, validationOptions);
 // Beyond standard claims, validate custom claims based on your application logic
 if (decoded.scope && !decoded.scope.includes('api.read')) {
 throw new Error('Token does not have required "api.read" scope.');
 }
 console.log('All claims validated successfully.');
} catch (error) {
 console.error('Claim validation failed:', error);
 throw error;
}

IMPORTANT

The aud claim is probably the most commonly misconfigured or ignored claim. If your service doesn't validate aud against its own identifier, it could accept a token meant for a completely different service, leading to authorization bypasses if an attacker gets hold of such a token.

Trade-offs in Validation Rigor

AspectLax Validation (Bad)Strict Validation (Good)Trade-offs
alg parameterTrusts alg header, allows none.Explicitly specifies and limits allowed algorithms (e.g., ['RS256', 'ES256']).More secure, but requires maintenance if algorithms change. Prevents "None" algorithm attacks.
iss (Issuer)No validation or accepts multiple IdPs blindly.Verifies against a known, trusted IdP URI.Prevents tokens from unknown or malicious issuers. Can be complex in multi-IdP environments, requiring a list of trusted issuers.
aud (Audience)No validation or accepts any aud.Verifies that the token's audience includes the current service's identifier.Prevents tokens intended for other services from being used. Requires each service to know its own unique audience identifier.
exp (Expiration)Ignores or uses a long exp.Enforces a short exp (e.g., 5-15 minutes for access tokens), uses clockTolerance.Shorter exp reduces the window for token replay/theft, but necessitates more frequent token refreshes (using refresh tokens). clockTolerance prevents issues with minor clock skew between systems.
nbf (Not Before)Ignores nbf.Enforces nbf to prevent premature use.Small overhead, but prevents tokens from being used before their intended activation time.
jti (JWT ID)No tracking.Stores used jtis in a temporary blacklist/cache to prevent replay attacks.Adds statefulness to an otherwise stateless system. Increases complexity and requires a distributed, fast cache. Only practical for short-lived tokens or specific high-security scenarios. Often better to rely on short exp and refresh token rotation.
Custom ClaimsTrusts all custom claims as-is.Validates custom claims against expected types, formats, and authorization policies.Prevents malicious injection of false claims. Requires explicit policy definition and enforcement logic in the application.

Key Management and Rotation

Proper key management is not a technical detail; it's a foundational security requirement. If your signing keys are compromised, an attacker can issue valid-looking tokens, bypassing all your signature checks.

JWKS Endpoint (RFC 7517)

For asymmetric signing algorithms (RS256, ES256, PS256), a JWKS endpoint is the standard way for Identity Providers (IdPs) to publish their public keys. This allows relying parties (your services) to dynamically fetch the keys without manual configuration.

A typical JWKS endpoint (/.well-known/jwks.json) returns a JSON object containing an array of JWKs, each with a unique kid.

{
 "keys": [
 {
 "kty": "RSA",
 "e": "AQAB",
 "use": "sig",
 "kid": "unique-key-id-1",
 "alg": "RS256",
 "n": "..." // RSA public key modulus
 },
 {
 "kty": "EC",
 "crv": "P-256",
 "use": "sig",
 "kid": "unique-key-id-2",
 "alg": "ES256",
 "x": "...", // EC public key x-coordinate
 "y": "..." // EC public key y-coordinate
 }
 ]
}

NOTE

When implementing JWKS fetching, always cache the keys with an appropriate TTL. Repeatedly fetching the JWKS for every token validation is a performance killer and can lead to rate limiting. Refresh the cache periodically (e.g., every 6-24 hours) or when a kid is encountered that isn't in the current cache.

Key Rotation Strategy

Keys should be rotated periodically, especially signing keys. A good rotation strategy looks like this:

  1. Generate New Key Pair: Create a new asymmetric key pair with a fresh kid.
  2. Publish New Public Key: Add the new public key to your JWKS endpoint alongside the old one.
  3. Start Signing with New Key: Configure your IdP to start signing new tokens with the new private key.
  4. Grace Period: Keep the old public key in the JWKS for a grace period (e.g., 24-48 hours) to allow existing tokens signed with the old key to expire gracefully, and for relying parties' caches to update.
  5. Deprecate Old Key: Remove the old public key from the JWKS.

This strategy ensures a smooth transition without invalidating currently active tokens or breaking relying parties that have cached the old keys.

WARNING

If you're using symmetric keys (HS256), key rotation is significantly harder. It requires distributing the new secret to all relying parties simultaneously and coordinating the switchover. This is a primary reason why asymmetric keys are preferred for distributed authorization. For HS256, consider short-lived tokens and a robust key distribution mechanism (e.g., HashiCorp Vault, AWS Secrets Manager).

Common Pitfalls and Attack Patterns

JWTs are powerful, but their misuse is rampant, leading to severe vulnerabilities. Here are the "I learned this the hard way" moments and common attack vectors.

"None" Algorithm Vulnerability (CVE-2015-9235)

This is the most infamous JWT vulnerability. If a validation library allows the alg header to be set to "none" and then skips signature verification, an attacker can craft any payload, change the alg to "none", strip the signature, and the token will be accepted.

How it works:

  1. Attacker gets a valid token.
  2. Attacker decodes the token, modifies claims (e.g., changes sub to admin, roles to admin).
  3. Attacker changes the header to {"alg": "none", "typ": "JWT"}.
  4. Attacker removes the signature part.
  5. If the verifier library isn't explicitly configured to only accept specific algorithms, it might see alg: "none" and assume no signature verification is needed, thus accepting the tampered token.

CAUTION

Always specify a whitelist of allowed algorithms in your JWT verification library. Never let the library infer the algorithm from the token header. This is a fundamental guardrail.

Key Confusion / Algorithm Confusion (CVE-2016-10555)

This attack exploits a weakness where an attacker can force a verifier to use a symmetric key (HS256) instead of an asymmetric public key (RS256) for verification.

How it works:

  1. An IdP uses RS256 with a private key to sign tokens, and publishes the corresponding public key in a JWKS.
  2. An attacker obtains this public key.
  3. The attacker crafts a malicious JWT, sets alg: "HS256", and signs it using the IdP's public key as the symmetric secret.
  4. If the verifier dynamically fetches the public key from JWKS but then uses it as a symmetric secret when alg: "HS256" is specified in the tampered token, the token will validate successfully.

This vulnerability often arises when a library's verify function is too flexible, using the same key material for both symmetric and asymmetric verification based solely on the alg header.

IMPORTANT

Your validation logic must strictly separate symmetric and asymmetric key usage. If alg is HS*, expect a symmetric secret. If alg is RS* or ES*, expect an asymmetric public key. Libraries like jsonwebtoken or node-jwks-rsa generally handle this correctly if configured properly by specifying allowed algorithms.

Signature Stripping / Header Tampering

Beyond the "none" algorithm, attackers might try to manipulate other header parameters. For example, if your system uses typ (type) to distinguish between different token types (e.g., JWT vs JWE), an attacker might try to change it. Your validation logic should be robust enough to handle unexpected or missing header fields, and only trust what's explicitly needed.

Replay Attacks

If a token is intercepted, an attacker can reuse it until it expires. This is a "replay attack."

  • Mitigation:
  • Short exp times: The shorter the token's lifetime, the smaller the window for replay. This is the primary and most effective mitigation.
  • jti (JWT ID) and Blacklisting: For critical operations, a unique jti claim can be used. When a token is used, its jti is added to a distributed blacklist (e.g., Redis). Subsequent attempts to use a token with the same jti are rejected. This adds statefulness and complexity but provides immediate revocation.
  • Refresh Token Rotation: When a refresh token is used to get a new access token, the old refresh token should be immediately invalidated. This limits the usability of a stolen refresh token.

Insecure Token Storage (XSS, CSRF)

Where you store JWTs on the client-side matters immensely.

  • localStorage: Highly susceptible to Cross-Site Scripting (XSS) attacks. If an XSS vulnerability exists, an attacker can steal the token, gain full access to the user's session, and perform actions on their behalf. This is a major footgun.
  • HttpOnly Cookies: Cookies marked HttpOnly are inaccessible to client-side JavaScript, significantly mitigating XSS risks for token theft.
  • Secure Cookies: Ensures cookies are only sent over HTTPS.
  • SameSite Cookies: Prevents Cross-Site Request Forgery (CSRF) by instructing browsers not to send cookies with cross-site requests. SameSite=Lax is a good default; SameSite=Strict offers more protection but can break legitimate cross-site links.

WARNING

Storing access tokens in localStorage is generally considered an anti-pattern due to XSS risks. Use HttpOnly, Secure, SameSite=Lax cookies for session tokens, or implement a robust refresh token strategy with short-lived access tokens exchanged via a backend-for-frontend (BFF) pattern.

Insufficient Claim Validation

Blindly trusting claims is a recipe for disaster.

  • Audience (aud) validation: As mentioned, if a service doesn't check if the token is for it, it could accept tokens intended for other services.
  • Issuer (iss) validation: Accepting tokens from any issuer can lead to accepting tokens from malicious IdPs.
  • Scope/Permissions: If your application makes authorization decisions based on scope or roles claims, ensure these claims are present and contain expected values. Don't assume.

Overly Long Expiration Times

Setting exp to days, weeks, or even months for an access token increases the attack window dramatically. If a token is stolen, the attacker has that much longer to use it. Access tokens should be short-lived (5-15 minutes). Use refresh tokens for long-term sessions.

Architecture and Implementation Considerations

Integrating JWTs securely requires careful architectural design, particularly in microservice environments.

Token Issuance: The IdP's Domain

The Identity Provider (IdP) is responsible for issuing JWTs. This typically adheres to the OpenID Connect (OIDC 1.0) specification, which is built on top of OAuth 2.1.

  • OIDC id_token: A JWT containing identity claims about the authenticated user. Primarily for the client application to know who the user is.
  • OAuth 2.1 access_token: Can be a JWT (Bearer token) or an opaque token. It grants access to protected resources (APIs). Primarily for the Resource Server to authorize what the client can do.

NOTE

An id_token is for authentication (proving identity to the client), while an access_token is for authorization (proving permission to the resource server). Don't use an id_token as an access_token for your APIs directly; it's bad practice and often lacks the necessary audience or scope for resource access.

Token Consumption: Resource Servers and API Gateways

Resource Servers (your APIs/microservices) are the consumers of JWTs. They must validate every incoming token.

Centralized Validation (API Gateway)

An API Gateway (e.g., Kong, AWS API Gateway, Nginx with nginx-jwt) is an excellent place for centralized JWT validation. It can:

  1. Fetch and cache JWKS.
  2. Perform signature and standard claim validation (iss, aud, exp).
  3. Inject validated claims into request headers for downstream microservices.
  4. Reject invalid tokens before they even hit your application logic.

Validation

1. Request with JWT
2. Extract KID
3. Get Public Key
4. Verify Signature & Claims
5. Forward Request (with validated claims)
5. Forward Request (with validated claims)
6. Authorize based on claims

Client

API Gateway (JWT Validator)

JWKS Cache

JWT Validation Logic

Microservice A

Microservice B

Data Store

Decentralized Validation (Microservices)

While an API Gateway handles initial validation, individual microservices should still perform fine-grained authorization based on the claims passed to them. This might involve:

  • Checking specific scope or roles claims.
  • Verifying sub (subject) or tenant_id claims against resource ownership.
  • Re-validating critical claims if the gateway's trust cannot be fully extended. (This is often bikeshedding, trust the gateway if it's done right).

Token Revocation: The Stateless Dilemma

JWTs are designed to be stateless, meaning once issued, they contain all necessary information and don't require database lookups for every validation. This is great for performance, but it means immediate revocation is hard.

What works for revocation:

  1. Short-lived Access Tokens + Refresh Tokens: This is the industry standard (OAuth 2.1). Access tokens have a short exp (e.g., 5-15 minutes). If stolen, their utility is limited. Refresh tokens are long-lived, stored securely, and are stateful – they can be revoked. When a refresh token is used, it should ideally be rotated (issued a new one, invalidated the old one).
  2. Distributed Blacklist (jti): For critical actions or immediate logout, jti claims can be added to a distributed, fast cache (like Redis) upon revocation. All subsequent token validations check this blacklist. This adds state, but provides immediate revocation.
  3. Changing Signing Keys: A blunt instrument. Changing the IdP's signing key invalidates all currently issued tokens signed with the old key, forcing re-authentication. This is disruptive and usually reserved for emergencies.

Advanced Topics and What Works

Refresh Tokens: The Unsung Hero

Refresh tokens are the glue that holds secure, long-lived JWT-based sessions together. They address the inherent conflict between short-lived access tokens (good for security) and user experience (long sessions).

How it works:

  1. Client authenticates with IdP, receives a short-lived access_token and a long-lived refresh_token.
  2. Client uses access_token for API calls.
  3. When access_token expires, client uses refresh_token to get a new access_token (and potentially a new refresh_token for rotation) from the IdP.

Related Topics

JWT securityJWT vulnerabilitiesJWT validationJWT attack patternsIAM JWT securityJSON Web Token security

Found this helpful?

Share it with your network