Skip to content

Token meaning

  • Lesson 7
  • 55 min
  • Module 2: Understand login and tokens

What you will be able to do

Make the API check every access token before it trusts it: algorithm, type, key, signature, time, issuer and audience. Refuse a bad token with a clear reason. Keep the provider’s keys in a cache, so that checking stays fast.

Before you startWhat to know and to have readyAbout 55 minutes

What you need to know

What you need on your computer

  • The project after Read a token, with scripts/login.py and scripts/token.py.
  • The provider running in its own terminal (python -m idp). PostgreSQL and Azurite running.

After this lesson, your project matches the snapshot end-of-m02 and has 181 tests. Every bad token in this lesson is a constructed case: made for the course with your practice provider’s key, sent to your own API. Tested on macOS (Apple silicon); the Windows commands are written but not tested. The times were measured on an Apple M4 computer (16 GB, macOS) with little else running; your numbers will differ, but the shape stays the same.

A steward stands at the door of a concert hall. You hand over a ticket. The steward checks the hologram first: is it a real ticket? Then the details. Is it from this hall’s box office? Is it for this hall, not the theatre next door? Is it for tonight? Is it a ticket at all, or the cloakroom receipt?

A real hologram on a ticket for the theatre next door is still the wrong ticket. Each check stops a different mistake. The steward also gives a little time: a person who arrives two minutes after the doors close still gets in.

The problem: a check that stops at the signature

Section titled “The problem: a check that stops at the signature”

Read a token ended with a list: a valid signature does not prove the audience, the time or the token type. How bad is it to stop there? The author wrote an API check that tests only the signature, wrong on purpose. It used the right key and allowed only RS256, and nothing more. Then it received the 15 made-up tokens of this lesson:

accepted valid sub=usr-sam aud=ticket-api
refused no_token
accepted expired sub=usr-sam aud=ticket-api
accepted expired_within_leeway sub=usr-sam aud=ticket-api
accepted not_yet_valid sub=usr-sam aud=ticket-api
accepted wrong_audience sub=usr-sam aud=billing-api
accepted wrong_issuer sub=usr-sam aud=ticket-api
accepted id_token sub=usr-sam aud=help-desk-web
refused alg_none
refused hs256_public_key
refused unknown_key
refused wrong_key_same_kid
refused tampered
accepted missing_sub sub=None aud=ticket-api
refused not_a_jwt
A signature-only check accepted 8 of 15 tokens; the full check accepts 2 (valid, expired_within_leeway).

It accepted six tokens that it must refuse. One expired 5 minutes ago, and one is not valid yet. One was made for billing-api, and one comes from another issuer. The last two are Sam’s ID token and a token without sub. Each of them has a real signature. Only the claims say that they are wrong.

The idea: check the meaning, in a fixed order

Section titled “The idea: check the meaning, in a fixed order”

A token means “this provider says that this person may use this API, from this moment to that moment”. The API checks every part of that sentence. ticket_api/auth.py runs these checks in order and stops at the first one that fails:

  1. A JWT at all: three base64url parts. Otherwise malformed.
  2. The algorithm is on the allow-list. The list is fixed in the API: RS256 only. The token’s own alg never chooses the check. Otherwise algorithm_not_allowed.
  3. An access token: the header’s typ is at+jwt. An ID token says JWT. Otherwise wrong_token_type.
  4. A published key: the kid is in the provider’s key list. Otherwise unknown_key.
  5. The signature matches that key. Otherwise bad_signature.
  6. The required claims are there: exp, iat, iss, aud and sub. Otherwise missing_claim.
  7. The time: now is between nbf and exp, with 30 seconds of leeway. Otherwise not_yet_valid or expired.
  8. The issuer is our provider. Otherwise wrong_issuer.
  9. The audience is ticket-api. Otherwise wrong_audience.

Why check the algorithm before anything else? Because an attacker writes the header. With alg: none, a careless library skips the signature. With alg: HS256, a careless library uses the public key as a shared secret, and anyone can read the public key. The second attack is called algorithm confusion. A fixed allow-list stops both before the signature check starts.

