JSON Web Token (JWT)
JSON Web Tokens (JWTs) are compact, signed tokens used to securely transmit user identities and claims between systems. JWTs serve as the foundation for modern API authentication and single sign-on—but if implemented incorrectly, they can be a common security vulnerability.
JSON Web Tokens (JWT) are an open standard (RFC 7519) for the secure exchange of information as JSON objects. They are used by OAuth 2.0 and OpenID Connect as ID tokens and access tokens—and are also one of the most common sources of authentication bugs in modern web applications.
JWT Structure - Header.Payload.Signature
A JWT consists of three Base64URL-encoded parts separated by dots. The following example shows a decoded JWT:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxMTQ4MDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The header (after Base64URL decoding) contains the algorithm used:
{
"alg": "RS256",
"typ": "JWT"
}
The payload (after Base64URL decoding) contains the claims:
{
"sub": "user_123",
"iss": "https://auth.firma.de",
"aud": "api.firma.de",
"iat": 1711393600,
"exp": 1711480000,
"role": "admin",
"email": "max@firma.de"
}
The signature is calculated as:
RSASHA256(base64url(header) + "." + base64url(payload), privateKey)
> Important: The payload is ONLY Base64URL-encoded, not encrypted. Anyone can decode and read the payload. The signature prevents tampering, but not reading. Do not include sensitive data such as passwords or Social Security numbers in the payload.
JWT in Practice: Issuance and Validation
Login Flow with JWT
The process begins with the client sending credentials to the auth server. The server verifies the credentials, creates a signed JWT, and responds with an access token, refresh token, and the expiration time (expires_in: 3600).
API Request with JWT
The client sends the token in the Authorization: Bearer header. The API server validates four points: check signature, exp > now, iss == known, and aud == this API.
The validation in Node.js looks like this:
import { verify } from 'jsonwebtoken';
function validateJWT(token: string): JWTPayload {
try {
const payload = verify(token, publicKey, {
algorithms: ['RS256'], // Only RS256 or ES256!
issuer: 'https://auth.firma.de',
audience: 'api.firma.de',
});
return payload as JWTPayload;
} catch (err) {
throw new UnauthorizedError('Invalid token');
}
}
JWT Algorithms - Critical Comparison
Symmetric Algorithms (HMAC)
| Algorithm | Properties | Problem |
|---|---|---|
| HS256 | Same key for signing and validation | Auth server and API must share the key; any API server that validates could also issue JWTs |
| HS512 | Stronger than HS256 | Same fundamental problem |
HMAC algorithms are only secure in single-service setups where only one component performs validation.
Asymmetric Algorithms (Recommended)
| Algorithm | Properties | Recommendation |
|---|---|---|
| RS256 (RSA + SHA256) | Private key only on auth server, public key on all API servers via JWKS | Standard for OAuth 2.0 / OIDC |
| ES256 (ECDSA + SHA256) | Elliptic curves, smaller keys, same security as RS256, faster than RSA | Recommended for new systems |
DANGEROUS - Never use
alg: "none" (unsigned JWT): A JWT without a signature. Some libraries accept this—this is the biggest historical JWT vulnerability. Attackers change the header to "alg":"none" and manipulate the payload at will.
Algorithm Confusion (alg: "HS256" when RS256 is expected): An attacker signs using the public key as the HS256 secret. If the server reads the alg header without validation, the token is accepted as valid. Fix: Always hardcode the algorithm in the code; never read it from the header.
JWT Security Vulnerabilities
1. Algorithm Confusion (alg-Swap)
The attacker changes the header from {"alg": "RS256"} to {"alg": "HS256"} and signs using the publicly available RS256 public key as the HMAC secret. The server reads the alg header and verifies it using the public key as the HS256 secret—the token is considered valid, and the attacker can set any claims.
Fix: Hardcode the algorithm:
verify(token, publicKey, { algorithms: ['RS256'] }) // NEVER ['RS256', 'HS256']!
2. "none" Algorithm
Tokens with alg: "none" do not require a signature; the payload can be manipulated arbitrarily (e.g., role: "admin").
Fix: Check the library—verify() must enforce an algorithm list.
3. Missing expiration (exp)
A JWT without an exp claim or with a very long validity period remains permanently valid after being stolen.
Fix: Always set exp; maximum 1 hour for access tokens, maximum 7–30 days for refresh tokens.
4. Signature not validated
Some APIs only check if a token is present, not if the signature is correct—decode is not the same as verify.
- Wrong:
jwt.decode(token)—only decodes, no signature verification - Correct:
jwt.verify(token, publicKey)
5. Sensitive data in the payload
Since the JWT payload is only Base64URL-encoded, password hashes, Social Security numbers, or account numbers are visible to anyone. The payload should only contain non-sensitive claims that are necessary for authentication decisions.
6. Token in localStorage (browser)
Data in localStorage is readable by JavaScript—an XSS attack can steal the token.
Better: httpOnly Secure Cookie (inaccessible to JavaScript). For SPAs: In-memory storage plus a refresh token in an httpOnly cookie.
7. Missing Audience (aud) Check
A token for Service A (aud: "service-a") is sent to Service B, which accepts it—this is called token substitution.
Fix: Always explicitly check the aud claim; specify it in verify().
JWKS - Dynamically loading public keys
JWKS (JSON Web Key Set) is the standard for public key distribution. The auth server publishes its public key at GET /.well-known/jwks.json:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key-2024-01",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2...",
"e": "AQAB"
}
]
}
The API server loads the keys on startup and caches them:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.firma.de/.well-known/jwks.json')
);
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.firma.de',
audience: 'api.firma.de',
});
Key Rotation: The auth server can rotate keys—the kid claim in the JWT header identifies which key to use. Old tokens remain valid as long as exp has not expired. The JWKS endpoint provides new keys; API servers cache keys with, for example, a 10-minute TTL.
JWT Lifetime and Refresh Token Strategy
Recommended Lifetimes
| Token | Duration | Reason |
|---|---|---|
| Access Token | 15–60 minutes | Short—if stolen, quickly becomes worthless; no revocation issue |
| Refresh Token | 7–30 days | For renewing access tokens; stored securely |
Refresh Token Rotation
With every access token refresh, a new refresh token is issued and the old one is invalidated. This enables the detection of token theft: If a stolen refresh token is used while the legitimate user still recognizes the old token as active, the system detects two active refresh tokens and revokes both.
POST /auth/refresh
Cookie: refresh_token=eyJ...
Response:
{ "access_token": "eyJ...", "expires_in": 900 }
Set-Cookie: refresh_token=eyJ...; HttpOnly; Secure; SameSite=Strict
JWT vs. Session Tokens - When to Use Which?
| Property | JWT | Session Token |
|---|---|---|
| State | Stateless (API-validated) | Stateful (DB lookup) |
| Revocation | Difficult (until exp!) | Immediate (delete token from DB) |
| Scalability | Easy (no DB hit) | DB access with every request |
| Size | ~400–1000 bytes | 32 bytes (ID only) |
| Payload | Claims directly in the token | Claims in the session table |
When to use JWT
- Microservices (Service B validates without querying Service A)
- API ecosystem with multiple audiences
- OAuth 2.0 / OIDC (standard defines JWT)
When to use session tokens
- Traditional web apps with server-side rendering
- Immediate revocation required (e.g., "Account locked")
- Smaller data volume per request desired
- Simpler implementation preferred
Hybrid approach (recommended for web apps)
- Access Token: short-lived JWT (15 min)
- Session:
httpOnlycookie with refresh token - Revocation via refresh token database