Skip to content
RESEARCH INDEX BREACHROAD / INTELLIGENCE NOTE

JWT security: kid, JWKS and key rotation

How JWT verification works and where it breaks: alg confusion, kid injection, JWKS handling, iss/aud validation, revocation, testing and detection.

PUBLIC RESEARCH
AUTHOR
/ CEO of Breachroad · OSCP · PNPT
PUBLISHED
25 July 2026
READING TIME
17 min read
TOPIC
Identity and Access
JWT security: kid, JWKS and key rotation

A JWT is not a session. That single sentence explains most of what follows. A classic server-side session keeps state in the application and the cookie is only an unguessable pointer to that state. A JWT inverts the arrangement: the token carries claims about the user, and the server trusts them purely because the signature checks out. The security burden moves from the randomness of an identifier to the correctness of cryptographic verification and claim validation.

That shift in responsibility is where most audit findings come from. Applications rarely skip signature verification outright. Far more often they verify it in a way an attacker can point at their own key, or they verify the signature perfectly and ignore the fact that the token was issued for an entirely different audience.

Anatomy and the trust boundary

A compact-form token is three dot-separated parts — header, payload and signature — each base64url encoded. Encoding is not encryption: anyone holding the token can read the header and payload. If a national ID, address, internal role or customer identifier ends up in the token, that data is visible to the user, the browser, every proxy on the path and anyone who reads the logs.

The header declares the algorithm (alg) and usually a key identifier (kid). The payload carries claims: who issued the token (iss), who it is intended for (aud), who it describes (sub), the validity window (nbf, exp) and often a unique identifier (jti). The signature binds both parts to the issuer’s key.

The trust boundary sits in exactly one place: everything in the token is attacker-controlled input until the signature is verified with a trusted key. The header is part of the token, so the header is attacker-controlled too. That sounds obvious, and it is the root of the two most common vulnerability classes.

Class one: taking the algorithm from the token

The oldest mistake is reading alg from the header and choosing the verification path from it. Two consequences follow, both critical.

The first is the value none. The specification defines an “unsecured JWS”, so a library that treats alg: none as a valid mode will accept an unsigned token — meaning a token with any content at all. Mature libraries block this by default, but hand-rolled verification and stale dependencies still fall over here.

The second is algorithm family confusion. When a server issues RS256 tokens, its public key is public by definition. If the verifier picks the algorithm from the header and, for HS256, uses the “key” from configuration — which happens to be that public key — an attacker can sign their own token symmetrically using a publicly available value as the secret. The signature matches, because both sides compute an HMAC over the same material.

The fix is short and non-negotiable: the verifier must know the algorithm from configuration, not from the token. Accept a closed list of algorithms bound to a specific issuer, and reject any token with a different alg before attempting any signature computation.

Class two: kid as a client-controlled pointer

The kid field indicates which key signed the token, which is necessary whenever an issuer holds more than one key — that is, whenever it rotates keys at all. Problems start when the kid value feeds an operation that can do more than look up an entry in a map.

Three variants show up in practice. A kid used as part of a filesystem path to a key opens the door to path traversal and pointing at a file with predictable contents. A kid interpolated into a SQL query turns token verification into a pre-authentication SQL injection sink. A kid used as a distributed cache key sometimes allows entry poisoning if that cache also accepts data from another producer.

A separate family is the jku and x5u headers — URLs pointing to a key set or certificate. If the verifier fetches a key from an address supplied inside the token, the attacker delivers their own key alongside their own token and verification always succeeds. These headers make sense only with a hard allowlist of URLs, and in most deployments the right decision is to reject them outright.

The safe pattern is simple: kid is a label used to look up a key in a set fetched from a trusted source. Not a path, not a URL, not a query parameter. An unknown kid ends in a rejected token, not in fetching something from the network.

Class three: valid signature, wrong token

This category is insidious because the cryptography is flawless and the request still gets authorised when it should not. A signature only tells you that the token was issued by someone whose key you know. It does not tell you it was issued for you, that it is still current, or that it concerns this application.

Complete validation means several independent checks. iss must match the expected issuer exactly — string comparison, not substring matching, because https://issuer.example.com.attacker.tld contains the expected prefix. aud must include this specific service’s identifier; if a shared identity provider issues tokens for ten microservices, skipping this check means a token for the reporting service opens the payments service. exp and nbf need a small allowance for clock skew, but an allowance measured in minutes turns expiry into fiction. If the token carries scope, roles or tenant, every one of those fields must be enforced at the resource, not merely rendered in the UI.

Direction of trust between systems matters too. An internal token issued by service A for service B should not be accepted by service C just because all three share a key. That is precisely the situation where aud stops being a formality.

JWKS in practice: publication, caching, rotation

The public key set (JWKS) is normally published at a well-known issuer URL and fetched by verifiers. Three operational details matter.

First, caching with a sensible lifetime. Fetching the set on every request turns the identity provider into a single point of failure and an invitation to exhaust rate limits. Fetching once a day means a key rotation breaks traffic for hours.

Second, a key overlap window. Correct rotation looks like this: the new key appears in JWKS but does not sign yet; after a period longer than verifier caches, the issuer starts signing with it while leaving the old key in the set for as long as the longest-lived issued token; only then does the old key disappear. Skipping any step produces the classic “everyone got a 401 after deployment” outage.

