OPENID CONNECT · LAB
Rotate a signing key against a cached key set
Rotate the ID token signing key while your relying party caches the key set, see one refetch fix an unknown kid, keep old tokens valid while the old key retires, and see what disabling a key stops.
Partly readyUses your lab tenant
The lesson
Builds on: Connecting a sign-in to an account.
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.
- G64 Encrypted ID tokens and signed or encrypted UserInfo
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.
Generate a new RS256 signing key
Recorded as
tenant.oauth.keys.generatesucceeded.Point the ID token manager at the new key
Recorded as
tenant.oauth.id_token_managers.updatesucceeded.Sign in and receive a token signed with the new key
Recorded as
oauth.authorizesucceeded (code_issued) forlab-collage.Retire the old key
Recorded as
tenant.oauth.keys.retiresucceeded.Disable the old key
Recorded as
tenant.oauth.keys.disablesucceeded.See a hint signed by the disabled key refused
Recorded as
oauth.authorizerejected (invalid_id_token_hint) forlab-collage.
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
Signing key rotation (generate, re-point, retire, disable) and every signature-side check are real. Encryption keys, and the decryption half of the lesson, need G64. This lab practises the lesson's discipline on the signing layer, which every nested response also has: select the key by kid from a source you chose, allow only the registered algorithm, and fail with one clear outcome.
Keep
ID_TOKEN_AVAfrom Key accounts on issuer and subject. It was signed with the current ID token key.Cache the key set the way a relying party would:
curl -s "$ISSUER/oauth/jwks" > jwks-cache.json
jq '[.keys[] | {kid, alg}]' jwks-cache.json
Save the relying party's signature check as
cached-verify.mjs. It allows only RS256, selects the key bykidfrom the cache, refetches at most once a minute when thekidis unknown, and logs the stage, check,kidand algorithm, never the token. It checks the signature, issuer and audience only;btl-lab verifydoes the full validation.
import { createPublicKey, verify } from 'node:crypto';
import { readFileSync, statSync, writeFileSync } from 'node:fs';
const [jwt, issuer, audience] = process.argv.slice(2);
const [h, p, s] = jwt.split('.');
const header = JSON.parse(Buffer.from(h, 'base64url')), body = JSON.parse(Buffer.from(p, 'base64url'));
const say = (result, check) => {
console.log(JSON.stringify({stage: 'id_token_signature', result, check, kid: header.kid ?? null, alg: header.alg ?? null}));
process.exit(result === 'verified' ? 0 : 1);
};
if (header.alg !== 'RS256') say('rejected', 'algorithm_not_allowed');
let jwk = JSON.parse(readFileSync('jwks-cache.json', 'utf8')).keys.find(key => key.kid === header.kid);
if (!jwk && process.env.NO_REFETCH !== '1' && Date.now() - statSync('jwks-cache.json').mtimeMs > 60000) {
const fresh = await (await fetch(issuer + '/oauth/jwks')).json();
writeFileSync('jwks-cache.json', JSON.stringify(fresh));
console.log(JSON.stringify({stage: 'jwks', result: 'refetched', kid: header.kid}));
jwk = fresh.keys.find(key => key.kid === header.kid);
}
if (!jwk) say('rejected', 'unknown_kid');
if (!verify('sha256', Buffer.from(h + '.' + p), createPublicKey({key: jwk, format: 'jwk'}), Buffer.from(s, 'base64url'))) say('rejected', 'bad_signature');
if (body.iss !== issuer || ![body.aud].flat().includes(audience)) say('rejected', 'issuer_or_audience');
say('verified', 'none');
source ~/btl-oidc.sh, runbtl-lab callbackbefore each request, and press Start.
Walkthrough
Publish before use. In Lab Photos, open Signing keys and generate a new RS256 key. Fetch the key set: the new
kidis listed already, while ID tokens are still signed with the old key.
GET$ISSUER/oauth/jwks
Open in console
GET $ISSUER/oauth/jwksWhy it matters: a new key is published first, so cached key sets can pick it up before anything is signed with it.
In OAuth > ID token managers, point the manager assigned to
lab-collageat the new key. Sign in and read the new token's header:part "$ID_TOKEN" 1names the newkid.Wait a minute after Setup, then check the new token against your cache:
node cached-verify.mjs "$ID_TOKEN" "$ISSUER" "$CLIENT_ID"
One refetched line, then verified.
Why it matters: an unknown kid is the signal to refetch, once. A cache that never refreshes rejects every sign-in on rotation day.
Keep old tokens valid while the old key retires. In Signing keys, try to retire the new key: refused, because a manager uses it. If any other manager still uses the old key, point it at the new key too. Then retire the old key. It stays in the key set as retiring, and a token it signed still verifies:
curl -s "$ISSUER/oauth/jwks" > jwks-cache.json
node cached-verify.mjs "$ID_TOKEN_AVA" "$ISSUER" "$CLIENT_ID"
verified.
Why it matters: a key stays published until nothing it signed is still in use. The encryption version of this rule is the one the lesson's printer broke: keep the old private key for the cache lifetime plus a margin.
Disable the old key, the response to a leaked key. It leaves the key set, and everything it signed stops being trusted. Ask silently with the old token as a hint:
signin prompt=none "id_token_hint=$ID_TOKEN_AVA"
The listener prints error=invalid_request: the tenant accepts hints only with a key it still publishes.
Why it matters: disabling is not retiring. A leaked key's past output is treated as exposed, at the cost of everything legitimately signed with it.
Break it
A cache that never refreshes. Generate another RS256 key, point the manager at it, sign in, and check with refetching switched off:
NO_REFETCH=1 node cached-verify.mjs "$ID_TOKEN" "$ISSUER" "$CLIENT_ID"
rejected unknown_kid. Run it again without NO_REFETCH: one refetch, then verified.
Restore: keep the refetch on. Retire and disable the extra key if you do not want it, after pointing the manager back.
A real token with a different algorithm. Pass
lab-collage's ES256 access token to the ID token check:
node cached-verify.mjs "$TOKEN" "$ISSUER" "$CLIENT_ID"
rejected algorithm_not_allowed, before any key is selected or any signature work is done. The log line carries the stage, kid and algorithm only, which is all support needs and nothing a prober could use.
Check your work
Press Check my progress. The checks look for the new key, the manager pointed at it, a sign-in after the change, the old key retired and then disabled, and the refused hint signed by the disabled key.
Cleanup
Keep the new key active and the manager pointed at it.
Sign Ava in once more and keep
ID_TOKEN_AVA=$ID_TOKEN: later labs use it as a hint, and the old one can no longer serve.Delete
jwks-cache.jsonif you no longer need it.
Missing infrastructure
G64 (encrypted ID tokens, signed or encrypted UserInfo). The encryption half of the lesson would add: register a client encryption key in a JWKS served with a one-hour
Cache-Control; rotate it by publishing the new key and keeping the old private key for the cache lifetime plus a margin; see a response encrypted to the oldkidstill open during that window; and confirm that every decryption failure (unknownkid, wrongalgorenc, failed tag) produces one identical outcome and one log line.