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 it
- header: metadata, including
alg(e.g. RS256) andkid(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:
| Field | Name | Meaning | Do you check it? |
|---|---|---|---|
alg | algorithm | how it is signed (RS256, ES256) | implicitly: you require RS256 |
kid | key ID | which key signed it, to pick from the JWKS | yes, to find the key |
iss | issuer | who issued it (the IdP) | yes = your trusted issuer |
sub | subject | who the token is about (the user; an email here) | you use it (map to your user) |
aud | audience | who the token is for (your client id) | yes = your app |
exp | expiration | when it stops being valid (unix time) | yes = not in the past |
iat | issued at | when it was created | informational |
nbf | not before | earliest it is valid | yes, if present |
jti | JWT ID | unique token id (replay / revocation) | optional |
The mental split that makes verification click:
- The signature answers "is this genuine and untampered?"
iss/aud/expanswer "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.
| JWT | JWKS | |
|---|---|---|
| What it is | one signed token | a set of public keys |
| Shape | header.payload.signature | JSON { "keys": [ ... ] } |
| Who holds it | the client, sent on each request | published by the issuer at a URL |
| Key involved | signed with the issuer private key | holds the public keys to verify |
| Changes | a new one every login / refresh | rarely, only on key rotation |
| Its job | proves who the user is | lets 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
kidis 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 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:
| Created | Stops being used | Safe to delete | |
|---|---|---|---|
| private key | at rotation | immediately, when the next rotation starts | right away (you never need an old private key again) |
| public key | at rotation | after the grace window | only 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
kidyou 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.