Why the leeway? Two computers rarely have exactly the same time. If the provider’s clock is a few seconds ahead, a new token can look “not valid yet” to the API. A small leeway accepts that difference. Here is the rule with every value visible. The API’s clock says 1,000; the leeway is 30 seconds:

Token Rule Result
exp = 990 (expired 10 s ago) refuse if exp ≤ 1,000 − 30 = 970 990 > 970: accepted
exp = 700 (expired 5 minutes ago) refuse if exp ≤ 970 700 ≤ 970: refused, expired
nbf = 1,300 (valid in 5 minutes) refuse if nbf > 1,000 + 30 = 1,030 1,300 > 1,030: refused, not_yet_valid

So a token is accepted for up to 30 seconds after its exp. That is the cost of the leeway. Keep it small: seconds, not minutes.

Each refusal is a 401 with the code invalid_token. The API also names the check in the header X-Token-Check, and it sends the standard header WWW-Authenticate: Bearer, so a client knows that it needs a new token. The token itself is never written to a log.

Try the checks on Camille’s real token. This time, start with their ID token:

Explore

Which check refuses which token?

Camille's real ID token from the same sign-in, sent as an access token. Refused by check 3: wrong_token_type. 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

eyJhbGciOiJSUzI1NiIsImtpZCI6IjdIeGNCdGs0ZGpkcnNQekIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg0MDAiLCJzdWIiOiJ1c3ItY2FtaWxsZSIsImF1ZCI6InRpY2tldC1jbGkiLCJleHAiOjE3OTE2MTE3NzAsImlhdCI6MTc5MTYxMTE3MCwiYXV0aF90aW1lIjoxNzkxNjExMTcwLCJuYW1lIjoiQ2FtaWxsZSIsImVtYWlsIjoiY2FtaWxsZUBsYXJrZmllbGQuZXhhbXBsZSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJub25jZSI6IkZNVlhJLUVsNWtWZW1EMzRTZW5makEifQ.QR15TTMFwIa0G4n1kNk_ftk7DU_EVCIEPyKc3bLSE3Igt8ZhJjryOGBUCQ8ujZn2w38Nn6Ee9Fu2vIlG8CdFqj8UOqlst4DUDpuGhPLeNVfueQ2rz0G4h1SKApmpaTL_KROHSU7kaYiUNV7jPCQuzOmlUt17XiqTFxCU9cYQhfSMgZ1tX5IgTzMZLEDpFYRcvauNHa04f7fSzEq-Qa38BNKDyILRv8D4M9LyW4rHXEtocRn-HjHKTsizdb-2s_u_grnDRIMuEBAeQqpAIbSv1W3Lj6lqYUclM3mInLSOWnFhyYpOrKmf93mf7qRrETZRKZOD6J1U5GKS1-u4bZ5Z3A

1. Header
alg"RS256"
kid"7HxcBtk4djdrsPzB"
typ"JWT"
2. Claims
iss"http://localhost:8400"
sub"usr-camille"
aud"ticket-cli"
exp17916117702026-10-10 05:56:10 UTC
iat17916111702026-10-10 05:46:10 UTC
auth_time17916111702026-10-10 05:46:10 UTC
name"Camille"
email"camille@larkfield.example"
email_verifiedtrue
nonce"FMVXI-El5kVemD34SenfjA"

3. Signature: The provider's real signature of the ID token.

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)refused: typ is "JWT" → wrong_token_type
  4. 4. Key ID published by the providernot reached: not reached
  5. 5. Signature matches that public keynot reached: not reached
  6. 6. Required claims presentnot reached: not reached
  7. 7. Inside nbf … exp (30 s leeway)not reached: not reached
  8. 8. Issuer is our providernot reached: not reached
  9. 9. Audience is ticket-apinot reached: not reached

GET /v1/me → 401 invalid_token

X-Token-Check: wrong_token_type

“This is not an access token (an ID token is for the application, not the API).”

