JWT (JSON Web Token)

JWT (JSON Web Token)

A JWT is a compact, self-contained token — encoded as three Base64URL segments separated by dots — where the header declares the algorithm, the payload carries claims, and the signature cryptographically binds both, allowing the holder to verify authenticity and integrity without contacting the issuing server.


Core Idea

JWTs enable three things simultaneously:

  1. Stateless verification — any party with the public key (or shared secret) can verify the token locally; no introspection call to the IdP is needed at scale
  2. Claim-carrying — user identity, roles, expiry, and audience are embedded in the payload in a standardised format (RFC 7519 registered claims)
  3. Tamper detection — the signature covers header + payload; any modification to either invalidates the signature

JWT Structure

A JWT looks like this:

eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwiZXhwIjoxNzExMDAwMDAwfQ.SIGNATURE

Three segments, separated by dots:

  • Header: {"alg":"RS256"} — declares the algorithm (always check this first — see alg:none attack)
  • Payload: {"sub":"user123","exp":1711000000,...} — carries claims (verify AFTER signature)
  • Signature: RSASSA-PKCS1-v1_5 over Base64URL(header) + "." + Base64URL(payload)

Each segment is Base64URL-encoded (not Base64). The payload is readable by anyone who possesses the token — it is not encrypted unless the JWT is also a JWE.


When NOT to Use

  • Do not use HS256 in multi-party systems — HS256 uses a shared secret; any party that can validate can also forge tokens; use RS256/ES256/EdDSA where only the issuer holds the private key
  • Do not store sensitive data in the payload — JWTs are Base64-encoded, not encrypted (unless using JWE); the payload is readable by anyone who possesses the token; put user ID and roles in claims, not SSNs or passwords
  • Do not use a JWT as a session token for long-lived browser sessions without a revocation strategy — JWTs are stateless; a compromised token is valid until expiry; for revocation, use short expiry (≤15 min) + refresh token rotation, or maintain a short-lived denylist
  • Do not skip the audience (aud) claim check — a token issued for Service A can be replayed at Service B if aud is not verified; each resource server must validate aud matches its own identifier
  • Do not use JWT for storing state that changes frequently — embedding frequently-changing user roles in a long-lived JWT means stale claims; use short expiry or opaque session tokens for mutable state

How It Works

The alg:none Attack

The alg:none attack exploits a specific failure mode in JWT libraries: an attacker crafts a token with {"alg":"none"} in the header and omits the signature entirely (the token ends with a trailing dot: header.payload.). Some naive implementations read alg from the JWT header itself, then skip verification entirely when alg is "none" — accepting the forged token as valid.

This is not an obscure edge case. It was a real vulnerability in multiple JWT libraries (including node-jsonwebtoken before 4.2.2 and others). The attack requires no cryptographic capability — any attacker who can craft a JSON object can forge a token.

Prevention is straightforward: the verifying library MUST have an explicit allowlist of acceptable algorithms defined at library initialization time, NEVER derived from the token header. The token's alg header is untrusted input. With jose: jwtVerify(token, key, { algorithms: ['RS256'] }). With jjwt: Jwts.parser().verifyWith(key).build() — jjwt 0.12.x derives the allowed algorithm from the key type, rejecting alg:none automatically.

Validation Order (Mandatory: Signature Before Claims)

