- Published on
Mastering JWT Security — Signature Verification, Key Rotation, and Common Vulnerabilities
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- JWT, JWS, JWE — Getting the Terminology Straight
- Dissecting a JWS — Header, Payload, Signature
- Attack Technique 1 — alg Confusion Attacks
- Attack Technique 2 — kid Injection
- Claim Validation — Checking the Signature Is Only Half the Job
- Symmetric vs Asymmetric — and EdDSA
- JWKS-Based Key Rotation Strategy
- Safe Verification Code — Java / Node / Go
- The Storage Debate — localStorage vs Cookie
- The Revocation Problem — JWT's Achilles Heel
- A Collection of Anti-Patterns
- Conclusion
- References
Introduction
JWT (JSON Web Token) is the lifeblood of modern authentication infrastructure. OIDC ID tokens, OAuth access tokens, service-to-service authentication in microservices, delegation proofs for AI agents — almost every token in flight is in JWT format. As the JWT Authorization Grant support introduced in Keycloak 26.6 shows, the range of JWT use keeps expanding in 2026.
The problem is that JWT's simplicity — "it is just signed JSON" — has led to far too many systems with sloppy verification. Swapping alg to none to bypass signature verification, disguising an RS256 public key as an HMAC secret to forge tokens, attempting SQL injection through the kid header — these are not CTF puzzles but incidents on the real CVE list.
This article starts with the structure of JWT/JWS/JWE and covers known attack techniques and their defenses, JWKS-based key rotation strategy, safe verification code per language, and the eternal homework of revocation — nearly everything you need in practice.
JWT, JWS, JWE — Getting the Terminology Straight
The three terms are often used interchangeably, but they live at different layers.
| Spec | RFC | Role |
|---|---|---|
| JWT | RFC 7519 | Abstract definition of a token format carrying claims |
| JWS | RFC 7515 | Concrete serialization guaranteeing integrity via signature (most JWTs) |
| JWE | RFC 7516 | Serialization adding confidentiality via encryption |
| JWK / JWKS | RFC 7517 | JSON representation of keys and key sets |
| JWA | RFC 7518 | The list of available algorithms |
What people commonly call a JWT is, 99% of the time, JWS Compact Serialization. If you remember only one crucial distinction, make it this one:
JWS only signs. Anyone can read the payload. Base64URL is not encryption. This is why you must never put personal data or secrets into a token. If you need confidentiality, use JWE — or do not put the data in the token at all.
Dissecting a JWS — Header, Payload, Signature
JWS Compact Serialization consists of three parts separated by dots.
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjIwMjYtMDYta2V5In0
.
eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbS9yZWFsbXMvbXlyZWFsbSIsInN1YiI6ImFsaWNlIiwiYXVkIjoib3JkZXItYXBpIiwiZXhwIjoxNzgwMDAwOTAwLCJpYXQiOjE3ODAwMDAwMDB9
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└── BASE64URL(header) . BASE64URL(payload) . BASE64URL(signature)
Decoded, the header and payload are JSON like this.
{
"alg": "RS256",
"typ": "JWT",
"kid": "2026-06-key"
}
{
"iss": "https://idp.example.com/realms/myrealm",
"sub": "alice",
"aud": "order-api",
"exp": 1780000900,
"iat": 1780000000,
"scope": "openid profile orders:read"
}
The signature is computed as follows.
signing_input = BASE64URL(header) + "." + BASE64URL(payload)
RS256: signature = RSASSA-PKCS1-v1_5-SHA256( private_key, signing_input )
ES256: signature = ECDSA-P256-SHA256( private_key, signing_input )
EdDSA: signature = Ed25519( private_key, signing_input )
HS256: signature = HMAC-SHA256( shared_secret, signing_input )
The key insight: the header is input that an attacker can manipulate at will. The moment you treat header parameters like alg, kid, jku, or x5u as "trustworthy metadata", you create a vulnerability. Verification logic must not follow the header's instructions — instead, a policy predetermined by the server must censor the header.
Attack Technique 1 — alg Confusion Attacks
The none algorithm attack
The JWA spec defines alg none for unsigned "Unsecured JWTs". Early libraries trusted the alg in the token header as-is, so if an attacker changed the header to:
{
"alg": "none",
"typ": "JWT"
}
a token with an empty signature section would pass as "verified" (multiple library CVEs in 2015). Since the payload can be forged at will, this is catastrophic.
Forged token: BASE64URL(none-header) . BASE64URL(forged-payload) .
└ no signature
The RS256 → HS256 key confusion attack
A more cunning variant. When a server verifies RS256 (asymmetric) and holds a public key, what happens if the attacker switches alg to HS256 (symmetric)?
1. The server's RSA public key is public (JWKS endpoint, etc.)
2. Attacker: creates a forged token with alg changed to HS256
3. Attacker: signs it with HMAC using "the PEM string of the RSA
public key" as the secret
4. Vulnerable server: sees alg=HS256 and performs HMAC verification —
using the RSA public key (the very key it holds) as the HMAC key
5. HMAC(publicKeyPEM, payload) matches → the forged token passes
The attack exploits the fact that public keys are not secret, so the attacker can produce the same HMAC signature with the same key.
Defense — pin an algorithm allowlist
There is exactly one defensive principle: hardcode the allowed verification algorithms in server code, and only compare the token header's alg against that list.
Bad: verify(token, key) ← the token decides alg
Good: verify(token, key, algorithms=[RS256]) ← the server decides alg
Most modern libraries force explicit algorithm specification, but legacy code and hand-rolled verification logic must be audited. Additionally, if you bind key types to algorithm families (RSA keys only with RS/PS, EC keys only with ES), key confusion becomes structurally impossible.
Attack Technique 2 — kid Injection
The kid (Key ID) header is a hint telling the server "which key to verify with". If the server uses the kid value without validation, it becomes an injection channel.
Scenario A: path traversal
Header: "kid": "../../../../dev/null"
If the server reads keys from the filesystem by kid,
/dev/null (empty content) becomes the key
→ forged tokens signed with an empty key pass
Scenario B: SQL injection
Header: "kid": "x' UNION SELECT 'attacker-known-secret' --"
If the server queries SELECT key FROM keys WHERE kid = '...',
a value the attacker knows is returned as the verification key
Scenario C: jku/x5u header abuse
Header: "jku": "https://evil.com/jwks.json"
If the server fetches keys from the jku URL,
verification happens against the attacker's key
The defenses are as follows.
- Treat kid as an opaque identifier only — look it up by exact match in a pre-loaded key map. If there is no match, reject immediately.
- Constrain the format of kid values (alphanumerics and hyphens only, etc.).
- Ignore jku and x5u headers, or only use allowlisted URLs (your own IdP's JWKS).
- Never put dynamic filesystem/DB lookups in the key resolution path.
Claim Validation — Checking the Signature Is Only Half the Job
A valid signature only means "the IdP issued it" — not "it was issued for this API, now, for this purpose". You must validate all of the standard claims.
| Claim | Meaning | Validation rule |
|---|---|---|
| iss | Issuer | Exact match against the expected issuer URL |
| aud | Audience | Confirm your service identifier is included |
| exp | Expiry | Current time before exp (allow 60-120 s clock skew) |
| nbf | Not before | Current time after nbf |
| iat | Issued at | Reject tokens abnormally in the future/past |
| sub | Subject | Non-empty, maps to your internal user model |
| typ / token_use | Token kind | Block mixing access tokens and ID tokens |
In particular, skipping aud validation is the most common and most dangerous mistake. Even with tokens issued by the same IdP, a "payment API token" could then call the "admin API". In microservice environments, give each service a unique audience and have each accept only its own — this is what blocks lateral movement of tokens.
Beware also of mixing ID tokens with access tokens. ID tokens exist to tell the client who the user is; they must not be used to authorize API calls. RFC 9068 (the JWT access token profile) structurally blocks this confusion by setting typ to at+jwt in the header.
Symmetric vs Asymmetric — and EdDSA
Algorithm comparison
| Algorithm | Kind | Key | Characteristics |
|---|---|---|---|
| HS256 | Symmetric HMAC | Shared secret | Fast. Verifiers can also issue (no non-repudiation) |
| RS256 | Asymmetric RSA | 2048-bit+ | Most widely compatible. Large signatures (256 bytes) |
| PS256 | Asymmetric RSA-PSS | 2048-bit+ | Improved RS256 (probabilistic padding) |
| ES256 | Asymmetric ECDSA P-256 | 256-bit | 64-byte signature. Nonce reuse leaks the key |
| EdDSA (Ed25519) | Asymmetric | 256-bit | Deterministic signatures, fast, side-channel resistant. Recommended in 2026 |
The selection criteria are simple.
- A single service issuing and verifying its own tokens: HS256 works, but the moment the secret spreads to every verifier, issuing power spreads with it. If the architecture is likely to grow, go asymmetric from the start.
- An IdP issues, multiple services verify: asymmetric, no exceptions. Verifiers hold only the public key, so even a leak does not enable forgery.
- Algorithm for new systems: EdDSA (Ed25519) is the first candidate. It is a deterministic signature without ECDSA's nonce-reuse problem, fast at signing/verifying, with small signatures. Keycloak 26.6 officially supports EdDSA, so you can select Ed25519 in the realm key provider. Do verify compatibility of older client libraries beforehand.
Token size and performance
A JWT rides along in every request header, so size is cost.
Rough size comparison (same claims)
HS256: header+payload + 32-byte signature → smallest
ES256: header+payload + 64-byte signature
EdDSA: header+payload + 64-byte signature
RS256: header+payload + 256-byte signature → about 342 chars after Base64
Caution: payload bloat is the bigger problem
- Tokens stuffed with entire group/permission lists commonly
exceed 8KB and blow past web server header limits (default 8KB)
- Recommended design: put only a "reference" to permissions
in the token and fetch details via API
For verification performance, EdDSA and ECDSA verification are faster than or comparable to RSA verification, and RSA is especially slow at signature generation. The higher your IdP's issuance volume, the bigger EdDSA's advantage.
JWKS-Based Key Rotation Strategy
Signing keys must not live forever. The older a key, the higher the cumulative probability of leakage — and the bigger the blast radius when it leaks. The standard pattern is zero-downtime rotation using a JWKS (JSON Web Key Set) endpoint and kid.
JWKS endpoint (Keycloak example)
GET https://idp.example.com/realms/myrealm/protocol/openid-connect/certs
{
"keys": [
{
"kid": "2026-06-key",
"kty": "OKP",
"crv": "Ed25519",
"alg": "EdDSA",
"use": "sig",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
},
{
"kid": "2026-03-key",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "0vx7agoebGcQSuuPiLJXZpt...",
"e": "AQAB"
}
]
}
The zero-downtime rotation procedure looks like this.
Step 1: Generate the new key, add it to JWKS (still sign with old key)
→ gives every verifier time to cache the new key
Step 2: Switch signing to the new key (keep the old key in JWKS)
→ unexpired tokens signed with the old key still verify
Step 3: After all tokens signed with the old key have expired,
remove the old key from JWKS
Timing rules:
Step1 → Step2 interval >= verifiers' JWKS cache TTL
Step2 → Step3 interval >= maximum access token lifetime
Keycloak implements this pattern via key provider priority and active/passive states. Adding a new key with higher priority switches signing; the old key stays passive, handling verification only, and is removed later.
Best practices on the verifier side matter too.
- Cache the JWKS rather than fetching it per request (5-15 minute TTL).
- On encountering an unknown kid, force-refresh the JWKS exactly once — it is the natural situation right after rotation. If still missing, reject.
- If a JWKS refresh fails, keep operating on the existing cache (availability).
- Rate-limit calls to the JWKS endpoint so a flood of forged kids cannot become a DoS.
Safe Verification Code — Java / Node / Go
Java (Spring Security Resource Server)
The safest approach is not to verify by hand. Delegate to Spring Security.
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com/realms/myrealm
audiences: order-api
// When extra claim validation is needed (customizing JwtDecoder)
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(
"https://idp.example.com/realms/myrealm");
OAuth2TokenValidator<Jwt> withIssuer =
JwtValidators.createDefaultWithIssuer(
"https://idp.example.com/realms/myrealm");
OAuth2TokenValidator<Jwt> withAudience = new JwtClaimValidator<List<String>>(
"aud", aud -> aud != null && aud.contains("order-api"));
decoder.setJwtValidator(
new DelegatingOAuth2TokenValidator<>(withIssuer, withAudience));
return decoder;
}
The issuer-uri approach auto-configures the JWKS location and supported algorithms via OIDC discovery, and validates exp/nbf and issuer by default. Do not forget that audience validation must be added explicitly, as above.
Node.js (the jose library)
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://idp.example.com/realms/myrealm/protocol/openid-connect/certs'),
{ cooldownDuration: 30000, cacheMaxAge: 600000 }
);
export async function verifyAccessToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://idp.example.com/realms/myrealm',
audience: 'order-api',
algorithms: ['EdDSA', 'RS256'], // hardcoded algorithm allowlist
clockTolerance: '60s',
});
if (payload.token_use && payload.token_use !== 'access') {
throw new Error('not an access token');
}
return payload;
}
jose's createRemoteJWKSet handles kid matching, caching, and the one-time re-fetch on unknown kid for you. The habit of always specifying algorithms is the important part.
Go (lestrrat-go/jwx)
package auth
import (
"context"
"errors"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
var cache *jwk.Cache
func InitJWKS(ctx context.Context) error {
cache = jwk.NewCache(ctx)
return cache.Register(
"https://idp.example.com/realms/myrealm/protocol/openid-connect/certs",
jwk.WithMinRefreshInterval(10*time.Minute),
)
}
func VerifyAccessToken(ctx context.Context, raw string) (jwt.Token, error) {
keySet, err := cache.Get(ctx,
"https://idp.example.com/realms/myrealm/protocol/openid-connect/certs")
if err != nil {
return nil, err
}
tok, err := jwt.Parse([]byte(raw),
jwt.WithKeySet(keySet), // kid-based key selection
jwt.WithIssuer("https://idp.example.com/realms/myrealm"),
jwt.WithAudience("order-api"),
jwt.WithAcceptableSkew(60*time.Second), // clock skew tolerance
jwt.WithValidate(true),
)
if err != nil {
return nil, err
}
if tok.Subject() == "" {
return nil, errors.New("missing sub claim")
}
return tok, nil
}
The shared principles across all three languages are identical: (1) pin the algorithm allowlist, (2) explicitly validate issuer/audience, (3) delegate JWKS caching and kid matching to the library, (4) never hand-roll verification by decoding Base64 yourself.
The Storage Debate — localStorage vs Cookie
Where to keep tokens in an SPA is a debate now in its tenth year. Here is the summary.
| Storage | XSS exposure | CSRF exposure | Notes |
|---|---|---|---|
| localStorage | Vulnerable (readable by JS) | Safe | Theft leaks the token itself |
| sessionStorage | Vulnerable | Safe | Gone when tab closes; same limits |
| Memory (JS variable) | Partially vulnerable | Safe | Lost on refresh; theft is harder |
| HttpOnly Cookie | Safe (unreadable) | Vulnerable → mitigated by SameSite | The token value itself is protected |
A sober assessment is required: once XSS happens, it is close to game over regardless of where you stored the token. Even with an HttpOnly cookie, the attack script can simply call your APIs directly from the user's browser. That said, the HttpOnly cookie still wins on preventing exfiltration of the token value itself (offline abuse, use from another IP).
The recommended order as of 2026:
- The BFF pattern: tokens exist only on the server. The browser gets an HttpOnly + Secure + SameSite=Strict session cookie. Effectively the closing argument of SPA security.
- If a BFF is not feasible: access token in memory, refresh token in an HttpOnly cookie with rotation enabled.
- Avoid putting refresh tokens in localStorage. A single XSS exfiltrates a long-lived credential wholesale.
The Revocation Problem — JWT's Achilles Heel
JWT's greatest strength — stateless verification — is also its greatest weakness. An issued token remains valid until exp, and there is no standard way for the server to declare "this specific token is now invalid". The solutions form a spectrum of trade-offs.
fully stateless ◄──────────────────────────────► fully stateful
short lifetime denylist introspection opaque tokens
(5-15 min) (jti blocklist) (RFC 7662) + session store
no instant cut store only query IdP every give up JWT
invalidations request
The recommended combination in practice:
- Shorten access token lifetime to 5-15 minutes — structurally shrink the theft window. For most services this alone is enough.
- Manage refresh tokens statefully — rotation + reuse detection + server-side invalidation. "Logout" and "forced revocation" are realized at the refresh layer.
- Extra verification for high-risk operations — payments, permission changes, etc. should require introspection (RFC 7662) or re-authentication.
- An emergency denylist — keep a short-TTL cache (e.g., Redis) to block specific jti/sub values during incident response. Because exp is short, the denylist stays small.
- Event-driven propagation — in large environments, consider an architecture that subscribes to session invalidation events via OpenID Shared Signals/CAEP.
A Collection of Anti-Patterns
Finally, the anti-patterns found repeatedly in the field.
| Anti-pattern | What is wrong | Correction |
|---|---|---|
| Decoding the token without verifying | Base64 decoding is not verification | Verify signature + all claims |
| Branching on alg read from the token header | The entry point of alg confusion | Hardcoded allowlist |
| Skipping aud validation | Permits token lateral movement | Unique audience per service |
| Storing passwords/PII in the payload | JWS is plaintext-readable | JWE or do not store |
| Issuing tokens without expiry | Becomes a permanent credential | exp required, keep it short |
| Short string as the HS256 secret | Vulnerable to brute force | 256-bit+ random key |
| Detailed verification failure reasons in errors | Feedback for attackers | Generic 401 response |
| Only the gateway verifies signatures | Internal direct calls go unverified | Zero trust — every service verifies |
Conclusion
The essence of JWT security fits in one sentence: trust the policy your server defines, not what the token says. The alg, the kid, and the claims are all inputs an attacker can fill in, and your verification code is the gatekeeper that censors those inputs against policy.
- Structure: JWS only signs; confidentiality needs JWE. Treat the payload as public information.
- Attacks: alg none, RS256→HS256, and kid injection all arise from "trusting the header". Pin the algorithms and key sources on the server.
- Claims: validate iss/aud/exp/nbf without exception. aud validation in particular blocks lateral movement in microservices.
- Key management: automate zero-downtime rotation with JWKS + kid, and evaluate EdDSA first for new systems (officially supported in Keycloak 26.6).
- Lifecycle: short access tokens + stateful refresh tokens + an emergency denylist is the realistic balance.
Verification code is code that, once written, waves through every request for years. Take this article's checklist and audit the verification logic of the services you operate today.
References
- RFC 7519 - JSON Web Token (JWT)
- RFC 7515 - JSON Web Signature (JWS)
- RFC 7516 - JSON Web Encryption (JWE)
- RFC 7517 - JSON Web Key (JWK)
- RFC 7518 - JSON Web Algorithms (JWA)
- RFC 8725 - JSON Web Token Best Current Practices
- RFC 9068 - JWT Profile for OAuth 2.0 Access Tokens
- RFC 7662 - OAuth 2.0 Token Introspection
- RFC 8037 - CFRG Elliptic Curve Signatures in JOSE (Ed25519)
- RFC 9700 - Best Current Practice for OAuth 2.0 Security
- OpenID Connect Core 1.0
- Keycloak Documentation
- Keycloak 26.6.0 Release Notes