// structure · signing algorithms · validation · attacks · refresh tokens · JWKS · senior → principal
header.payload.signature.
Header: JSON object declaring the token type and signing algorithm. {"alg":"RS256","typ":"JWT"}
Payload: JSON object containing claims — statements about the subject. Claims are not encrypted; anyone who holds the token can read them. {"sub":"user-123","iss":"auth.example.com","exp":1716000000}
Signature: cryptographic proof that the header and payload were not tampered with. For RS256: RSA_SIGN(base64url(header) + "." + base64url(payload), private_key). A valid signature proves the token was issued by the party holding the signing key.
iss (issuer): who created the token — validate against expected issuer - sub (subject): who the token is about — the user or entity ID - aud (audience): who the token is intended for — validate against your service ID - exp (expiration): Unix timestamp after which the token is invalid — always validate - nbf (not before): token not valid before this time - iat (issued at): when the token was created - jti (JWT ID): unique identifier — use for one-time tokens and revocation lists
Public claims: registered in the IANA JWT Claims Registry to avoid collisions.
Private claims: custom claims agreed between parties — role, scope, tenant_id. Keep payloads small; tokens are sent on every request.
Every JWT consumer must validate all of the following — skipping any one opens an attack vector:
1. Algorithm: confirm alg header matches what you expect. Reject none. Never
accept an algorithm you didn't configure.
2. Signature: verify using the correct key for the declared algorithm. 3. Expiry (exp): reject tokens past their expiry. Allow a small clock skew (≤30 s). 4. Not-before (nbf): reject tokens not yet valid. 5. Issuer (iss): reject tokens from unexpected issuers. 6. Audience (aud): reject tokens not intended for your service. This prevents a
token issued for Service A from being used at Service B.
Use a well-maintained JWT library — do not implement validation by hand. Pass the expected algorithm list, issuer, and audience as explicit configuration parameters.
alg: none: attacker strips the signature and sets alg to none. A naive library skips verification. Fix: whitelist allowed algorithms explicitly; never accept none.
Algorithm confusion (RS256 → HS256): if a library allows the client to choose the algorithm, an attacker sets alg: HS256 and signs with the server's public key as the HMAC secret (public keys are public). The server verifies the HMAC using the public key — and it matches. Fix: pin the expected algorithm server-side, never trust the header's alg.
Weak HMAC secret: HS256 secrets can be brute-forced offline if the attacker captures a token. A 10-character alphanumeric secret can be cracked in minutes with hashcat. Fix: use cryptographically random secrets of ≥256 bits, or switch to RS256/ES256.
Missing aud validation: a token for payments-api is accepted by admin-api because neither validates aud. Fix: configure expected audience on every service.
JWT in localStorage (XSS): JavaScript can read localStorage, so any XSS on your domain steals the token. Fix: store in httpOnly; Secure; SameSite=Strict cookies.
exp. There is no built-in revocation mechanism — the server validates the signature and expiry, not a database. Logout, password change, and permission revocation don't invalidate outstanding tokens unless you maintain a server-side denylist (a Redis set of revoked jti values). This reintroduces statefulness — exactly what JWT was supposed to eliminate. The practical answer is short access token TTLs (15 min) so the revocation window is bounded, combined with long-lived refresh tokens that can be revoked server-side.
nbf = now is rejected as "not yet valid." Conversely, lax skew (>5 min) widens the window for replay attacks. Synchronize all clocks via NTP and allow a small, explicit tolerance (30 s) in your validation logic. Most libraries support a configurable clockSkewSeconds parameter.
SameSite=Strict (or Lax) on the cookie, which blocks cross-site requests from sending the cookie. For APIs consumed by single-page apps where SameSite isn't sufficient, use the Double Submit Cookie pattern or a separate CSRF token. Never rely on JWT presence alone as a CSRF defense.
| HS256 / HS384 / HS512 | HMAC-SHA. Symmetric — same secret signs and verifies. Fast. All verifiers must hold the secret. Secret compromise = all tokens forgeable. Use only in single-service or closed systems. |
| RS256 / RS384 / RS512 | RSA-PKCS1v15. Asymmetric — private key signs, public key verifies. 2048-bit minimum key. Larger tokens (~340 bytes signature). Publish public key via JWKS. Industry standard for OAuth2. |
| PS256 / PS384 / PS512 | RSA-PSS. Same key size as RS256 but probabilistic padding — more secure than PKCS1v15. Preferred over RS256 for new systems. Same JWKS distribution model. |
| ES256 / ES384 / ES512 | ECDSA. Asymmetric. Much smaller keys and signatures than RSA (~64 bytes for ES256 vs ~256 bytes for RS256). Same security level. Preferred when token size matters. P-256 / P-384 / P-521 curves. |
| EdDSA (Ed25519) | Edwards-curve DSA. Fastest signing and verification. Smallest signatures. Not yet universally supported in all libraries but increasingly common. Strong security properties. |
| none | No signature. NEVER accept. Attacker can forge any claims. Always whitelist algorithms explicitly and reject 'none'. |
| iss (Issuer) | Who issued the token. Validate against your expected auth server URL. Reject if missing or unexpected. |
| sub (Subject) | Who the token represents — typically a user ID or service ID. Stable, unique identifier. |
| aud (Audience) | Intended recipient. Must match your service identifier. Prevents token reuse across services. Often skipped — don't. |
| exp (Expiration) | Unix timestamp. Reject tokens past this time. Apply a small clock skew tolerance (≤30 s). |
| nbf (Not Before) | Token not valid before this time. Allows issuing tokens that activate in the future. |
| iat (Issued At) | When the token was created. Used to determine token age and enforce maximum lifetime independent of exp. |
| jti (JWT ID) | Unique token ID. Use in a denylist for revocation, or to prevent replay of one-time tokens. |
| alg: none | Strip signature, set alg=none. Naive libraries skip verification. Fix: whitelist expected algorithms; never accept none. |
| Algorithm confusion | Switch RS256 to HS256, sign with the server's public key as HMAC secret. Fix: ignore alg header; pin expected algorithm in server config. |
| Weak HMAC secret | Offline brute-force of captured tokens with hashcat. Fix: 256-bit random secret; or use RS256/ES256. |
| Missing aud validation | Token for service A accepted by service B. Fix: validate aud on every service; configure expected audience explicitly. |
| JWT in localStorage | XSS steals token. Fix: httpOnly; Secure; SameSite cookie. |
| Replay attack | Captured valid token reused after the user logs out. Fix: short exp + jti denylist on revocation; refresh token rotation. |
| Key confusion (kid injection) | Attacker forges kid header to point to an attacker-controlled key. Fix: validate kid against a trusted JWKS endpoint; never fetch arbitrary URLs from the kid claim. |
| JWK embedded in header | Some parsers trust a jwk or x5c header embedded in the token itself to verify the signature. Fix: only trust keys from your pre-configured JWKS endpoint. |
| JWT (self-contained) | Stateless — no server lookup on validation. Instant horizontal scale. Cannot be revoked without a denylist. Payload visible. Best for service-to-service, OAuth2 access tokens. |
| Opaque token | Random string. Server looks up the token in a database/cache on every request. Instantly revocable. No information leakage. Higher per-request latency. Best for user sessions needing instant revocation. |
| Session cookie | Server stores session state. httpOnly cookie on client. Instant revocation. CSRF risk (mitigate with SameSite). Simple for traditional web apps. Shared session store needed for horizontal scale. |
| PASETO (Platform-Agnostic SE Token) | Safer alternative to JWT. No algorithm agility — each version fixes the algorithm (v4: Ed25519 for public, XChaCha20-Poly1305 for local). No 'none' attack possible. Gaining adoption but less tooling than JWT. |
| Dimension | JWT | Server Session | PASETO v4 | SAML 2.0 |
|---|---|---|---|---|
| Format | JSON, Base64URL, dot-separated | Opaque ID in cookie; server holds state | Binary / JSON, version-tagged | XML, signed and optionally encrypted |
| Stateless | Yes — server validates signature only | No — server looks up session store | Yes — same model as JWT | No — SP validates with IdP |
| Revocation | Hard — requires denylist or short TTL | Instant — delete session from store | Hard — same as JWT | Possible via session management |
| Algorithm agility | Yes — negotiated via alg header (attack surface) | N/A | No — fixed per version (safer) | XML-DSig (RSA SHA-256 standard) |
| Payload visibility | Visible (Base64 decoded) | Hidden on server | Visible (public token) or encrypted (local) | Visible in XML |
| Standard | RFC 7519 | No RFC; implementation-specific | paseto.io spec | OASIS SAML 2.0 |
| Use case | API auth, OAuth2, service-to-service | Traditional web apps, instant revocation | Same as JWT, safer algorithm model | Enterprise SSO, ADFS, legacy IdPs |
| Tooling | Excellent — every language has libraries | Built into most web frameworks | Growing — limited compared to JWT | Mature but complex XML tooling |
| Size | ~200–500 bytes typical | 32-byte session ID in cookie | Similar to JWT | 1–10 KB XML blob |
header.payload.signature.
Header: a JSON object Base64URL-encoded. Declares the token type (typ: JWT) and the signing algorithm (alg: RS256). The kid (key ID) field optionally identifies which key to use for verification. {"alg":"RS256","typ":"JWT","kid":"2024-key-1"}
Payload: a JSON object Base64URL-encoded containing claims. Registered claims (iss, sub, aud, exp, iat, jti) plus any custom claims the application needs. {"sub":"user-abc","iss":"https://auth.example.com","aud":"payments-api","exp":1716000000,"roles":["user"]}
Signature: for RS256 — RSA_SIGN(SHA256(base64url(header) + "." + base64url(payload)), private_key), then Base64URL-encoded. Changing any character in the header or payload invalidates the signature.
Base64URL differs from standard Base64: uses - instead of +, _ instead of /, and omits = padding. This makes the token URL-safe for query parameters and HTTP headers.
Key point: Base64URL is an encoding, not encryption. The header and payload are trivially decoded. The signature proves integrity and authenticity — not confidentiality.kid header field is important for key rotation. A verifier that receives a token with kid: 2024-key-2 fetches the matching key from the JWKS endpoint and uses it for verification. This allows the auth server to rotate keys (publish a new key alongside the old one) without breaking existing tokens — verifiers automatically select the right key by kid. Without kid, a key rotation requires all verifiers to update their key configuration simultaneously, which is operationally painful in a distributed system.alg header from the token. An attacker takes an RS256-signed token, changes the header to alg: HS256, re-signs it using the server's public key as the HMAC secret, and submits it. A library that honors the header's alg field will verify the HMAC using the public key — and it succeeds, because the attacker used that same key to sign. This is why you must always configure the expected algorithm server-side and never let the token's header determine what algorithm is used for verification.alg — verify it matches your expected algorithm. Skipping enables the alg: none attack (no signature) or algorithm confusion attack (RS256 → HS256 with public key).
Signature — verify the cryptographic signature using the correct key. Skipping means any forged token with valid-looking claims is accepted.
exp (expiration) — reject tokens past their expiry. Skipping means stolen tokens are valid forever — no time-bounded window for revocation.
nbf (not before) — reject tokens not yet active. Skipping allows pre-issued tokens to be used before their intended activation time.
iss (issuer) — verify the token was issued by your trusted auth server. Skipping allows tokens issued by an attacker-controlled server to be accepted if the attacker can produce a valid signature (possible if they have a valid key pair and you don't validate the issuer).
aud (audience) — verify the token is intended for your service. Skipping enables token substitution: a token issued for billing-api is accepted by admin-api. This is the most commonly skipped validation and enables privilege escalation if different services have different authorization levels.
Always configure issuer, audience, and allowed algorithms explicitly in your JWT library rather than validating them manually.payment-api tokens grant write access to payment records and analytics-api tokens grant read-only access, a stolen payment-api token presented to analytics-api (without aud validation) is accepted with full read access — or worse, if the analytics service's token is stolen and presented to the payment API. Each service should validate aud against its own identifier, ensuring tokens are scoped to their intended consumer.alg: none attack: The JWT spec originally allowed alg: none to indicate an unsigned token. Some early libraries honored this — if alg was none, they skipped signature verification entirely. An attacker decodes a valid token, modifies the payload (escalates privileges, changes user ID), sets alg: none in the header, strips the signature, and the server accepts it as valid.
Algorithm confusion attack: RS256 uses a private key to sign and a public key to verify. If a library accepts the algorithm from the token header (rather than from server configuration), an attacker can set alg: HS256 in a modified token. The server then tries to verify an HMAC-SHA256 signature. The attacker signed the token using the server's public key as the HMAC secret. Since public keys are public, the attacker knows the value, and the server's HMAC verification succeeds.
Prevention: - Whitelist algorithms: configure your JWT library with an explicit list of accepted
algorithms (["RS256"] or ["ES256"]). Reject tokens whose alg header isn't in the list.
- Never accept none: explicitly exclude it from the allowed list. - Never use the token header to select the verification key type: the server decides
the algorithm; the token's alg field is only used to confirm it matches.
- Use a reputable, well-maintained library — these attacks target hand-rolled or naive implementations.alg header to manipulate. This is the correct long-term solution; JWT's algorithm agility is a design flaw that requires defensive coding to work around. For systems using JWT, the defense is always "configure, don't negotiate" — configure the expected algorithm in your validator, and treat a mismatch as an attack, not a negotiation.localStorage / sessionStorage: - Accessible by JavaScript on the same origin. - Any XSS vulnerability on your site can steal the token — document.cookie is off-limits
to JavaScript, but localStorage is not.
- No CSRF risk (browser doesn't automatically send localStorage to cross-site requests). - Not recommended for access tokens with any meaningful lifetime.
httpOnly; Secure; SameSite=Strict cookie: - Cannot be accessed by JavaScript — XSS cannot read it. - Automatically sent by the browser on same-site requests. - CSRF risk exists if SameSite is not set or is set to None. - SameSite=Strict: cookie not sent on cross-site requests at all — strongest CSRF protection. - SameSite=Lax: cookie sent on top-level navigations (clicking a link) but not on
sub-resource requests — good balance for most apps.
- Recommended for session tokens and refresh tokens.
In-memory (JavaScript variable): - XSS cannot exfiltrate it (no persistent storage). - Lost on page refresh — poor UX for access tokens unless paired with a silent refresh flow. - Some SPAs store access tokens in memory and use httpOnly cookies for refresh tokens./.well-known/jwks.json) containing all current public keys used to verify tokens.
json {
"keys": [
{
"kty": "RSA",
"kid": "2024-key-1",
"use": "sig",
"alg": "RS256",
"n": "<modulus-base64url>",
"e": "AQAB"
}
]
}
Verification flow: 1. Service receives a JWT. Reads kid from the header. 2. If kid is not in the local key cache, fetches the JWKS endpoint. 3. Finds the key with the matching kid. 4. Reconstructs the public key from the JWKS parameters. 5. Verifies the JWT signature using that key. 6. Caches the JWKS response (respect Cache-Control headers) to avoid fetching on every request.
Key rotation: the auth server publishes a new key alongside the old one. Old tokens (signed with the old key, referencing it via kid) continue to verify. New tokens use the new key. Once all old tokens expire, the old key is removed from JWKS.kid is encountered (cache miss triggers a refresh). The failure mode to avoid: caching JWKS indefinitely — after a key rotation, the old key is removed from JWKS, but your cache still has it. All tokens signed with the new key fail verification until the cache expires. Implement: cache hit → use cached key; cache miss (unknown kid) → fetch JWKS once more, then fail if still not found.jti denylist in a shared cache (Redis): On logout or account compromise, write the jti of all outstanding tokens to a Redis set with TTL matching the token's remaining validity. Every service checks the denylist on validation. Reintroduces network dependency but allows instant revocation. Scale challenge: the denylist must be accessible to all services; Redis must be highly available.
3. Token version in a fast store: Store a token_version per user in Redis. Embed the version in the JWT claim. On validation, check that the claim version matches the current version. On logout/password change, increment the version — all outstanding tokens with older versions are instantly invalid. One Redis lookup per request, but only one key per user.
4. Introspection endpoint: OAuth2 RFC 7662 — verifying service calls the auth server's introspection endpoint on every request. Instant revocation, full server-side control. Highest latency and coupling; auth server becomes a synchronous dependency. Suitable for high-value operations (banking, healthcare) where instant revocation outweighs the cost.
In practice: most systems use short TTLs (option 1) for access tokens and a denylist or version check (option 2/3) for the rare cases that require instant revocation.exp time — entries self-expire when the token would have expired anyway. Monitor denylist size and Redis memory; a spike in revocations (e.g., after a breach announcement) can cause unexpected Redis growth. The token version approach (option 3) is more scalable for mass revocation events (force-logout all users after a breach) — one write per user instead of one write per outstanding token.scope, role, tenant_id): encode what the token broadly allows. Keep the payload small — tokens are sent on every request. Avoid embedding fine-grained permissions (individual resource IDs) in the token.
Fine-grained authorization at the service boundary: each service enforces its own policy using the token's claims. A scope: payments:write claim in the token means the auth server has delegated that permission; the payments service enforces whether that scope allows the specific operation on the specific resource.
Service-to-service tokens: use short-lived tokens issued via a machine identity (mTLS client cert → token exchange, or Vault's JWT auth method). These tokens carry a sub of the calling service (e.g., sub: order-service) and a narrow scope. Services should not forward user tokens to other services — they should exchange for a service-scoped token.
Token exchange (RFC 8693): allows a service to present a user token and receive a new token scoped for a specific downstream service. Maintains the user's identity through the call chain while constraining the downstream token's audience and scope.kid. The new key is available for verification but the auth server still signs new tokens with the old key. All services cache the JWKS with both keys.
Step 2 — Switch signing to the new key: Update the auth server to sign new tokens with the new key (kid: new-key). Old tokens (signed with the old key, kid: old-key) are still verified using the old public key still present in JWKS. Verifiers handle both transparently via kid lookup.
Step 3 — Wait for old tokens to expire: Old tokens expire naturally within one access token TTL (15 min – 1 h). After this window, no valid old-key tokens remain in circulation.
Step 4 — Remove the old key from JWKS: Remove the old key's entry from JWKS. Verifiers that cached it will eventually refresh their cache. Monitor for verification failures after removal — any spike indicates tokens with the old kid are still being presented.
Total rotation time = max access token TTL + JWKS cache TTL. With 1h tokens and 15 min JWKS cache, the rotation window is ~75 min of running both keys simultaneously.kid distribution in your token validation metrics. If you see tokens with the old kid still arriving after the expected rotation window, investigate before removing the key — long-lived refresh tokens that issue access tokens with the old kid are a common oversight. Ensure the auth server uses the new key for all newly issued tokens (access and ID tokens) before removing the old key from JWKS.JWT (JWS — JSON Web Signature): signs the payload to guarantee integrity and authenticity. The payload is visible — Base64URL decoded by anyone who holds the token. JWE (JSON Web Encryption): encrypts the payload so it is confidential. Only the intended recipient with the decryption key can read the claims. JWE adds a 5-part structure: header, encrypted key, initialization vector, ciphertext, authentication tag. When you actually need JWE: - The token contains sensitive personal data (medical records, financial details, government IDs) that must not be readable by intermediaries (CDNs, load balancers, logging pipelines) - You're embedding a payload that a relying party must not inspect (e.g., an opaque token that carries sensitive routing information) - Regulatory requirements explicitly mandate encrypted tokens (some healthcare and financial compliance frameworks)
When you don't need JWE (most systems): - Your claims are non-sensitive (user ID, roles, scopes, tenant ID) - Tokens are transmitted over TLS — the transport layer provides confidentiality in transit - The token is only visible to the server and the authenticated client JWE adds significant complexity (key management for encryption, nested token formats, library support) with minimal benefit for typical authorization use cases. Fix the root cause instead: don't put sensitive data in JWT claims.
aud claim matching the intended service (payments-api, inventory-api). Services reject tokens for other audiences. Token exchange (RFC 8693) allows a gateway or service to request a downstream-scoped token from the auth server when making service-to-service calls.
3. Tiered token lifetimes by sensitivity: - High-value endpoints (payments, account changes): 5–15 min access tokens,
require step-up authentication (MFA re-prompt) for specific operations
- Standard API access: 15–60 min access tokens - Read-only public APIs: up to 1 h - Machine-to-machine (service accounts): 5–15 min, rotated automatically by the platform
4. Claims taxonomy governance: publish a claims registry (internal docs or schema). All services agree on claim names, formats, and semantics. role values are defined centrally; custom claims follow a namespaced convention (https://example.com/claims/tenant). Prevents drift where admin means different things in different services.
5. Observability: instrument every token issuance (issuer, audience, subject, algorithm, TTL) and every validation failure (expired, wrong aud, bad sig). Token metrics reveal misconfigured clients (flooding the token endpoint), abuse patterns, and rotation issues.
6. Auth server HA: the auth server must be highly available — any downtime blocks all logins. Active-active across AZs with a shared signing key (or replicated HSM). The JWKS endpoint should be cached at the edge (CDN or load balancer cache) so it remains available even if the auth server is degraded.role vs roles vs user_role vs permissions), and cross-service authorization logic diverges. Treat the JWT claim schema as a public API — versioned, documented, with a deprecation policy. Services that consume claims are coupled to this schema; breaking changes require a migration plan. The auth team should own this schema with the same rigor as an external API. Automate compliance: a CI check that validates that any new claim in a token matches the registered schema prevents undocumented claims from appearing in production tokens and ending up in audit logs unrecognized.A compromised signing key means an attacker can forge any JWT for any user with any claims. All tokens signed with that key are untrusted. This is a Tier-1 security incident. Immediate (minutes): 1. Generate a new signing key pair on the auth server. 2. Remove the compromised key from JWKS immediately — this causes all tokens signed with the old key to fail validation across all services (a forced logout of all users). This is an intentional, unavoidable disruption. Communicate it to stakeholders. 3. Revoke the compromised key in your key management system (KMS, HSM, Vault). 4. Force re-authentication: all users must log in again to receive tokens signed by the new key. Containment (hours): 5. Determine the blast radius: when was the key exfiltrated? Pull audit logs from the key store. Any tokens signed between exfiltration and key removal may have been forged — treat all activity in that window as potentially attacker-controlled. 6. Audit the window's API activity for anomalous patterns: unusual privilege claims, unusual users, unusual volumes. Correlate with application logs. 7. Rotate all secrets that the forged tokens may have accessed during the window — API keys, database credentials, anything the attacker could have retrieved using a forged admin token.
Recovery and hardening: 8. Store private keys in an HSM or cloud KMS — never in plaintext on disk or in environment variables. 9. Implement key access auditing: every signing operation should produce an audit log entry. 10. Set up alerting for: unexpected JWKS changes, signing volume anomalies, auth server accessing key material outside expected patterns. 11. Implement a break-glass procedure for key rotation that can be executed in under 5 minutes.
{iss, sub, aud, exp, iat, jti, tenant_id, role}. Keep it small. tenant_id in the token enforces tenant isolation at the service level — every service checks that the request's resource belongs to the token's tenant_id. This prevents cross-tenant data access even if authorization logic has a bug elsewhere.aud per service (which would require a token per service). aud: platform-api covers all standard services; aud: admin-api covers admin operations requiring stricter tokens with shorter TTL.sub (or outstanding jti values from audit logs) to the denylist in Redis. All services check the denylist on validation. Takes effect within the next request for each service instance.tenant_id values is an automatic flag), and resource access volume. Feed these into a SIEM with baseline alerting.