Read a token: JWT, signatures and keys
- Lesson 6
- 50 min
- Module 2: Understand login and tokens
Lesson 6 of 19 · Authentication, Permissions and Multi-Tenant ApplicationsRead a token: JWT, signatures and keys
1. Identity and permission basics
2. Understand login and tokens
3. Sessions and application integration
4. Enforce resource access
5. Background work and administration
6. Validate and document the design
What you will be able to do
Open a token and read its three parts. Learn what the provider’s signature proves and what it does not, and how the API finds the right public key to check it.
Before you startWhat to know and to have ready
What you need to know
- The access token and the ID token of one sign-in, from Application flows.
- SHA-256 as a fingerprint of a text, from the PKCE example of the same lesson.
What you need on your computer
- The project after Application flows, with
scripts/login.py. - The provider running in its own terminal (
python -m idp), and a saved sign-in:.tokens/usr-sam.json. If you deleted it, runpython -m scripts.login --user usr-samagain.
This lesson adds one file, scripts/token.py. The tokens in this lesson are real tokens of the practice provider. They are expired, and a made-up local key signed them, so they open nothing. Tested on macOS (Apple silicon); the Windows commands are written but not tested.
A banknote
Section titled “A banknote”Look at a banknote. Anyone can read it: the value, the serial number, the bank that issued it. Nothing on it is secret.
Anyone can also check it. The bank publishes what a real note looks like: the watermark, the thread, the special ink. A shop owner checks those features without asking the bank. But only the bank can make a note that passes the check.
A real note proves two things: the bank made it, and nobody changed it. It does not prove that the person who hands it over owns it. A stolen note spends like any other.
The problem: a string that anyone can read and anyone can edit
Section titled “The problem: a string that anyone can read and anyone can edit”Sam’s access token is 732 characters long. It looks like noise, so it is easy to think that it is secret or that nobody can change it. Neither is true.
Here is a real access token from a sign-in of Camille, the same kind of token as Sam’s. It starts like this:
eyJhbGciOiJSUzI1NiIsImtpZCI6IjdIeGNCdGs0ZGpkcnNQekIiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg0MDAiLCJzdWIiOiJ1c3ItY2FtaWxsZSIsImF1ZCI6InRpY2tldC1hcGkiLCJleHAiOjE3OTE2MTE3NzAsIm5i…The author changed one word inside it: usr-camille became usr-grace, the owner of Larkfield. This edit is a constructed case, done for the course. A program that only decodes the token now reads:
sub "usr-grace"If the API trusted what it reads, Camille would now be Grace. So the API needs something that anyone can read but nobody can forge: a signature.
The idea: a JSON Web Token
Section titled “The idea: a JSON Web Token”The provider’s tokens are JSON Web Tokens (JWT, often said “jot”). A JWT has three parts, separated by two dots:
- The header: how the token is signed.
algis the algorithm,kidthe key ID,typthe kind of token. - The claims: what the token says. Each claim is one statement, a name and a value, such as
"sub": "usr-camille". - The signature: the provider’s proof that it made the first two parts, exactly as they are.
The header and the claims are JSON, written in base64url. Base64url is not encryption: it only writes bytes as text that is safe in an address and in an HTTP header. It takes the bytes 3 at a time, 24 bits, and writes them as 4 characters of 6 bits each. A 6-bit value from 0 to 63 picks one character from A–Z, a–z, 0–9, - and _.
A tiny example, with every bit visible. Every token’s header starts with the three characters {"a:
{ |
" |
a |
|
|---|---|---|---|
| Bytes | 123 | 34 | 97 |
| Bits | 01111011 |
00100010 |
01100001 |
| The same 24 bits in groups of 6 | 011110 = 30 |
110010 = 50 |
001001 = 9, then 100001 = 33 |
| Characters | e (30) |
y (50) |
J (9), then h (33) |
So {"a becomes eyJh, and the whole header {"alg":"RS256"} becomes eyJhbGciOiJSUzI1NiJ9. That is why almost every JWT starts with eyJ: it is base64url for {". Base64url drops the = padding at the end, and it uses - and _ where plain base64 uses + and /, because those two have a meaning in an address.
The claims of an access token
Section titled “The claims of an access token”Copy the script that reads tokens from the snapshot, and decode Sam’s saved access token:
cp ../nextia-learning-labs/C23/snapshots/end-of-m02/scripts/token.py scripts/python -m scripts.token decode --user usr-samCopy-Item ..\nextia-learning-labs\C23\snapshots\end-of-m02\scripts\token.py scripts\python -m scripts.token decode --user usr-samYour output shows usr-sam and your own key. Here is the same command for Camille’s token, as the author ran it with the whole token in place of --user usr-sam:
A JWT is three parts, separated by dots: 74, 319 and 342 characters.1. header (base64url JSON): {"alg": "RS256", "kid": "7HxcBtk4djdrsPzB", "typ": "at+jwt"}2. claims (base64url JSON): iss "http://localhost:8400" sub "usr-camille" aud "ticket-api" exp 1791611770 (2026-10-10 05:56:10 UTC) nbf 1791611170 (2026-10-10 05:46:10 UTC) iat 1791611170 (2026-10-10 05:46:10 UTC) jti "14dafe2628fe4233891734b84ad77238" client_id "ticket-cli" scope "tickets:read tickets:write members:manage"3. signature: dX880F0-mK2-D7Yu... (256 bytes)Decoding is not checking: anyone can read these parts. Only the signature, checked withthe provider's public key '7HxcBtk4djdrsPzB', shows that the provider made them.The claim names are short on purpose: every request carries them. Each one has a fixed meaning:
| Claim | Name | Camille’s value | What it says |
|---|---|---|---|
iss |
issuer | http://localhost:8400 |
Which provider made the token |
sub |
subject | usr-camille |
Who the token is about: a stable user ID that never changes |
aud |
audience | ticket-api |
Which API the token is for |
exp |
expiry | 05:56:10 UTC | Not valid after this moment |
nbf |
not before | 05:46:10 UTC | Not valid before this moment |
iat |
issued at | 05:46:10 UTC | When the provider made it |
jti |
token ID | 14dafe26… |
A unique ID for this one token |
client_id |
ticket-cli |
Which application asked for it | |
scope |
three scopes | What the application may do for Camille |
Times are Unix seconds: seconds since 1 January 1970, UTC. exp minus iat is 1791611770 − 1791611170 = 600 seconds: 10 minutes, as the provider’s expires_in said. The token has no organization and no role, as OAuth and OIDC decided.
The signature: easy to check, impossible to forge without the key
Section titled “The signature: easy to check, impossible to forge without the key”The provider signs with RS256: an RSA signature of the SHA-256 fingerprint of the first two parts, header.claims. RSA uses a key pair. The private key makes signatures, and it never leaves the provider’s folder .idp/keys/. The public key checks signatures, and anyone may have it. That is the banknote: everyone can check, only the bank can make.
A toy example shows the arithmetic with numbers small enough to follow. Real keys use numbers with more than 600 digits; these are far too small to be safe. The toy key pair is n = 3233, the public number e = 17 and the private number d = 2,753:
- The provider has a fingerprint to sign. Say it is the number 65.
- It signs with the private number: 652753 mod 3233 = 588. The signature is 588.
- The API checks with the public numbers: 58817 mod 3233 = 65. It equals the fingerprint, so the signature matches.
- Someone edits the claims, and their fingerprint is now 66. The old signature still gives 65, and 65 ≠ 66: refuse.
- To sign 66, the forger needs the private number. Without it, a guess such as 589 checks to 2,838, not 66.
(“mod 3233” means “the remainder after dividing by 3233”.) The real signature is 256 bytes: 342 base64url characters. Changing one letter in the claims changes the fingerprint completely, as the PKCE example showed, and the old signature no longer matches.
Text description of the diagram
Follow the token from left to right. The provider signs header.claims with its private key. The token travels through the browser or a script, and anyone on the way can read it. The API reads the kid in the header, takes the public key with that ID from the provider’s key list, and checks the signature. A match proves two things: the provider made the token, and not one character of the header or the claims changed since.
The provider’s keys: a JWKS
Section titled “The provider’s keys: a JWKS”The provider publishes its public keys as a JWKS, a JSON Web Key Set, at the address jwks_uri of its discovery document. Ask for it:
curl -s http://localhost:8400/jwks.json | python -m json.toolcurl.exe -s http://localhost:8400/jwks.json | python -m json.tool{ "keys": [ { "kty": "RSA", "key_ops": ["verify"], "n": "1Gad4mdVxOgzjDlPIplFn14xf3FjTSknTY8gXNTAC3UqJ7YwSKwuqPsfwYEmv1VI5-Dmx15c...", "e": "AQAB", "kid": "7HxcBtk4djdrsPzB", "use": "sig", "alg": "RS256" } ]}Each key is one JSON object. kty is the key type, RSA. n and e are the two public numbers, like 3233 and 17 in the toy, but n has 2,048 bits. kid is the key ID that tokens name in their header. use and key_ops say that this key checks signatures. The author shortened n here; yours is about 340 characters long. This list is from the run that made Camille’s token, so its kid is 7HxcBtk4djdrsPzB; yours is your own key’s ID. There is no private number in this list.
Why a list and a key ID, and not one key? Because keys change. A provider replaces its signing key from time to time, or at once when a key may have leaked. This is called key rotation. During a rotation, the list holds the old key and the new key, and each token names its key with kid. Try it:
-
Add a new key. Without
--activate, it is published but does not sign yet:Terminal window python -m idp rotate-keyNew key pHlo1LoZvVE4kFQ9: published. Activate it later: python -m idp activate-key pHlo1LoZvVE4kFQ9 -
List the keys:
Terminal window python -m idp keysMy9O_SxNpIg4Ff10 published signs new tokenspHlo1LoZvVE4kFQ9 publishedOpen
http://localhost:8400/jwks.jsonagain: it now has two keys. -
Let the new key sign. Use your own key ID from step 1:
Terminal window python -m idp activate-key pHlo1LoZvVE4kFQ9 -
Sign in again, and decode the new token’s header:
Terminal window python -m scripts.login --user usr-sampython -m scripts.token decode --user usr-samThe header now says
"kid": "pHlo1LoZvVE4kFQ9". Tokens with the oldkidstill have a published key, so they still check until they expire. Later,python -m idp retire-key <old kid>stops publishing the old key.
Why publish first and sign later? An API keeps the key list in memory for a while. A key that signs before the API has it gives tokens that the API cannot check yet. Token meaning measures how long that lasts.
Explore: change one thing in a real token
Section titled “Explore: change one thing in a real token”The explorer holds Camille’s real access token. Change one thing, and see which part of the token changes and whether the signature still matches. The right column shows all the API’s checks; the next lesson explains them one by one.
Explore
One access token: its three parts and the API's checks
Predict first: Someone changes sub from usr-camille to usr-grace after the provider signed the token. Which of the three parts of the token change? Does the signature still match?
The access token exactly as the provider issued it. Accepted: every check passed. The API's clock in this explorer: 2026-10-10 05:48:10 UTC, two minutes after the sign-in.
The token
header . claims . signature · changed = differs from the token as issued
eyJhbGciOiJSUzI1NiIsImtpZCI6IjdIeGNCdGs0ZGpkcnNQekIiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg0MDAiLCJzdWIiOiJ1c3ItY2FtaWxsZSIsImF1ZCI6InRpY2tldC1hcGkiLCJleHAiOjE3OTE2MTE3NzAsIm5iZiI6MTc5MTYxMTE3MCwiaWF0IjoxNzkxNjExMTcwLCJqdGkiOiIxNGRhZmUyNjI4ZmU0MjMzODkxNzM0Yjg0YWQ3NzIzOCIsImNsaWVudF9pZCI6InRpY2tldC1jbGkiLCJzY29wZSI6InRpY2tldHM6cmVhZCB0aWNrZXRzOndyaXRlIG1lbWJlcnM6bWFuYWdlIn0.dX880F0-mK2-D7Yu94CnoeV7_dujRDIBmC25k1F8eZrPlXlHlLmWNsSNfYvx4dt9Rk2nfpoec4eZhuWYJGf7k9NgoyApre6hxt0jpysfR5RoWofmMlNWBaZWQaMYjiGPXhCqzhdoJc2qnSEMyzr6jnDAWAUiJn89ejMObwNozADY6v9sANTiyGzxa5Va9ggl9tDw0syBpZU7bzRQsxm-PBljrpLX-yCP8wZswpINMkrO6Z33Pd0xCszmQLwEebF_ExG5n_8U-2LcrRyd2DaeKXplDgNtO32tbhGLLTSz7xQlJStwZHyQ_Y4dEgQlrsBYWuglka4EXcpKab6-VxIbgw
alg | "RS256" |
|---|---|
kid | "7HxcBtk4djdrsPzB" |
typ | "at+jwt" |
iss | "http://localhost:8400" |
|---|---|
sub | "usr-camille" |
aud | "ticket-api" |
exp | 17916117702026-10-10 05:56:10 UTC |
nbf | 17916111702026-10-10 05:46:10 UTC |
iat | 17916111702026-10-10 05:46:10 UTC |
jti | "14dafe2628fe4233891734b84ad77238" |
client_id | "ticket-cli" |
scope | "tickets:read tickets:write members:manage" |
3. Signature: The provider's real signature.
The API's checks, in order
- 1. A JWT: three base64url partspassed: header and claims decode
- 2. Algorithm on the allow-list (RS256)passed: alg is "RS256"
- 3. An access token (typ at+jwt)passed: typ is "at+jwt"
- 4. Key ID published by the providerpassed: kid "7HxcBtk4djdrsPzB"
- 5. Signature matches that public keypassed: matches the public key with this kid
- 6. Required claims presentpassed: exp, iat, iss, aud, sub
- 7. Inside nbf … exp (30 s leeway)passed: exp is in 480 s
- 8. Issuer is our providerpassed: iss is "http://localhost:8400"
- 9. Audience is ticket-apipassed: aud is "ticket-api"
GET /v1/me → 200 OK
The API accepts the token: the caller is the token's sub.
Text description of the explorer
The explorer shows a real access token of the practice provider: a sign-in of usr-camille, 737 characters. It is expired and was signed with a local mock key. The token is shown in three coloured parts: header, claims and signature. Below are the decoded header and claims, and a note on the signature. On the right, the nine checks of the API are listed in order, then the API’s answer to GET /v1/me. The API’s clock in the explorer is two minutes after the sign-in, so the unchanged token is valid there.
As issued, every check passes and the API answers 200. With “sub: usr-grace, edited”, only the middle part, the claims, changes. The header and the signature stay the same. The signature check, check 5, fails with bad_signature, because the old signature was made for the old claims.
The other buttons change one thing each, and one check refuses each change. The audience check refuses aud billing-api (wrong_audience). The time check refuses exp 5 minutes ago (expired) and nbf in 5 minutes (not_yet_valid). It accepts exp 10 seconds ago, inside the 30-second leeway. The issuer check refuses iss localhost:9999 (wrong_issuer), and the claims check refuses a missing sub (missing_claim). The token type check refuses Camille’s ID token (wrong_token_type); with that check off, the audience check refuses it (wrong_audience). The allow-list refuses alg none and alg HS256 (algorithm_not_allowed). The key check refuses a kid that the provider never published (unknown_key). The signature check refuses another key with the provider’s kid (bad_signature). The format check refuses the string abc.def (malformed). For the course, the author signed every changed token again, except the edited sub. The provider’s key or another key signed it, so that one check at a time refuses it.
What this tells us. An edit changes the part that holds it. The signature cannot follow, because only the private key can make a new one. So the signature check catches every edit, whatever the edit says.
What a signature proves, and what it does not
Section titled “What a signature proves, and what it does not”| A valid signature proves | A valid signature does not prove |
|---|---|
| The provider with this key made the token. | That the token is secret. Anyone can decode it, so a token must never hold a password or other secret data. |
| Not one character of the header or the claims changed. | That the token is for this API. A token for billing-api has a valid signature too. |
| That the token is still valid. An expired token keeps its valid signature forever. | |
That the sender is the person in sub. Like a banknote, a token works for whoever holds it: it is a bearer token. |
The right column is why a valid signature is the start, not the end. Token meaning adds the checks for the audience, the time, the issuer and the token type. Module 3 makes the bearer risk smaller: tokens stay on the server, and the browser gets only a cookie.
When to use self-contained tokens, and what they cost
Section titled “When to use self-contained tokens, and what they cost”A JWT is self-contained: the API checks it with a public key, and it needs no call to the provider for each request. That makes it fast, and it keeps the provider out of every request. The costs come from the same fact:
- No early end. The provider cannot withdraw a JWT before
exp. A disabled person’s token works until it expires. Short lifetimes keep this small: 10 minutes here. Module 5 measures it. - Size. 732 characters travel with every request.
- Everyone can read it. Keep claims small and harmless: IDs, not personal details. The access token here has no name and no email.
Some providers issue opaque tokens instead: random strings that only the provider can read. The API then asks the provider about each token, a call named introspection. That gives an early end, at the cost of one extra call per request. This course uses JWTs, as most providers do for APIs.
Your turn: the ID token
Section titled “Your turn: the ID token”Decode the ID token of the same sign-in, and find three differences from the access token. Predict them first.
python -m scripts.token decode --user usr-sam --id-tokenShow the answer
The author’s run, after a new sign-in:
A JWT is three parts, separated by dots: 70, 298 and 342 characters.1. header (base64url JSON): {"alg": "RS256", "kid": "My9O_SxNpIg4Ff10", "typ": "JWT"}2. claims (base64url JSON): iss "http://localhost:8400" sub "usr-sam" aud "ticket-cli" exp 1791613209 (2026-10-10 06:20:09 UTC) iat 1791612609 (2026-10-10 06:10:09 UTC) auth_time 1791612609 (2026-10-10 06:10:09 UTC) name "Sam" email "sam@larkfield.example" email_verified true nonce "7jPwIjXDl9Xe3KTUl3bh3g"3. signature: R8x4xTemR8kFZVaD... (256 bytes)Decoding is not checking: anyone can read these parts. Only the signature, checked withthe provider's public key 'My9O_SxNpIg4Ff10', shows that the provider made them.typisJWT, notat+jwt: the header says that this is not an access token.audisticket-cli, the application, notticket-api.- It has the person’s
name,email,auth_time(when Sam signed in) and thenonce; it has noscopeand noclient_id.
The same provider and the same key signed both tokens, so both signatures are valid. The signature alone cannot tell the API which token it got: the header and the claims can.
Common mistake
Trusting a token that was only decoded
Symptom (constructed): a helper script decides who is calling with jwt.decode(token, options={"verify_signature": False}). The edited copy of Camille’s token passes, and the script treats the caller as usr-grace, the owner of Larkfield.
Cause: decoding only reads base64url. It checks nothing. scripts/login.py and scripts/token.py decode without checking on purpose, to show a token, and both say so in their output: “decoded, not checked”.
Diagnose: search the code for places that read a token without checking it:
grep -rn "verify_signature\|get_unverified" --include='*.py' ticket_api scripts idpSelect-String -Path ticket_api\*.py, scripts\*.py, idp\*.py -Pattern "verify_signature|get_unverified"In your project at this point, eight lines match. Seven are in scripts/, to show tokens. One is in idp/app.py: it reads only the header, to choose the key. After the next lesson, a ninth line is in ticket_api/auth.py, which also reads only the header, to choose the check. Any other match that decides access is the bug.
Fix:
- Decide access only with a checked token: the API’s token validator, built in the next lesson.
- Read an unchecked header only to choose how to check, never whether to trust.
- Add a test that sends an edited token and expects 401.
scripts.token caseshas one:tampered.
Check your understanding
Why do almost all JWTs start with the same three characters, eyJ? What does that tell you about how secret a token's content is?
Show the answer
eyJ is base64url for the two characters {", with which every JSON header starts. Base64url only rewrites bytes as safe characters, 3 bytes as 4 characters; it is not encryption. So anyone who has a token can read its header and its claims. Never put a secret in a token, and treat the token itself as secret, because it works for whoever holds it.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
The API has the provider's public key. Can the API (or an attacker who reads /jwks.json) make a token that the API accepts? Why?
Show the answer
No. The public key can only check signatures. Making a signature needs the private key, which stays in the provider’s .idp/keys/. In the toy example, the public numbers 3233 and 17 check the signature 588. Without the private number 2753, nobody can compute the signature for a new fingerprint. That is why the provider can publish the key list to everyone.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
A token has a valid signature from your provider. Name three reasons why the API may still have to refuse it.
Show the answer
It may be for another API (aud is not ticket-api). It may be expired (exp is in the past) or not valid yet (nbf in the future). It may be an ID token, not an access token (typ JWT, aud the application). A valid signature also does not prove that the sender is the person in sub: anyone who holds the token can send it.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- A JWT has three base64url parts: the header (
alg,kid,typ), the claims and the signature. Base64url is not encryption:{"becomeseyJ, and anyone can read every claim. - The access token’s claims:
iss,sub,aud,exp,nbf,iat,jti,client_id,scope, with times in Unix seconds. No organization, no role, no name. - RS256: the provider signs with its private key; anyone checks with the public key. An edited claim no longer matches its signature.
- The provider publishes its public keys as a JWKS at
/jwks.json; the header’skidnames the key. During a key rotation, the list holds two keys. - A valid signature proves who made the token and that nothing changed. It does not prove the audience, the time, the token type, or who sends it.
- Next: the API checks all of it, in order, and says which check refused a token.
Next: Token meaning