RFC 7519 Section 7.2 and RFC 7515 Section 5.2 specify this order:

  1. Parse the header to extract alg — but do NOT trust alg to select the verification algorithm; use only the pre-configured algorithm allowlist
  2. Verify the signature — if the signature does not match, reject immediately; do NOT inspect claims on a token with an invalid signature
  3. Validate claims — in order: exp (not expired), nbf (not-before, if present), iss (issuer matches expected), aud (audience matches this service's identifier)

Spec compliance warning: Reversing this order — checking claims before signature — is a spec inaccuracy per RFC 7519 Section 7.2. A claims-first implementation could be exploited by an attacker who crafts valid-looking claims with an invalid signature. Those claims would pass a claims check before failing the signature check, potentially leaking information about valid claim structures or allowing timing-based attacks.

Algorithm Selection

AlgorithmKey typeKey distributionUse when
RS256RSA 2048+ key pairPublic key via JWKS endpointDefault choice; public key is shareable; compatible with all IdPs and most JWT libraries
ES256ECDSA P-256 key pairPublic key via JWKS endpointPrefer over RS256 for new systems; shorter keys (256-bit vs 2048-bit) with equivalent security; faster verification
EdDSA (Ed25519)Ed25519 key pairPublic key via JWKS endpointBest performance and smallest keys; use when all parties support it (IdP + library versions permitting)
HS256Shared secretSecret must be shared with every verifierSingle-party systems only; never use when multiple independent services verify tokens

RS256 is the safe default for existing systems and maximum IdP compatibility. Prefer ES256 or EdDSA for new systems where the IdP supports them — shorter keys reduce JWKS response size and signature overhead.

ID Token vs Access Token

DimensionID TokenAccess Token
PurposeIdentity assertion — who the user isAuthorization — what the user can do
AudienceThe client application (client_id)The resource server (API)
Claimssub, email, name, picture, nonce (OIDC standard)sub, scope, aud, custom roles/permissions
Who validates itThe client that initiated the OIDC flowThe resource server (API) receiving the request
Passed to APIsNever — ID token is for the client onlyYes — in Authorization: Bearer header
SpecOpenID Connect Core 1.0RFC 6749, RFC 9700

A common mistake is forwarding the ID token to APIs instead of the access token. The resource server must only receive and validate the access token; forwarding the ID token to a resource server violates the OIDC specification and may expose user identity claims to systems that should only see authorization scope.

Mermaid Validation Flowchart

flowchart TD
    A[Receive JWT] --> B[Parse header — extract alg claim]
    B --> C{alg in server's\nallowlist?}
    C -- No --> REJECT1[Reject: algorithm not allowed\nalg:none attack prevention]
    C -- Yes --> D[Verify signature\nusing pre-configured key]
    D --> E{Signature valid?}
    E -- No --> REJECT2[Reject: tampered or forged token]
    E -- Yes --> F[Check exp claim\nnot expired]
    F --> G{Expired?}
    G -- Yes --> REJECT3[Reject: token expired]
    G -- No --> H[Check iss and aud claims]
    H --> I{iss and aud\nmatch expected?}
    I -- No --> REJECT4[Reject: wrong issuer or audience]
    I -- Yes --> ACCEPT[Accept: token valid]

TypeScript Example

// jose 6.2.2 — ESM-native, Web Crypto standard; use instead of legacy jsonwebtoken
import { createRemoteJWKSet, jwtVerify } from 'jose'
import type { JWTPayload } from 'jose'
 
/**
 * Verifies an OAuth2 access token using the IdP's JWKS endpoint.
 * Signature is verified BEFORE claims per RFC 7519 Section 7.2.
 */
async function verifyAccessToken(
  token: string,
  jwksUri: string,
  expectedAudience: string
): Promise<JWTPayload> {
  // createRemoteJWKSet caches and auto-rotates keys; no manual key management needed
  const JWKS = createRemoteJWKSet(new URL(jwksUri))
 
  const { payload } = await jwtVerify(token, JWKS, {
    // NEVER omit algorithms — required to prevent alg:none attack
    algorithms: ['RS256', 'ES256'],
    audience: expectedAudience,
  })
 
  // jose verifies: signature → exp → nbf → iss (if issuer option set) → aud
  // Any failure throws JWTExpired, JWTClaimValidationFailed, or JWSSignatureVerificationFailed
  return payload
}

Java Example

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import org.springframework.stereotype.Component;
 
import java.security.PublicKey;
 
/**
 * Validates OAuth2 access tokens using jjwt-api 0.12.x.
 * Signature is verified BEFORE claims — enforced by the library.
 */
@Component
public class JwtValidator {
 
    private final PublicKey publicKey;
 
    public JwtValidator(PublicKey publicKey) {
        this.publicKey = publicKey;
    }
 
    /**
     * Validates the access token and returns its claims.
     * Throws JwtException (mapped to 401) for any validation failure.
     */
    public Claims validateAccessToken(String token) {
        try {
            // jjwt 0.12.x: verifyWith() enforces algorithm from key type — alg:none is rejected automatically
            return Jwts.parser()
                .verifyWith(publicKey)
                .requireAudience("my-service")
                .build()
                .parseSignedClaims(token)
                .getPayload();
        } catch (JwtException e) {
            // JwtException covers expired, invalid signature, wrong aud — all mapped to 401
            throw new UnauthorizedException("Invalid token: " + e.getMessage(), e);
        }
    }
}

For Spring Security resource server auto-configuration (JWKS endpoint discovery, per-request validation), see Token-Relay-Pattern.


ConceptRelationship
OAuth2-OIDC-FlowsOAuth2/OIDC grants produce the JWTs validated by this note
RBAC-ABACJWT claims (roles, groups) are the input to RBAC/ABAC authorization evaluation
Session-ManagementJWT as stateless session token — trade-offs against server-side sessions
Token-Relay-PatternHow JWTs are forwarded from BFF to downstream resource servers