Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

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.

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.

  1. Generate a new RS256 signing key

    Recorded as tenant.oauth.keys.generate succeeded.

  2. Point the ID token manager at the new key

    Recorded as tenant.oauth.id_token_managers.update succeeded.

  3. Sign in and receive a token signed with the new key

    Recorded as oauth.authorize succeeded (code_issued) for lab-collage.

  4. Retire the old key

    Recorded as tenant.oauth.keys.retire succeeded.

  5. Disable the old key

    Recorded as tenant.oauth.keys.disable succeeded.

  6. See a hint signed by the disabled key refused

    Recorded as oauth.authorize rejected (invalid_id_token_hint) for lab-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.

  1. Keep ID_TOKEN_AVA from Key accounts on issuer and subject. It was signed with the current ID token key.

  2. 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
  1. Save the relying party's signature check as cached-verify.mjs. It allows only RS256, selects the key by kid from the cache, refetches at most once a minute when the kid is unknown, and logs the stage, check, kid and algorithm, never the token. It checks the signature, issuer and audience only; btl-lab verify does 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');
  1. source ~/btl-oidc.sh, run btl-lab callback before each request, and press Start.

Walkthrough

  1. Publish before use. In Lab Photos, open Signing keys and generate a new RS256 key. Fetch the key set: the new kid is listed already, while ID tokens are still signed with the old key.

GET$ISSUER/oauth/jwks Open in console
GET $ISSUER/oauth/jwks

Why it matters: a new key is published first, so cached key sets can pick it up before anything is signed with it.

  1. In OAuth > ID token managers, point the manager assigned to lab-collage at the new key. Sign in and read the new token's header: part "$ID_TOKEN" 1 names the new kid.

  2. 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.

  1. 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.

  1. 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

  1. 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.

  1. 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

  1. Keep the new key active and the manager pointed at it.

  2. 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.

  3. Delete jwks-cache.json if 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 old kid still open during that window; and confirm that every decryption failure (unknown kid, wrong alg or enc, failed tag) produces one identical outcome and one log line.

Back to all labs

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

The Lab