Blogs

Digital Signatures: From JWT to JWKS to Key Rotation

2026-06-2713 min read

Private signs, public verifies. Follow that one idea all the way into JWTs, the JWKS keyring, the kid header, and how an identity provider rotates signing keys with zero downtime.

Digital Signatures: From JWT to JWKS to Key Rotation

This is the third post in a series on what public-private keys actually do. The overview names the three primitives; this one goes deep on the one you meet most: signing. We will start from the bare idea and follow it, without a single leap, all the way into how a real identity provider verifies your login and rotates its keys.

Why signing is the one to really understand

Of the three primitives, signing is the workhorse. Scan the list of "auth things" in a normal stack (JWT, OIDC, SAML, SSH login, git signing, passkeys, TLS certificates, code signing, DKIM) and almost all of them are signatures, not encryption. If you only deeply learn one primitive, learn this one, because understanding it is what lets you actually debug "signature invalid", reason about token expiry, and not be afraid of the word "rotation".

The direction

Signing is the mirror image of encryption:

The private key signs. The public key verifies.

Only the holder of the private key can produce a valid signature, and anyone with the public key can check it. That is the entire trust model: one secret signer, unlimited public verifiers.

It answers a different question than encryption. Encryption asks "how do I keep this secret?" Signing asks "who produced this, and was it changed in transit?" A valid signature proves both: it came from the private-key holder, and not one byte has been altered since.

A signature does not hide anything. The data it covers stays fully readable. This matters enormously for the JWT discussion below.

A JWT is just a signed statement

A JWT (JSON Web Token) is the most common signed object a backend dev handles. It is three base64url parts joined by dots: header.payload.signature.

A JWT is a signed statement; the JWKS is the public keyring that verifies itA JWT is a signed statement; the JWKS is the public keyring that verifies it

  • header: metadata, including alg (e.g. RS256) and kid (which key signed this, more on that soon).
  • payload: the claims, sub (subject, often an email), iss (issuer), aud (audience), exp (expiry). Plain base64url. Readable by anyone. Not secret.
  • signature: the header and payload, signed with the issuer's private key.

So when your identity provider issues a token, it signs it with a private key only it holds. Your backend then verifies that signature with the matching public key. If it checks out, the backend trusts the claims, because only the issuer could have produced that signature.

// header
{ "alg": "RS256", "kid": "ak-2026-06" }
// payload  (readable by anyone, do not put secrets here)
{ "sub": "you@example.com", "iss": "https://id.example.com", "exp": 1782533046 }
// signature
//   RS256( base64(header) + "." + base64(payload), ISSUER_PRIVATE_KEY )

Every field, and which ones you actually verify

The payload is a bag of claims, statements about the token. They are called claims (not "fields" or "data") on purpose: each one is an assertion the issuer makes about the subject, "I, the IdP, claim this user's email is X, that I issued this, and that it expires at Y." You trust a claim only because the signature vouches for it. The token is the issuer claiming facts; verification is you deciding to accept those claims. (SAML calls the whole token an assertion for exactly this reason.) A handful of claims are standard, and the difference between "I read this field" and "I verify this field" is where security bugs hide:

FieldNameMeaningDo you check it?
algalgorithmhow it is signed (RS256, ES256)implicitly: you require RS256
kidkey IDwhich key signed it, to pick from the JWKSyes, to find the key
ississuerwho issued it (the IdP)yes = your trusted issuer
subsubjectwho the token is about (the user; an email here)you use it (map to your user)
audaudiencewho the token is for (your client id)yes = your app
expexpirationwhen it stops being valid (unix time)yes = not in the past
iatissued atwhen it was createdinformational
nbfnot beforeearliest it is validyes, if present
jtiJWT IDunique token id (replay / revocation)optional

The mental split that makes verification click:

  • The signature answers "is this genuine and untampered?"
  • iss / aud / exp answer "is it from someone I trust, meant for me, and still valid?"

A valid signature alone is not enough. This is the gotcha that bites people: if you skip aud and iss, a perfectly-signed token that was minted for a different app (or by a different issuer you also happen to trust the keys of) sails right through at yours. So you always verify all of: signature, issuer, audience, and expiry. A "valid signature" only means "this issuer signed something", not "this token was meant for me".

Pin the algorithm too. Historically, libraries that accepted alg from the token header had a famous class of bugs (alg: none, or swapping RS256 for HS256 so the public key gets used as a symmetric secret). Decide the algorithm on your side and require it; never trust the header to tell you how to verify.

JWT vs JWKS: they are not the same thing

These two get confused constantly, so let us pin them down. A JWT is the one signed token a user carries. A JWKS (JSON Web Key Set) is the set of public keys the server uses to check that token's signature. One is the message; the other is the keyring.

JWTJWKS
What it isone signed tokena set of public keys
Shapeheader.payload.signatureJSON { "keys": [ ... ] }
Who holds itthe client, sent on each requestpublished by the issuer at a URL
Key involvedsigned with the issuer private keyholds the public keys to verify
Changesa new one every login / refreshrarely, only on key rotation
Its jobproves who the user islets anyone verify a JWT offline

The point of a JWKS is that your backend does not have to call the identity provider to validate every token. The provider publishes only its public keys, so your backend fetches them once and verifies signatures locally: no per-request round trip, and key rotation handled automatically.

What kid is (and is not)

You already understand public and private keys. kid ("key ID") trips people up because it sounds cryptographic, but it is not. It is just a label on a keypair.

  • The private key signs.
  • The public key verifies.
  • The kid is the name of that pair. Not secret, not cryptographic, just an identifier.

