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

IDENTITY SECURITY · LAB

Rotate refresh tokens, narrow scopes and see reuse end a token family

Set refresh lifetimes, run a rotating refresh for lab-printer-app, ask for a narrower token, see your own reused refresh token end the whole family, and meet the recent sign-in gate.

ReadyUses your lab tenant

The lesson

Builds on: How sessions and tokens are stolen.

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. lab-printer-app receives tokens for Ava

    Recorded as oauth.token succeeded for lab-printer-app about [email protected].

  2. A refresh cannot add a scope Ava never granted

    Recorded as oauth.token rejected (scope_not_granted) for lab-printer-app.

  3. A stale session cannot add a sign-in method

    Recorded as account.security rejected (reauthentication_required) about [email protected].

  4. Reusing a rotated refresh token is refused

    Recorded as oauth.token rejected (refresh_replayed) for lab-printer-app.

  5. The newer refresh token died with its family

    Recorded as oauth.token rejected (refresh_revoked) for lab-printer-app.

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

  1. Press Start on this page.

  2. In OAuth > Flow policy, make sure the refresh_token grant is allowed.

  3. If lab-printer-app does not exist yet, create it in OAuth > Clients: public, PKCE required, grants authorization_code and refresh_token, redirect URI https://beyondthelogin.dev/lab/callback/ (the hosted callback page; the loopback http://127.0.0.1:8765/callback with btl-lab callback also works), scopes openid, profile, offline_access and photos.read.

  4. In Access Token Management, edit the default access token manager: refresh token lifetime 7 days (604800 seconds), absolute family lifetime 30 days (2592000 seconds), and Refresh token reuse grace 0 seconds. These match the lesson's example table.

Walkthrough

  1. Prepare a PKCE pair and a state value. The toolkit prints them as shell assignments, so eval sets VERIFIER, CHALLENGE, STATE and NONCE.

export CLIENT_ID="<lab-printer-app client ID>"
eval "$(btl-lab pkce)"
eval "$(btl-lab state)"
  1. Open the authorization request and sign in as Ava. Approve the consent screen if one appears.

GET$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=https%3A%2F%2Fbeyondthelogin.dev%2Flab%2Fcallback%2F&scope=openid%20profile%20offline_access%20photos.read&code_challenge=$CHALLENGE&code_challenge_method=S256&state=$STATE Open in console
GET $ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=https%3A%2F%2Fbeyondthelogin.dev%2Flab%2Fcallback%2F&scope=openid%20profile%20offline_access%20photos.read&code_challenge=$CHALLENGE&code_challenge_method=S256&state=$STATE
  1. The callback page shows code and state. Check that state matches yours, then redeem the code. The response is kept in shell variables and only its non-secret fields are printed.

read -rs CODE
RESPONSE=$(curl -s -d grant_type=authorization_code -d client_id="$CLIENT_ID" --data-urlencode code="$CODE" \
  --data-urlencode redirect_uri=https://beyondthelogin.dev/lab/callback/ -d code_verifier="$VERIFIER" "$ISSUER/oauth/token")
echo "$RESPONSE" | jq '{token_type, expires_in, scope, refresh: (.refresh_token != null)}'
R1=$(echo "$RESPONSE" | jq -r .refresh_token)

Why it matters: the access token lasts minutes; the refresh token is the long-lived credential, so its rotation and lifetime decide how long a copy would stay useful.

  1. Refresh once with R1, asking only for photos.read. The response carries a new refresh token and a narrower scope.

RESPONSE=$(curl -s -d grant_type=refresh_token -d client_id="$CLIENT_ID" --data-urlencode refresh_token="$R1" -d scope=photos.read "$ISSUER/oauth/token")
echo "$RESPONSE" | jq '{scope, expires_in, refresh: (.refresh_token != null)}'
R2=$(echo "$RESPONSE" | jq -r .refresh_token)

Why it matters: rotation makes each refresh token close to single use, and the tenant keeps a family record of every token descended from Ava's grant. A token that carries only photos.read reaches less if it leaks.

  1. Refresh with R2, asking for photos.read photos.write. Ava never granted photos.write. The tenant answers invalid_scope.

curl -s -d grant_type=refresh_token -d client_id="$CLIENT_ID" --data-urlencode refresh_token="$R2" -d "scope=photos.read photos.write" "$ISSUER/oauth/token" | jq .

Why it matters: a refresh may narrow scope, or return to anything within the original grant, but never widen past what Ava approved. Narrow tokens have to be requested on purpose; the server makes sure they cannot grow.

  1. Sign in as Ava at $ISSUER/account/security, leave the page for more than 10 minutes, then try to add a sign-in method. The page asks you to confirm it is you.

Why it matters: the lesson's "asking again before it matters." Note what this check measures: how long ago Ava signed in, not how. A session a relay created minutes ago would pass it, which is the gap Missing infrastructure describes.

Break it

  1. Present R1 again, the refresh token you already used in step 4. The tenant refuses it with invalid_grant.

curl -s -d grant_type=refresh_token -d client_id="$CLIENT_ID" --data-urlencode refresh_token="$R1" "$ISSUER/oauth/token" | jq .
  1. Now present R2, the newest token in the family, which was valid a moment ago. It is refused too.

curl -s -d grant_type=refresh_token -d client_id="$CLIENT_ID" --data-urlencode refresh_token="$R2" "$ISSUER/oauth/token" | jq .

Why it matters: when a used token comes back, the server cannot tell which holder is legitimate, and it does not need to. Reuse detection ends the whole family, so whichever party held the newer token loses it, and Ava signs in again.

  1. Optional: in Access Token Management, set Refresh token reuse grace to 30 seconds, run steps 2 to 4 again, and present the old refresh token within 30 seconds. It is accepted and recorded with reason refresh_reuse_grace. A grace period forgives a client that lost a response in transit, at the cost of a short window where a copy also works.

Restore: set Refresh token reuse grace back to 0 seconds.

Check your work

  • Check my progress confirms the token issue, the refused widening, the stale-session refusal, the refused reuse and the dead family, in that order.

  • In Audit, source Protocol activity, filter rejected and find oauth.token with refresh_replayed and then refresh_revoked, both for lab-printer-app and Ava.

  • In Audit, source OAuth management, the token manager update records the lifetimes you set.

Cleanup

  1. Clear the tokens from your shell: unset R1 R2 RESPONSE CODE.

  2. Keep lab-printer-app and the lifetimes. The revocation lab uses both.

Missing infrastructure

  • G14: the tenant issues bearer tokens only. There is no DPoP, so a token cannot be bound to a key the client holds. Once it exists, the lab will request a DPoP-bound token for lab-printer-app and see the tenant record the key thumbprint in the token's cnf claim.

  • G38: the recent sign-in check counts time only. The tenant cannot require that adding a sign-in method be confirmed with a passkey. Once it exists, the lab will sign Ava in with a password, try to add a method, and be asked for her passkey instead of being let through.

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