Camille's real access token and ID token from one sign-in at the practice provider (expired; signed with the author's local mock key). Each changed token gets the API's real answer to the same made-up token (scripts.token cases).
Text description of the explorer

The explorer starts with Camille’s real ID token from the same sign-in, sent as an access token. With the token type check on, check 3 refuses it, because its typ is JWT, not at+jwt. The API answers 401 with X-Token-Check: wrong_token_type. The message says: “This is not an access token (an ID token is for the application, not the API).”

With the token type check off, as for Keycloak or Entra ID, checks 4 to 8 pass. The key is published, the signature is real and the claims are present. The time is inside the window, and the issuer is the practice provider. Check 9 refuses it: its audience is ticket-cli, the application, so the API answers wrong_audience, “The token is not for ticket-api.”

The other buttons change one thing in Camille’s access token. Exactly one check refuses each change, in the order of the list. The format check refuses abc.def (malformed). The allow-list refuses alg none and HS256 (algorithm_not_allowed). The key check refuses an unpublished kid (unknown_key). The signature check refuses another key and an edited sub (bad_signature). The claims check refuses a missing sub (missing_claim). The time check refuses nbf in 5 minutes (not_yet_valid) and exp 5 minutes ago (expired). The issuer check refuses iss localhost:9999 (wrong_issuer), and the audience check refuses aud billing-api (wrong_audience). The API accepts exp 10 seconds ago, inside the 30-second leeway.

What this tells us. The audience check is the one that matters most. It refuses an ID token even when the provider writes no useful typ, and it refuses every token made for another API. The typ check is a cheap extra that gives a clearer message when the provider supports it.

Amira’s check lives in ticket_api/auth.py. Here is its core: the three header checks, the key, and one call to PyJWT that checks the signature and the claims together.

ticket_api/auth.py (TokenValidator.validate, shortened)
header = jwt.get_unverified_header(token) # only to choose the check
if header.get("alg") not in ALGORITHMS: # ["RS256"], fixed in the code
raise TokenRejected("algorithm_not_allowed", ...)
if self.check_type and header.get("typ") not in ACCESS_TOKEN_TYPES:
raise TokenRejected("wrong_token_type", ...)
key = self.provider.key(header.get("kid", "")) # from the cached JWKS
if key is None:
raise TokenRejected("unknown_key", ...)
claims = jwt.decode(
token, key.key,
algorithms=ALGORITHMS, # the allow-list again
audience=self.audience, # OIDC_AUDIENCE, ticket-api
issuer=self.provider.issuer, # OIDC_ISSUER
leeway=self.leeway, # TOKEN_LEEWAY_SECONDS, 30
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)

Each PyJWT error becomes one named refusal, for example ExpiredSignatureError becomes expired. A checked token becomes an Identity: the sub, the client_id, the scopes and the expiry. A new endpoint, GET /v1/me, shows the caller as the API sees them. The identity comes from the token. The memberships come from the API’s own tables, because the token has none.

  1. Copy the files of this lesson from the snapshot end-of-m02. They are the token check, the new endpoint, its settings and error answers, and their tests:

    Terminal window
    S=../nextia-learning-labs/C23/snapshots/end-of-m02
    for f in ticket_api/auth.py ticket_api/me.py ticket_api/security.py ticket_api/config.py \
    ticket_api/errors.py ticket_api/main.py ticket_api/models.py ticket_api/repository.py \
    contract/openapi.json pyproject.toml compose.yaml .env.example \
    tests/conftest.py tests/test_identity_provider.py tests/test_tokens.py tests/test_me.py; do
    cp "$S/$f" "$f"
    done

    The API’s version is now 1.4.0, and its contract has the new endpoint.

  2. Open .env and add the two new lines of .env.example at the end:

    .env (two new lines at the end)
    OIDC_ISSUER=http://localhost:8400
    OIDC_AUDIENCE=ticket-api

    OIDC_ISSUER is the provider’s name and address. If it is empty, sign-in is off, and every route that needs a token answers 503 sign_in_unavailable.

  3. Run the tests. The provider’s tests run it inside the test process, so they need no server:

    Terminal window
    python -m pytest

    Expect 181 passed, 1 deselected. Your project now equals the snapshot end-of-m02.

  4. Start the API in another terminal: fastapi dev. You now have three terminals: the provider, the API and your commands.

  5. Call /v1/me with no token, then with Sam’s access token:

    Terminal window
    curl -i http://127.0.0.1:8000/v1/me
    curl -H "Authorization: Bearer $(python -m scripts.login --user usr-sam --print-access-token)" http://127.0.0.1:8000/v1/me
  6. Do the same for usr-camille and usr-tomas, and compare their memberships.