Why have a label at all? Because a JWKS can hold more than one public key at once (during rotation, or when supporting multiple algorithms). With several keys published, the verifier needs to know which one to use. So the issuer stamps each keypair with a kid and writes that same kid into:

  • the JWT header ("kid": "ak-2026-06"), meaning "I was signed by the key named ak-2026-06", and
  • the matching JWKS entry, which holds that key's public half.

Match the two strings, you have found the right public key, you verify the signature. Think of a keyring where the metal keys are the actual public/private keys and the little paper tag on each ("front door", "garage") is the kid. The tag opens nothing; it just tells you which key to grab.

Key rotation: why a JWKS sometimes holds two keys

Here is where it all comes together, and where the timescales surprise people. A signing key lives a long time (weeks to months). The tokens it signs live a short time (minutes to an hour). So one key signs thousands of tokens before it is ever retired.

When the issuer rotates, it does not modify a key. It generates a brand-new keypair with a new kid, starts signing with the new private key, and publishes the new public key. The old public key has to stick around for a while, though, because tokens it already signed are still valid until they expire.

A signing key rotates: the old public key lingers only until its last-signed token expiresA signing key rotates: the old public key lingers only until its last-signed token expires

Walk the timeline with concrete numbers (15-minute token lifetime, hourly-ish rotation):

n          K1 starts signing (kid=K1); tokens have a 15-min lifetime
n .. n+1h  K1 signs tokens the whole hour
n+1h       ROTATE: K1 stops signing (becomes "old"), K2 starts signing
           the LAST K1 token (signed ~n+1h) is still valid until n+1h+15m
n+1h..+15m JWKS holds BOTH keys: { K1 (verify-only), K2 (signing) }
n+1h+15m   the last K1 token expires -> drop K1 -> JWKS = { K2 }  again

So the JWKS holds two keys only during that overlap window. The rule the issuer follows is simply: keep an old public key for at least the maximum token lifetime (plus a little clock-skew margin) after you stop signing with it. It does not track individual tokens (JWTs are stateless; nobody keeps a list). It just waits out the known max lifetime, after which every token signed by the old key has hit its own exp and is rejected on expiry alone. Then the old key is safe to drop.

There is a mirror-image care on the other end too: publish the new public key in the JWKS and give verifiers time to refetch before you start signing with it. Verifiers cache the JWKS, so if you sign with a brand-new kid the instant you create it, a verifier with a slightly stale cache will fail to find the key and reject good tokens. Introduce the key, wait out the cache, then switch signing over.

The two keys retire on different schedules

The private and public halves are born together at rotation but leave at different times:

CreatedStops being usedSafe to delete
private keyat rotationimmediately, when the next rotation startsright away (you never need an old private key again)
public keyat rotationafter the grace windowonly after the grace window

The old private key can (and ideally should) be destroyed the instant you stop signing with it. Keeping an unused private key around is pure liability: if it leaked, an attacker could forge tokens that still verify during the grace window. The old public key must linger just long enough to verify the tokens it already vouched for, then it goes.

Caching survives rotation for free

Your backend caches public keys by kid, so it is not refetching the JWKS on every request. Rotation does not break this:

  • Token arrives with a kid you have cached: cache hit, verify locally, no network call.
  • Token arrives with a new, unknown kid: one JWKS refetch, cache the new key, carry on.

That is exactly what a library like PyJWKClient does: read the token's kid, look it up in the cached JWKS, and on a miss, refetch. Caching saves round trips and survives rotation automatically.

The whole backend integration, in one function

Strip away the framework and verifying a JWT is just this: fetch the signing key by the token's kid, then check the signature, issuer, audience, and expiry.

def verify(token: str) -> dict:
    # PyJWKClient reads the token's `kid`, finds that key in the JWKS
    # (fetching + caching as needed), and hands back the public key.
    key = jwks_client.get_signing_key_from_jwt(token).key
    return jwt.decode(
        token,
        key,
        algorithms=["RS256"],
        issuer="https://id.example.com",
        audience="my-api",
    )

Every "log in with X" system (Auth0, Clerk, WorkOS, Authentik, your own OIDC server) reduces to this on the backend. Swap the issuer and the JWKS URL and the code is identical, because it is all the same primitive: private signs, public verifies.

Signing is everywhere else too

Once you see signing as "prove origin + integrity", you spot it all over:

  • SSH key login: your client signs a server challenge with your private key; the server verifies with the public key in authorized_keys. (Modern SSH login is signing, not encryption, despite the old "the server encrypts a secret only you can decrypt" telling.)
  • Git commit/tag signing: you sign; anyone verifies with your public key.
  • Passkeys / WebAuthn: your device signs a challenge, no password ever sent.
  • TLS certificates: a Certificate Authority signs the server's cert, forming a chain of trust your browser checks.
  • Code signing & DKIM: Apple notarization, signed APKs, npm provenance, and email anti-spoofing all verify a signature against a published public key.

The takeaway

Signing is the private-to-public direction: only the secret holder can produce a signature, anyone can verify it, and the data stays readable (a signature proves, it does not hide). A JWT is a signed statement; a JWKS is the public keyring that verifies it; kid is just the label that points a token at the right key; and rotation is the careful dance of introducing a new keypair while letting the old public key linger exactly one token-lifetime.

Next in the primitives series: Key Exchange, where two strangers agree on a shared secret over an open wire. And if you want to see where these signed tokens come from, OAuth2 vs OIDC vs SAML maps the protocols that actually mint and exchange them. Or return to the overview.