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:
- 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
- Claim-carrying — user identity, roles, expiry, and audience are embedded in the payload in a standardised format (RFC 7519 registered claims)
- 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:
- Parse the header to extract
alg— but do NOT trustalgto select the verification algorithm; use only the pre-configured algorithm allowlist - Verify the signature — if the signature does not match, reject immediately; do NOT inspect claims on a token with an invalid signature
- 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
| Algorithm | Key type | Key distribution | Use when |
|---|---|---|---|
| RS256 | RSA 2048+ key pair | Public key via JWKS endpoint | Default choice; public key is shareable; compatible with all IdPs and most JWT libraries |
| ES256 | ECDSA P-256 key pair | Public key via JWKS endpoint | Prefer over RS256 for new systems; shorter keys (256-bit vs 2048-bit) with equivalent security; faster verification |
| EdDSA (Ed25519) | Ed25519 key pair | Public key via JWKS endpoint | Best performance and smallest keys; use when all parties support it (IdP + library versions permitting) |
| HS256 | Shared secret | Secret must be shared with every verifier | Single-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
| Dimension | ID Token | Access Token |
|---|---|---|
| Purpose | Identity assertion — who the user is | Authorization — what the user can do |
| Audience | The client application (client_id) | The resource server (API) |
| Claims | sub, email, name, picture, nonce (OIDC standard) | sub, scope, aud, custom roles/permissions |
| Who validates it | The client that initiated the OIDC flow | The resource server (API) receiving the request |
| Passed to APIs | Never — ID token is for the client only | Yes — in Authorization: Bearer header |
| Spec | OpenID Connect Core 1.0 | RFC 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.
Related Concepts
| Concept | Relationship |
|---|---|
| OAuth2-OIDC-Flows | OAuth2/OIDC grants produce the JWTs validated by this note |
| RBAC-ABAC | JWT claims (roles, groups) are the input to RBAC/ABAC authorization evaluation |
| Session-Management | JWT as stateless session token — trade-offs against server-side sessions |
| Token-Relay-Pattern | How JWTs are forwarded from BFF to downstream resource servers |