Show the expected result

Without a token, the API asks for one (the date, server and length lines are left out):

HTTP/1.1 401 Unauthorized
content-type: application/json
www-authenticate: Bearer realm="ticket-api"
x-request-id: c555e193389d
{"error":{"code":"not_signed_in","message":"Send an access token: Authorization: Bearer <token>.","request_id":"c555e193389d","fields":[]}}

With Sam’s token (here formatted with python -m json.tool):

{
"user_id": "usr-sam",
"kind": "user",
"name": "Sam",
"platform_role": null,
"client_id": "ticket-cli",
"scopes": ["members:manage", "tickets:read", "tickets:write"],
"token_expires_at": "2026-10-10T06:21:19Z",
"memberships": [
{"tenant_id": "larkfield", "tenant_name": "Larkfield", "role": "staff"}
]
}

Camille has two memberships: bramble as read_only and larkfield as staff. Tomás has "memberships": []: he can sign in, but he belongs to no shop. Your token_expires_at is 10 minutes after your sign-in.

Now send all the made-up tokens. scripts.token cases makes each one with your provider’s key and sends it to the API code in the same process, so no server is needed:

Terminal window
python -m scripts.token cases

The API’s log prints one line per request first. The table comes at the end:

Made-up tokens sent to GET /v1/me (constructed for the course):
valid 200 accepted Sam's access token, as the provider issued it
no_token 401 refused by: not_signed_in no Authorization header at all
expired 401 refused by: expired expired 5 minutes ago
expired_within_leeway 200 accepted expired 10 seconds ago (inside the 30 s leeway)
not_yet_valid 401 refused by: not_yet_valid valid only from 5 minutes from now (nbf)
wrong_audience 401 refused by: wrong_audience made for another API (aud billing-api)
wrong_issuer 401 refused by: wrong_issuer from another issuer
id_token 401 refused by: wrong_token_type Sam's ID token sent as an access token
alg_none 401 refused by: algorithm_not_allowed unsigned (alg none)
hs256_public_key 401 refused by: algorithm_not_allowed HS256, with the provider's PUBLIC key as the secret
unknown_key 401 refused by: unknown_key signed with a key the provider never published
wrong_key_same_kid 401 refused by: bad_signature signed with another key, using the provider's kid
tampered 401 refused by: bad_signature sub changed to usr-grace after signing (signature kept)
missing_sub 401 refused by: missing_claim no sub claim
not_a_jwt 401 refused by: malformed a random string

Fifteen tokens, and each one is refused by the check that its mistake names. The test test_every_made_up_token_gets_the_expected_answer keeps it that way.

Common mistake

Tomás: 'the ID token is fine for calling the API'

Symptom: Tomás builds a small report page. It signs in, takes the id_token from the provider’s answer, because “it has the user’s name in it”, and sends it to the API. Every call answers 401:

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="ticket-api", error="invalid_token", error_description="This is not an access token (an ID token is for the application, not the API)."
x-token-check: wrong_token_type

Cause: the ID token is a note for the application. Its aud is the application (ticket-cli or help-desk-web), and its header says typ: JWT. It has no scopes, so it says nothing about what the application may do at the API.

Diagnose: read X-Token-Check. Then decode what the page sends with python -m scripts.token decode <token>: an ID token has name, email and nonce, and no scope.

