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.
- G14 DPoP
- G38 Method-aware step-up: require a phishing-resistant method for sensitive actions, not just a recent sign-in
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.
lab-printer-app receives tokens for Ava
Recorded as
oauth.tokensucceeded forlab-printer-appabout[email protected].A refresh cannot add a scope Ava never granted
Recorded as
oauth.tokenrejected (scope_not_granted) forlab-printer-app.A stale session cannot add a sign-in method
Recorded as
account.securityrejected (reauthentication_required) about[email protected].Reusing a rotated refresh token is refused
Recorded as
oauth.tokenrejected (refresh_replayed) forlab-printer-app.The newer refresh token died with its family
Recorded as
oauth.tokenrejected (refresh_revoked) forlab-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
Press Start on this page.
In OAuth > Flow policy, make sure the
refresh_tokengrant is allowed.If
lab-printer-appdoes not exist yet, create it in OAuth > Clients: public, PKCE required, grantsauthorization_codeandrefresh_token, redirect URIhttps://beyondthelogin.dev/lab/callback/(the hosted callback page; the loopbackhttp://127.0.0.1:8765/callbackwithbtl-lab callbackalso works), scopesopenid,profile,offline_accessandphotos.read.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
Prepare a PKCE pair and a state value. The toolkit prints them as shell assignments, so
evalsetsVERIFIER,CHALLENGE,STATEandNONCE.
export CLIENT_ID="<lab-printer-app client ID>"
eval "$(btl-lab pkce)"
eval "$(btl-lab state)"
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=$STATEThe callback page shows
codeandstate. Check thatstatematches 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.
Refresh once with
R1, asking only forphotos.read. The response carries a new refresh token and a narrowerscope.
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.
Refresh with
R2, asking forphotos.read photos.write. Ava never grantedphotos.write. The tenant answersinvalid_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.
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
Present
R1again, the refresh token you already used in step 4. The tenant refuses it withinvalid_grant.
curl -s -d grant_type=refresh_token -d client_id="$CLIENT_ID" --data-urlencode refresh_token="$R1" "$ISSUER/oauth/token" | jq .
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.
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
rejectedand findoauth.tokenwithrefresh_replayedand thenrefresh_revoked, both forlab-printer-appand Ava.In Audit, source OAuth management, the token manager update records the lifetimes you set.
Cleanup
Clear the tokens from your shell:
unset R1 R2 RESPONSE CODE.Keep
lab-printer-appand 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-appand see the tenant record the key thumbprint in the token'scnfclaim.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.