Third, controlled refresh on unknown kid. A sensible verifier seeing a kid outside its cache may refresh the set — but with a rate limit. Without one, a stream of tokens carrying random kid values turns verification into a traffic generator aimed at the identity provider.

Add basic fetch hygiene: HTTPS only, configured URL only, response validation, a hard timeout and a size limit. A key set fetched without limits is a ready-made memory exhaustion channel.

Symmetric secrets and internal tokens

HS256 gets picked because it is simple. The price is that the verification key is also the issuing key. Every service that checks tokens can also mint arbitrary ones. In an architecture with a dozen microservices, compromising the least important component yields the ability to issue an administrator token.

If tokens cross a team, service or trust-zone boundary, an asymmetric algorithm is the right choice. Keep symmetric secrets for local, short-lived uses, with adequate entropy, stored in a secret manager — not in the repository and not baked into a container image.

Lifetime and revocation

A signed token is valid until it expires and nothing changes that; this is the price of statelessness. That is why you design short access tokens plus a separate renewal mechanism that is stateful and can be revoked.

When immediate logout or cutting off a stolen token is required, you need a revoked-jti list or a per-user “valid from” marker checked during verification. This gives back part of the statelessness benefit, but it is the only honest way to make a “log out everywhere” button actually work.

Binding the token to a client is a related topic. A bearer token works for anyone who obtains it. Mechanisms such as DPoP or certificate-bound tokens make a token useless without the holder’s key. They are worth considering wherever the consequences of token theft are disproportionate to the cost of implementation.

Where tokens live and leak

The most common leaks are not cryptographic. A token in a query parameter lands in server logs, browser history, the Referer header and analytics pipelines. A token in localStorage is reachable by every script on the page, so any XSS becomes session takeover. A token in a cookie without HttpOnly, Secure and SameSite inherits every cookie problem there is.

Application logs and observability stacks add to this. The Authorization header gets logged “temporarily for debugging” and then stays forever. Redacting that header should be a default rule in the logging library, not an individual developer’s decision.

Testing methodology

Testing JWT security starts with review, not with sending requests. Establish who issues tokens, which library verifies them, in which version and with what parameters. Very often the entire answer sits in one configuration line missing an algorithm allowlist or an expected audience.

Next, build a matrix of negative cases and check each variant separately, using your own test account in an environment where you have permission to test: an expired token, a not-yet-valid token, a token from another issuer, a token whose audience is a different service, a token signed with a key the server does not know, a token with a swapped algorithm, a token with an unknown kid, a token with a truncated signature. In every case you care not only about the status code but also about timing and message content — differences reveal that verification stopped somewhere other than you assumed.

The third step is checking what happens to claims after verification. A token can be perfectly valid and still contain a role the user should not hold, if the issuing service lets a user influence that value during sign-up or login. This path is often skipped because, formally, it is not a token flaw but an issuer flaw.

Wrap the negative tests into an automated suite that runs in CI. Token verification is code that changes rarely and breaks quietly — usually on a library bump or an identity provider migration.

Detection on the defensive side

Logging should cover iss, aud, kid and the algorithm of accepted tokens, plus the rejection reason for rejected ones. That alone supports several cheap, effective rules.

An algorithm outside the allowlist means someone is probing the verifier; a legitimate client never does that. Unknown kid values across a growing number of requests point at forced JWKS refreshes or path guessing. A sudden spike in aud rejections after a deployment usually means misconfiguration, but it can be the first trace of a token being replayed across services. Finally, a spike in exp rejections right after key rotation says the overlap window was too short.

It is also worth monitoring token lifetimes in production traffic. Tokens valid for longer than policy allows mean some component is issuing them outside the standard path.

When not to use JWT

Statelessness pays off when it solves a real problem: many services, many zones, no shared session store, a requirement for offline verification. If the application is a single monolith with a single database, a server-side session is simpler and safer: revocation is immediate, sensitive data never travels to the client, and the entire attack surface described above simply does not exist.

Choosing JWT should be an architectural decision with a rationale, not a framework default.

Checklist

The verifier accepts only algorithms listed in configuration. The jku and x5u headers are ignored or restricted to a hard URL allowlist. kid is used solely to look up a key in a fetched set and never reaches a path, query or other interpreter. iss and aud are required and compared exactly. exp and nbf are enforced with skew measured in seconds. Keys have a planned rotation with an overlap window longer than verifier caches. JWKS refresh has a rate limit, a timeout and a size limit. Access tokens are short-lived and renewal is stateful and revocable. The Authorization header is redacted in logs. Tokens never travel in query parameters. Negative verification tests run in CI.

Conclusion

JWT is a good tool for carrying claims between services and a poor session replacement wherever sessions suffice. Token security does not end when the signature matches — it starts with asking whose key produced that signature and for whom, and it ends with whether you can revoke the token before it expires on its own. Three configuration decisions — a closed algorithm list, required iss and aud, and kid treated as a label — close most real attack paths. The rest is operational discipline around rotation and logs.


Primary sources: RFC 7519 — JSON Web Token, RFC 8725 — JWT Best Current Practices, OWASP — JSON Web Token Cheat Sheet.

SHARE / COPY