Fix:

  1. Send the access_token from the same answer. Read the person’s name from the ID token, or from /v1/me.
  2. Keep the audience check, even when you switch the typ check off. With Keycloak or Entra ID, whose access tokens also say typ: JWT, the setting ACCESS_TOKEN_TYPES=JWT (Module 3) switches it off. Then the audience check refuses the ID token: wrong_audience. The test test_an_id_token_is_also_for_another_audience proves it.

The API needs the provider’s public keys for check 4. Fetching /jwks.json for every request is slow, and it makes the provider a part of every request. So the API keeps the keys in memory for JWKS_CACHE_SECONDS, 600 seconds by default. The course measured both ways with the same token, in 5 passes: 200 checks per pass with the cache, 100 checks per pass without it.

Time to check one access token in the API code: keys kept in memory, and keys fetched over HTTP from the practice provider for every check. Median of 5 passes (200 checks per pass with the cache, 100 without); measured on an Apple M4 computer with little else running.
Text description of the chart

A column chart in milliseconds. With the keys cached: 0.065 ms per check, too small to see at this scale. With the keys fetched over HTTP for every check: 23.4 ms. The cached run fetched the keys once; the other run fetched them for every check, 525 times with the warm-up.

With the keys cached, a check took 0.065 ms. Fetching them every time took 23.4 ms: about 360 times slower. Each fetch opens a new connection to localhost:8400, and the provider reads its key file. With a provider on the internet, a fetch takes longer still. The cached run fetched the keys once for all its checks.

A cache has a cost: new keys arrive late. When a token names a kid that the cache does not have, the API fetches the keys again at once. But it does that at most once every JWKS_MIN_REFRESH_SECONDS, 10 seconds. Without that limit, a flood of tokens with made-up key IDs would make the API flood the provider too. The course measured what this means for a key rotation:

Two sequences between the identity provider and ticket-api. A: the new key signs at once. The API fetches the keys and gets only the old one. The provider runs rotate-key --activate, so the new key signs from now. A token with the new kid arrives. The kid is unknown and the last fetch is under 10 seconds old, so the API does not fetch: it answers 401 unknown_key, 39 times in 10.1 seconds. When 10 seconds have passed, it fetches old and new, and accepts from then on. B: the new key is published first. The API fetches the keys and gets the old one. The provider runs rotate-key: the new key is published and the old key still signs. 11 seconds later, it activates the new key. A token with the new kid arrives. The kid is unknown and the last fetch is over 10 seconds old, so the API fetches old and new, and accepts at once, with one fetch.
Key rotation seen by the API's key cache. A new key that signs at once was refused for 10.1 s; a key published first was accepted at once.
Text description of the diagram

In sequence A, the new key signed at once, right after the API had fetched the keys. For 10.1 seconds, every request with a new token got 401 unknown_key: 39 refusals in the measurement. In sequence B, the key was published first and signed only 11 seconds later. By then, the API’s last fetch was older than 10 seconds, so the unknown kid caused one fetch, and the token was accepted at once.

So rotate in two steps: python -m idp rotate-key, wait, then python -m idp activate-key. A real provider does the same, and with a long cache the wait matters more. Publish the new key at least one cache time (here 600 seconds) before it signs. Then every API already has it.

The cache has a second cost. A key that the provider retires stays in an API’s cache until the next fetch, up to 600 seconds. And if the provider is down, a running API keeps checking tokens with its cached keys. A freshly started API cannot: in the author’s run, it answered 503 identity_provider_unavailable, “The identity provider does not answer. Try again later.”

Use every check on every request; together they cost less than a tenth of a millisecond with cached keys. Change the settings only for a reason:

  • Leeway (TOKEN_LEEWAY_SECONDS, 30): larger hides clock problems and keeps expired tokens alive longer. Fix the clocks instead.
  • Key cache (JWKS_CACHE_SECONDS, 600): shorter sees new and retired keys sooner, and calls the provider more often.
  • Token type (ACCESS_TOKEN_TYPES): keep it on for a provider that writes at+jwt. Switch it off for Keycloak and Entra ID, and rely on the audience.
  • Audience (OIDC_AUDIENCE): never switch it off. It is the check that keeps tokens for other APIs, and ID tokens, out.

