Skip to content
Published on

Mastering JWT Security — Signature Verification, Key Rotation, and Common Vulnerabilities

Authors

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.

SpecRFCRole
JWTRFC 7519Abstract definition of a token format carrying claims
JWSRFC 7515Concrete serialization guaranteeing integrity via signature (most JWTs)
JWERFC 7516Serialization adding confidentiality via encryption
JWK / JWKSRFC 7517JSON representation of keys and key sets
JWARFC 7518The 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.

  1. 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.
  2. Constrain the format of kid values (alphanumerics and hyphens only, etc.).
  3. Ignore jku and x5u headers, or only use allowlisted URLs (your own IdP's JWKS).
  4. 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.

ClaimMeaningValidation rule
issIssuerExact match against the expected issuer URL
audAudienceConfirm your service identifier is included
expExpiryCurrent time before exp (allow 60-120 s clock skew)
nbfNot beforeCurrent time after nbf
iatIssued atReject tokens abnormally in the future/past
subSubjectNon-empty, maps to your internal user model
typ / token_useToken kindBlock 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

AlgorithmKindKeyCharacteristics
HS256Symmetric HMACShared secretFast. Verifiers can also issue (no non-repudiation)
RS256Asymmetric RSA2048-bit+Most widely compatible. Large signatures (256 bytes)
PS256Asymmetric RSA-PSS2048-bit+Improved RS256 (probabilistic padding)
ES256Asymmetric ECDSA P-256256-bit64-byte signature. Nonce reuse leaks the key
EdDSA (Ed25519)Asymmetric256-bitDeterministic 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.

  1. Cache the JWKS rather than fetching it per request (5-15 minute TTL).
  2. On encountering an unknown kid, force-refresh the JWKS exactly once — it is the natural situation right after rotation. If still missing, reject.
  3. If a JWKS refresh fails, keep operating on the existing cache (availability).
  4. 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.

Where to keep tokens in an SPA is a debate now in its tenth year. Here is the summary.

StorageXSS exposureCSRF exposureNotes
localStorageVulnerable (readable by JS)SafeTheft leaks the token itself
sessionStorageVulnerableSafeGone when tab closes; same limits
Memory (JS variable)Partially vulnerableSafeLost on refresh; theft is harder
HttpOnly CookieSafe (unreadable)Vulnerable → mitigated by SameSiteThe 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:

  1. 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.
  2. If a BFF is not feasible: access token in memory, refresh token in an HttpOnly cookie with rotation enabled.
  3. 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:

  1. Shorten access token lifetime to 5-15 minutes — structurally shrink the theft window. For most services this alone is enough.
  2. Manage refresh tokens statefully — rotation + reuse detection + server-side invalidation. "Logout" and "forced revocation" are realized at the refresh layer.
  3. Extra verification for high-risk operations — payments, permission changes, etc. should require introspection (RFC 7662) or re-authentication.
  4. 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.
  5. 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-patternWhat is wrongCorrection
Decoding the token without verifyingBase64 decoding is not verificationVerify signature + all claims
Branching on alg read from the token headerThe entry point of alg confusionHardcoded allowlist
Skipping aud validationPermits token lateral movementUnique audience per service
Storing passwords/PII in the payloadJWS is plaintext-readableJWE or do not store
Issuing tokens without expiryBecomes a permanent credentialexp required, keep it short
Short string as the HS256 secretVulnerable to brute force256-bit+ random key
Detailed verification failure reasons in errorsFeedback for attackersGeneric 401 response
Only the gateway verifies signaturesInternal direct calls go unverifiedZero 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