JWKS and OIDC: Verifying Tokens Without a Shared Secret
Why HS256 forces every service to hold the signing secret, how asymmetric keys and JWK Sets fix it, and how to verify a real OIDC token.
By Toolery Team · August 17, 2026
Picture an internal dashboard service getting compromised. It is the least interesting service anyone owns. It reads a database, draws some charts, and nobody has touched it in a year. The payoff for an attacker should be nothing much.
Instead they walk out able to sign in as any user in the system, administrators included.
The reason sits in that dashboard's config file: the HS256 secret it uses to check incoming tokens. Under HS256, checking a signature and producing one need the same key. A service built only to read tokens had been handed the power to mint them, and not by mistake. That is the only way the algorithm works.
That one property is why JWKS and OIDC exist.
The problem with one shared secret
HS256 is HMAC-SHA256, and HMAC is symmetric. There is no verify-only form of the key. Any service that can confirm a token is valid necessarily holds the value needed to forge one.
With a single service issuing and checking its own tokens, that costs nothing. The trouble starts at the second service. The issuer needs the secret; so does every downstream service that only reads tokens. Add a sixth service and the secret has to reach it too: a config file, an environment variable, a secret manager entry, a CI pipeline variable, a stray copy in someone's terminal history. The exposure surface tracks the number of services, not how much any of them was ever meant to be trusted.
So a break-in at the least important corner of the system is a break-in at the trust boundary itself. The dashboard never needed to mint tokens. HS256 gave it the ability anyway.
Rotating the secret doesn't fix the timing
Rotating an HMAC secret is possible, whatever some write-ups imply. Verifiers can accept two secrets during an overlap window. The kid header, usually associated with public-key setups, works with HMAC keys too, so a token can declare which secret version signed it.
The cost lives somewhere else: the distribution path. A new secret has to reach every verifying service through a channel that is genuinely secure, which in practice means a secret manager update followed by a redeploy of each one. Six services owned by four teams is four backlogs, four review queues, and four deploy windows that don't line up.
Now add the reason anyone rotates in a hurry. The secret leaked. Until the last verifier picks up the new value, whoever has the old one can still forge tokens that everything accepts. Urgent work that needs four teams to coordinate a deploy is not urgent work. The gap between those two facts is the attacker's working hours.
Flipping to asymmetric signing changes what's shared
RS256 and ES256 split that one capability in two. The issuer signs with a private key; verifiers check with the matching public key and can do nothing else with it.
| HS256 | RS256 | ES256 | |
|---|---|---|---|
| Key | one shared secret | RSA pair (2048-bit typical) | EC pair (P-256) |
| Can verify | anyone holding the secret | anyone, from the public key | anyone, from the public key |
| Can forge | the same set — every verifier | the issuer only | the issuer only |
| Signature size | 32 bytes | 256 bytes | 64 bytes |
| Cost | fast both ways | slow to sign, fast to verify | fast to sign, slower to verify than RSA |
| Rotation | push the new secret to every verifier | publish the new key in the JWKS | publish the new key in the JWKS |
| Reach for it when | one service signs and checks its own tokens | tokens cross boundaries; widest library and HSM support | tokens cross boundaries; token size or signing throughput matters |
A verifier holding a public key is holding a value designed to be handed out. Publishing it is the intended use, not a risk to be managed.
Which kills the distribution problem outright. No secret moves between services, so nothing needs a secure channel, a locked-down secret manager entry, or an audit of who can read it. A verifier that leaks its copy of the public key has leaked a value already served to the open internet.
JWK and JWKS: publishing the key
A public key still has to reach every verifier somehow. Hardcoding a .pem file into each service's config does work, right up until the issuer rotates, at which point every hardcoded copy goes stale on the same afternoon.
JWK (JSON Web Key, RFC 7517) and JWKS (JSON Web Key Set) are the fix. A JWK is a public key written as JSON instead of a PEM blob. A JWKS is a document with a keys array in it, served over ordinary HTTPS at a URL anyone can open. Apple publishes a live one at https://appleid.apple.com/auth/keys. Load it in a browser tab now; it costs nothing and makes the rest of this concrete. One entry, trimmed:
{
"keys": [
{
"kty": "RSA",
"kid": "W6WcOKB",
"use": "sig",
"alg": "RS256",
"n": "2Zc5d0-zkZ5AKmtYTvxHc3vRc41Yfbklfl...",
"e": "AQAB"
}
]
}The n value is shortened above; the real one runs several hundred characters. Apple's kid values also rotate, so what you get today won't match what's printed here. That mismatch is the whole argument for fetching the set instead of pinning a key. What the fields mean:
kty— key type, e.g.RSAorECkid— key ID, the label a token's header points at so a verifier knows which entry in the set to useuse— what the key is for (sigfor signature verification, as opposed to encryption)alg— the algorithm this key pairs withn/e— the RSA modulus and public exponent. Together, these two numbers are the public key
Now compare rotation against the HS256 version. The issuer publishes the new key next to the old one in the same document and starts signing with the new kid. Verifiers do nothing — they pick up the new entry on their next fetch, typically within minutes. Nobody files a ticket, nobody redeploys, and the four teams from earlier never hear about it.
Try it — decode a token
This is a token shaped like a real-world OIDC ID token. Open the header panel below and find the kid value — that's the label the verifier will look for in a JWKS.
What a verifier does, step by step
"Verify the JWT" sounds like one operation. It is seven, and the last three are the ones that get dropped.
- Split the token on its dots and decode the header to read
algandkid. - Fetch the issuer's JWK Set (usually cached, refreshed periodically).
- Pick the specific key whose
kidmatches the header. - Check the cryptographic signature using that public key.
- Check
iss— does it name the issuer you configured trust for? - Check
aud— was this specific token minted with your service as its intended audience? - Check
expandnbf— has it expired, or does it claim to start in the future? (RFC 7519 defines all of these.)
One thing that list hides: never take alg from the token and act on it. That header field is written by whoever sent the token. The two classic attacks both start there. alg: none politely asks the verifier to skip the signature check. RS256-to-HS256 confusion goes further: the attacker re-signs the token with HMAC using the issuer's public key as the shared secret. The key you published on purpose becomes the forgery key. Pin the algorithms you accept, in config or from the JWK's own alg, and reject the rest. RFC 7515 defines both headers, and it never promised they were trustworthy. Every serious JWT library takes an allowed-algorithms argument; pass it.
Steps five through seven are where real vulnerabilities live, and they are the ones people skip. A valid signature proves who signed the token. It says nothing about who the token was for. Skip the audience check and a token minted for one client walks straight into a different service — correctly signed, by an issuer both of them trust, and completely wrong. Cryptography answers the first question. Only the claim checks answer the second.
Where JWKS shows up in practice: OIDC
OpenID Connect (OIDC) is a layer on top of OAuth 2.0. OAuth 2.0 by itself answers authorization, meaning what a piece of software may do on a user's behalf. OIDC adds authentication, proof of who the user is, and does it by returning an ID token, which is always a JWT carrying standard claims like iss, sub, aud, exp, and iat.
A bare OAuth access token is not an authentication answer, and treating it as one is a long-running source of bugs. It can be silently reissued through a refresh token, with the user nowhere near the keyboard. And where it does carry an audience, that audience is the API it was minted for, not the app holding it, so a client that validates one is answering a question about somebody else's trust boundary. An ID token exists to be checked by the application that asked for it, and OIDC Core spells out exactly how.
Finding the key set is standardized too, in a separate spec, OpenID Connect Discovery 1.0. Formally it is optional; in practice it is universal. Every provider serves a discovery document:
curl -s https://accounts.example-idp.com/.well-known/openid-configurationThat returns JSON shaped roughly like this (trimmed to the relevant fields):
{
"issuer": "https://accounts.example-idp.com",
"jwks_uri": "https://accounts.example-idp.com/oauth2/v3/certs",
"id_token_signing_alg_values_supported": ["RS256"]
}So a verifier needs one endpoint in its config, the issuer URL, and it finds the keys from there by following jwks_uri. Two things still have to be told to it, though: which audience to expect, and which algorithms to accept. No discovery document can answer either one for you.
A real example: a Kubernetes service account token
Anyone who has wired up IRSA has already used this chain without naming it. A managed Kubernetes cluster can act as its own OIDC issuer, and the projected service account token mounted into the pod is a signed JWT from that issuer — same structure, same verification rules as everything above. It is not an ID token from a login flow: there is no nonce, no authorization code, no user. It's a web identity assertion, and it is verified exactly the same way. (The path below is the IRSA-specific projected token; a vanilla cluster mounts its own at /var/run/secrets/kubernetes.io/serviceaccount/token.)
A decoded example, anonymized (EXAMPLE1CLUSTER2ID3FOR4DEMO5ONLY stands in for a real cluster identifier; no cluster referenced here is real):
// header
{"alg": "RS256", "kid": "..."}
// payload
{
"iss": "https://oidc.eks.ap-northeast-2.amazonaws.com/id/EXAMPLE1CLUSTER2ID3FOR4DEMO5ONLY",
"sub": "system:serviceaccount:default:checkout-api",
"aud": "sts.amazonaws.com",
"exp": 1787011200,
"kubernetes.io": {
"namespace": "default",
"pod": { "name": "checkout-api-7d9f8b6c4-x2mlp" },
"serviceaccount": { "name": "checkout-api" }
}
}Watch what that buys. When an SDK inside the pod calls AssumeRoleWithWebIdentity, the cloud provider fetches the cluster's JWK Set and checks the RS256 signature itself. The cluster and the cloud provider never share a secret; the cluster only ever published a public key. One-time setup does register the issuer URL with IAM, but that's a pointer to where the public keys live, not a credential. And the sub claim, naming the exact namespace and service account, is what an IAM trust policy pins its condition on. That's how one pod gets a role and the pod beside it doesn't. AWS documents the setup under IAM roles for service accounts.
The hands-on version, substituting a reader's own issuer URL for the placeholder:
# 1. from inside the pod: the token it was handed
cat /var/run/secrets/eks.amazonaws.com/serviceaccount/token
# 2. from anywhere: ask the issuer where its keys live
curl -s https://oidc.eks.ap-northeast-2.amazonaws.com/id/EXAMPLE1CLUSTER2ID3FOR4DEMO5ONLY/.well-known/openid-configuration | jq .jwks_uri
# 3. fetch the key set
curl -s https://oidc.eks.ap-northeast-2.amazonaws.com/id/EXAMPLE1CLUSTER2ID3FOR4DEMO5ONLY/keys | jqNotice that none of those requests are authenticated. No token, no credential, no header. The key set is public on purpose, and that is the entire trick.
Try it — verify a token against its JWK Set
The token and key set below are pre-filled and matched — watch it come back verified, then edit either box (change a claim, change the kid) to see verification fail. Both the token and the key here are synthetic and shaped like the real EKS example above; the private key that produced this signature was thrown away right after signing, which is exactly why publishing the matching public half in a JWKS is harmless.
The embed above is our JWT debugger, which is worth a bookmark for the next time a token from your own services refuses to verify. For the other half of the JWT story, where these things should live once the browser has one, see JWT storage: localStorage, cookies, and the XSS problem. And if the cluster example raised more questions than it answered, why Kubernetes came after Docker covers the ground underneath it.
What this setup costs you
Verification now depends on an HTTP fetch. Cache the key set, or every token check becomes a round trip to your identity provider. But cache it badly and you get the opposite failure: a verifier that refetches the moment it sees an unfamiliar kid is a denial-of-service amplifier pointed at your own IdP, since anyone can mint garbage tokens with random kid values and make you go ask about each one. Bound the refresh rate. Most decent libraries do this for you, which is a good reason not to write the fetch yourself.
Discovery deserves the same suspicion. The jwks_uri in a discovery document is a URL your service will fetch, so pointing issuer configuration at something attacker-influenced turns key discovery into a request-forgery primitive. Pin your issuers.
Clocks drift, so allow a skew tolerance on exp and nbf. A minute or two, not an hour.
All of this assumes the issuer is competent. A JWKS moves the risk from "every service holds a forgery key" to "one service holds the forgery key and publishes its public half correctly." That's a much better place to be, but it is a relocation, not a deletion.
The short version
HS256 is fine when a single service signs its own tokens and checks them again later. Replicas don't change that. A second service reading those tokens does.
If a token crosses a service boundary
- Sign with RS256 or ES256, not HS256
- Publish a JWKS instead of distributing key files
- Put a
kidin the header, so rotation never needs a cutover date - Pin the algorithms you accept; never read
algfrom the token - Check
issandaudon every token, not just the signature
A signature tells you who signed a token. It will never tell you who it was for.