A checked token answers only the first question: who is calling. Tomás has a perfect token and no membership. The next questions, what Sam may do and in which shop, come from the API’s own tables in Module 4.

Your turn: shrink the leeway, change the audience

Section titled “Your turn: shrink the leeway, change the audience”

The completion check of this module: identify the right audience, and refuse an expired or wrong-audience token. Change one setting at a time, only for one command.

  1. Predict first: with no leeway, which line of token cases changes?

    Terminal window
    TOKEN_LEEWAY_SECONDS=0 python -m scripts.token cases 2>/dev/null
  2. Predict again: if the API believed that it was billing-api, which tokens would it accept?

    Terminal window
    OIDC_AUDIENCE=billing-api python -m scripts.token cases 2>/dev/null
Show the answer

With no leeway, the token that expired 10 seconds ago is refused. Its description still says “inside the 30 s leeway”, because the description is fixed text:

expired_within_leeway 401 refused by: expired expired 10 seconds ago (inside the 30 s leeway)

With the audience billing-api, Sam’s real token and the 10-second token are refused by wrong_audience, and the token made for billing-api is accepted:

valid 401 refused by: wrong_audience Sam's access token, as the provider issued it
expired_within_leeway 401 refused by: wrong_audience expired 10 seconds ago (inside the 30 s leeway)
wrong_audience 200 accepted made for another API (aud billing-api)

The audience is the API’s own name, and a token is valid only for the API that it names. The right audience for every token that ticket-api accepts is ticket-api. The other 12 lines do not change.

Check your understanding

Three tokens from your provider, all with valid signatures and inside their time window: A has aud ticket-api and typ at+jwt; B has aud help-desk-web and typ JWT; C has aud billing-api and typ at+jwt. Which does ticket-api accept, and which check refuses each of the others?

Show the answer

Only A. B is an ID token: check 3 refuses it with wrong_token_type; with the token type check off, check 9 refuses it with wrong_audience. C is an access token for another API: check 9 refuses it with wrong_audience. The audience must be the API’s own name, ticket-api.

Check your understanding

The API's clock says 1,000, and the leeway is 30 seconds. Is a token with exp 975 accepted? And one with exp 960? Show the rule.

Show the answer

The API refuses a token if its exp is at most now minus the leeway: 970 or less. An exp of 975 is greater than 970, so it is accepted: it expired 25 seconds ago, inside the leeway. An exp of 960 is less than 970, so it is refused with expired. With TOKEN_LEEWAY_SECONDS=0, both are refused.

Check your understanding

Why does the API take its list of algorithms from its own code (RS256 only) and never from the token's alg?

Show the answer

Because the sender writes the header. With alg none, a careless check skips the signature. With alg HS256, a careless check uses the public key, which anyone can read, as an HMAC secret, so anyone can sign. A fixed allow-list refuses both before the signature check: algorithm_not_allowed, as the alg_none and hs256_public_key cases show.

Recap

  • A signature-only check accepted 8 of 15 made-up tokens; the full check accepts 2. The signature is the start, not the end.
  • ticket_api/auth.py checks in order: JWT, algorithm allow-list (RS256), typ at+jwt, published kid, signature, required claims, time with 30 s leeway, issuer, audience. Each refusal is 401 invalid_token with the check in X-Token-Check.
  • The audience check is essential: it refuses tokens for other APIs and ID tokens, also when the provider writes typ: JWT (Keycloak, Entra ID).
  • GET /v1/me shows the caller: identity from the token, memberships from the API’s tables. The project has 181 tests and equals end-of-m02.
  • Cached keys: 0.065 ms per check instead of 23.4 ms. A new key that signs at once was refused for 10.1 s; publish keys before they sign.
  • Next: keep the tokens out of the browser, with a session cookie.

Next: Browser session choices