Skip to content

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

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 readyAbout 50 minutes

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, run python -m scripts.login --user usr-sam again.

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.

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 provider’s tokens are JSON Web Tokens (JWT, often said “jot”). A JWT has three parts, separated by two dots:

  1. The header: how the token is signed. alg is the algorithm, kid the key ID, typ the kind of token.
  2. The claims: what the token says. Each claim is one statement, a name and a value, such as "sub": "usr-camille".
  3. 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.

Copy the script that reads tokens from the snapshot, and decode Sam’s saved access token:

Terminal window
cp ../nextia-learning-labs/C23/snapshots/end-of-m02/scripts/token.py scripts/
python -m scripts.token decode --user usr-sam

Your 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 with
the 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:

  1. The provider has a fingerprint to sign. Say it is the number 65.
  2. It signs with the private number: 652753 mod 3233 = 588. The signature is 588.
  3. The API checks with the public numbers: 58817 mod 3233 = 65. It equals the fingerprint, so the signature matches.
  4. Someone edits the claims, and their fingerprint is now 66. The old signature still gives 65, and 65 ≠ 66: refuse.
  5. 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.

On the left, the identity provider signs: the header and the claims and the private key with kid 7HxcBtk4djdrsPzB, which is only in .idp/keys/, go into 'sign with RS256'. The result is the token, header.claims.signature, 732 characters, which anyone can read. A dotted arrow shows that the provider publishes the public half of the same key pair. On the right, ticket-api checks: the public key from /jwks.json with the header's kid and the token go into 'check with RS256'. Two results: match, which means the provider made it and nothing changed since; or no match, which means refuse with bad_signature.
The provider signs with its private key; the API checks with the public half, found by the key ID. Only the provider can sign; anyone can check.
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 publishes its public keys as a JWKS, a JSON Web Key Set, at the address jwks_uri of its discovery document. Ask for it:

Terminal window
curl -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:

  1. Add a new key. Without --activate, it is published but does not sign yet:

    Terminal window
    python -m idp rotate-key
    New key pHlo1LoZvVE4kFQ9: published. Activate it later: python -m idp activate-key pHlo1LoZvVE4kFQ9
  2. List the keys:

    Terminal window
    python -m idp keys
    My9O_SxNpIg4Ff10 published signs new tokens
    pHlo1LoZvVE4kFQ9 published

    Open http://localhost:8400/jwks.json again: it now has two keys.

  3. Let the new key sign. Use your own key ID from step 1:

    Terminal window
    python -m idp activate-key pHlo1LoZvVE4kFQ9
  4. Sign in again, and decode the new token’s header:

    Terminal window
    python -m scripts.login --user usr-sam
    python -m scripts.token decode --user usr-sam

    The header now says "kid": "pHlo1LoZvVE4kFQ9". Tokens with the old kid still 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.

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

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

1. Header
alg"RS256"
kid"7HxcBtk4djdrsPzB"
typ"at+jwt"
2. Claims
iss"http://localhost:8400"
sub"usr-camille"
aud"ticket-api"
exp17916117702026-10-10 05:56:10 UTC
nbf17916111702026-10-10 05:46:10 UTC
iat17916111702026-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. 1. A JWT: three base64url partspassed: header and claims decode
  2. 2. Algorithm on the allow-list (RS256)passed: alg is "RS256"
  3. 3. An access token (typ at+jwt)passed: typ is "at+jwt"
  4. 4. Key ID published by the providerpassed: kid "7HxcBtk4djdrsPzB"
  5. 5. Signature matches that public keypassed: matches the public key with this kid
  6. 6. Required claims presentpassed: exp, iat, iss, aud, sub
  7. 7. Inside nbf … exp (30 s leeway)passed: exp is in 480 s
  8. 8. Issuer is our providerpassed: iss is "http://localhost:8400"
  9. 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.

A real access token of the practice provider (a sign-in of usr-camille; expired, signed with the author's local mock key). The API's answers are the real answers to the same made-up tokens (scripts.token cases).
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.

Decode the ID token of the same sign-in, and find three differences from the access token. Predict them first.

Terminal window
python -m scripts.token decode --user usr-sam --id-token
Show 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 with
the provider's public key 'My9O_SxNpIg4Ff10', shows that the provider made them.
  1. typ is JWT, not at+jwt: the header says that this is not an access token.
  2. aud is ticket-cli, the application, not ticket-api.
  3. It has the person’s name, email, auth_time (when Sam signed in) and the nonce; it has no scope and no client_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:

Terminal window
grep -rn "verify_signature\|get_unverified" --include='*.py' ticket_api scripts idp

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:

  1. Decide access only with a checked token: the API’s token validator, built in the next lesson.
  2. Read an unchecked header only to choose how to check, never whether to trust.
  3. Add a test that sends an edited token and expects 401. scripts.token cases has 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.

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.

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.

Recap

  • A JWT has three base64url parts: the header (alg, kid, typ), the claims and the signature. Base64url is not encryption: {" becomes eyJ, 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’s kid names 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