OAUTH 2.0 · LAB
Validate a JWT access token in order and follow a key rotation
Decode a real at+jwt, validate it check by check, refuse an ID token and another tenant's token, then rotate the signing key and watch a local API keep up.
Partly readyUses both lab tenants
The lesson
Builds on: Using access tokens.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Partly ready. Most of this lab runs today. Steps that wait on platform features are marked, and Missing infrastructure says what they need.
- G66 Second lab tenant for every learner: additional tenants need a paid subscription or a BTL grant, so labs that use Lab Mail cannot be completed by an ordinary learner yet
Needs a second tenant. This lab also uses Lab Mail, a second tenant. Additional tenants currently need a paid subscription or a BTL grant, so you may not be able to do the Lab Mail steps yet (gap G66).
Your progress
Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.
Sign in to start this lab and check your progress. Log in or create an account.
Get a JWT access token for Ava
Recorded as
oauth.tokensucceeded forlab-printerabout[email protected].Publish a new ES256 signing key
Recorded as
tenant.oauth.keys.generatesucceeded.Move Default access tokens to the new key
Recorded as
tenant.oauth.managers.updatesucceeded.Get a token signed with the new key
Recorded as
oauth.tokensucceeded forlab-printer.Withdraw the old key
Recorded as
tenant.oauth.keys.disablesucceeded.
Request console
Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.
Setup
Choose Lab Photos as the lab tenant and press Start.
Use the variables and the
authorizeandexchangehelpers from Present an access token correctly, withCLIENT_IDandCLIENT_SECRETforlab-printer. Changeexchangeto keep the ID token too by addingID_TOKEN=$(jq -r '.id_token // empty' <<<"$RESP").Confirm
lab-printeruses Default access tokens (Signed JWT).In Lab Mail, confirm
lab-mail-collageexists (Confidential, redirect URIhttp://127.0.0.1:8765/callback) and that a user you can sign in as exists there, for example[email protected]with her Lab Mail password. SetISSUER2,MAIL_CLIENT_IDandread -rs MAIL_CLIENT_SECRET.Start the local photo API in its own terminal:
btl-lab resource --mode jwt. It trusts only$ISSUER, expects audience$ISSUER/resource, accepts only ES256, fetches the key set address from the issuer's metadata, and logs one decision per request without the token.
Walkthrough
Get a token:
authorize "openid profile photos.read", sign in as Ava,exchange. Then read it and measure it.
btl-lab decode "$TOKEN"; echo "length: ${#TOKEN}"
The header shows alg ES256, a kid and typ at+jwt. The claims show iss, sub, aud ($ISSUER/resource), client_id, scope, iat, exp and jti. The toolkit says plainly that decoding is not validating.
Why it matters: a self-contained token carries readable claims, and anything holding it can read them, so the authorization server chooses its contents knowing who will see them. It is also several hundred characters, sent with every request.
Fetch the key set the API will use.
GET$ISSUER/oauth/jwks
Open in console
GET $ISSUER/oauth/jwks HTTP/1.1Find the access token's kid (EC, ES256) and a separate RSA key (RS256) that signs ID tokens. Note the response header Cache-Control: public, max-age=300.
Why it matters: one key set serves both kinds of token, which is why the type check below matters.
Validate it check by check:
btl-lab verify "$TOKEN" --issuer "$ISSUER" --audience "$ISSUER/resource" --type at+jwt. Each line passes in the lesson's order: algorithm from the allowlist, type, key bykidfrom the issuer's own key set, signature, issuer, audience, time.
Why it matters: no claim is trusted until every check before it has passed, and each check is a setting you choose, not a default you hope for.
Call the photo API:
curl -si http://127.0.0.1:8766/photos -H "Authorization: Bearer $TOKEN". Returns200, and the API's log showsallowedwithclient_idandjti.
Present the ID token from the same response as if it were an access token:
curl -si http://127.0.0.1:8766/photos -H "Authorization: Bearer $ID_TOKEN". Returns401 invalid_token, logged asunexpected_alg(it is RS256). Runbtl-lab verify "$ID_TOKEN" --issuer "$ISSUER" --audience "$ISSUER/resource" --type at+jwttoo: the type check fails becausetypisJWT.
Why it matters: here the different algorithm catches it first, but nothing requires an issuer to sign ID tokens with a different algorithm. Only the type check reliably keeps another kind of JWT from passing as an access token.
Present a genuine token from another issuer. In Lab Mail, run the same flow with
lab-mail-collage(use$ISSUER2,$MAIL_CLIENT_IDand$MAIL_CLIENT_SECRETin the helpers), keep it asMAIL_TOKEN, and call the photo API with it. Returns401, logged asunknown_kid, after one key set fetch that did not find the key.
Why it matters: the token is real and correctly signed, by someone this API does not trust. The API takes keys only from its own issuer's key set and never from the token.
Publish a new key ahead of use. In Key Management, Generate key with ES256. Fetch the key set again (step 2): two EC keys are published, and new tokens are still signed with the old one.
Why it matters: a key appears in the set before it signs anything, so every API can learn it in advance.
Switch signing. In Access Token Management, edit Default access tokens and choose the new key as its signing key. Save. Get a new token;
btl-lab decodeshows the newkid. Call the photo API:200. Its log shows one key set fetch, because thekidwas unfamiliar.
Why it matters: refetching on an unknown kid, with a limit on how often, lets the issuer rotate without coordinating with every API.
Check the time rule. Edit Default access tokens and set Maximum lifetime to
60seconds. Get a token, wait two minutes, and call the API:401, logged asexpired.btl-lab verifyshows the time check failing beyond its small clock leeway.
Restore: set Maximum lifetime on Default access tokens back to 3600.
Break it
The cache decides how long a withdrawn key keeps working at an API.
Before this step, keep a token signed with the old key from step 1 as
OLD_TOKEN, and confirm the photo API, still running, accepts it.In Key Management, change the old ES256 key to Retiring, then Disabled. The tenant now treats tokens it signed as revoked.
Call the running API with
OLD_TOKEN: still200, because it holds the old key in its cache and the unfamiliar-kid rule never fires for a knownkid.Restart
btl-lab resource(an empty cache) and call again:401,unknown_kid.
Why it matters: if a signing key leaks, a cache measured in minutes rather than days keeps the window short.
Check your work
Press Check my progress. The checks look for, in order: Ava's first token, tenant.oauth.keys.generate, tenant.oauth.managers.update for the key switch, a token after the switch, and tenant.oauth.keys.disable for the old key.
The API log should show allowed, unexpected_alg, unknown_kid and expired decisions, each with jti and client_id, and never a token.
Cleanup
Keep the new key active and Default access tokens on it. Leave the old key disabled.
Confirm Maximum lifetime on Default access tokens is
3600.Stop
btl-lab resource. Rununset TOKEN ID_TOKEN MAIL_TOKEN OLD_TOKEN RESP.
Missing infrastructure
G66 Second lab tenant: this lab uses Lab Mail, a second tenant. Additional tenants currently need a paid subscription or a BTL grant, so an ordinary learner can do only the Lab Photos steps until every learner can have a second lab tenant.