OAUTH 2.0 · LAB
Race two copies of a refresh token and see what rotation catches
Show a confidential client's refresh token is useless alone, play the lesson's three rotation rows with two copies of a public client's token, and see how a reuse grace weakens detection.
Partly readyUses your lab tenant
The lesson
Builds on: Refresh tokens and rotation, Native applications.
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
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.
Present a confidential client's refresh token without its credential
Recorded as
oauth.tokenrejected (unsupported_auth_method) forlab-printer.Present a used copy of the app's refresh token
Recorded as
oauth.tokenrejected (refresh_replayed) forlab-printer-app.See the newer token refused after the reuse
Recorded as
oauth.tokenrejected (refresh_revoked) forlab-printer-app.Set a ten-second reuse grace for the app
Recorded as
tenant.oauth.managers.updatesucceeded.See a copy used inside the grace go undetected
Recorded as
oauth.tokensucceeded (refresh_reuse_grace) forlab-printer-app.
Setup
Choose Lab Photos as the lab tenant and press Start.
In Access Token Management, create
lab-tmp-public: Signed JWT, Maximum lifetime600, Refresh token lifetime in seconds86400, Sign-in limit for refresh tokens in seconds3600, Refresh token reuse grace in seconds0. Assign it tolab-printer-app.Confirm
lab-printerandlab-printer-appboth allow the refresh token grant. LoadCLIENT_IDandCLIENT_SECRETforlab-printerandAPP_IDforlab-printer-app, and the helpers from Rotate a refresh token family and Sign a desktop app in.Add a public refresh helper that prints only the outcome:
app_refresh() { # $1 = refresh token; sets NEXT to the replacement
RESP=$(curl -s "$ISSUER/oauth/token" -d grant_type=refresh_token -d "client_id=$APP_ID" --data-urlencode "refresh_token=$1")
NEXT=$(jq -r '.refresh_token // empty' <<<"$RESP"); jq -c '{error, error_description, issued: (.refresh_token != null)}' <<<"$RESP"
}
Walkthrough
When the thief also needs the client. Get a
lab-printerrefresh token for Ava (authorize "photos.read offline_access",exchange). Present it the way someone who copied only the database would, with the client ID and no credential:
curl -s "$ISSUER/oauth/token" -d grant_type=refresh_token -d "client_id=$CLIENT_ID" --data-urlencode "refresh_token=$REFRESH" | jq .
Returns 401 invalid_client, Audit reason unsupported_auth_method.
Why it matters: the token is bound to a confidential client, so a copy of the connection records alone is useless. That is why the printer keeps its client credential in a separate secrets store.
The app refreshes first. Get a
lab-printer-apprefresh token for Ava (a new sign-in withCLIENT_ID=$APP_IDandapp_exchange), keep it asP1and a second copy asBACKUP, as if it had been copied from a phone backup. Then:
app_refresh "$P1"; P2=$NEXT # the app rotates: P2 issued
app_refresh "$BACKUP" # the copy arrives second: invalid_grant, refresh_replayed
app_refresh "$P2" # the app's newer token: invalid_grant, refresh_revoked
Why it matters: whichever party uses the old token second, reuse ends the whole family. The thief gets nothing, and Ava reconnects once.
The copy refreshes first. Start a new family
Q1and keepBACKUP=$Q1. This time runapp_refresh "$BACKUP"first (the copy rotates), thenapp_refresh "$Q1"as the app returning:refresh_replayed, and the copy's replacement$NEXTis now refused too.
Why it matters: the server cannot tell who presented the old token, so it revokes everything, including what the thief received. The thief keeps at most an access token until it expires.
The app stays idle. Start a family
R1and refresh repeatedly, always with the newest token, as a thief would while the app is unused:
T=$R1; for i in 1 2 3; do app_refresh "$T"; T=$NEXT; done
Every call succeeds. Nothing looks wrong.
Why it matters: rotation notices theft only when both copies are used. This is the lesson's third row, and only the absolute lifetime ends it on a fixed date.
See what a reuse grace costs. Edit
lab-tmp-publicand set Refresh token reuse grace in seconds to10. Start a familyS1, keepBACKUP=$S1, then within ten seconds:
app_refresh "$S1"; app_refresh "$BACKUP"
Both succeed. Audit records the second as oauth.token succeeded with refresh_reuse_grace: no family was revoked, and nothing flagged a theft.
Why it matters: a grace saves a client that lost a response, and for those seconds it also hides a copy. That is the trade the lesson describes for the first two rows.
Restore: set Refresh token reuse grace in seconds on lab-tmp-public back to 0.
Containment notice. Write the message you would send Ava after step 2, naming the client and the time but no token values. Then compare it with what the tenant records: Audit's
refresh_replayedevent names the client, the subject, the time and the request ID, and no token.
Note: the tenant does not notify a person when a family ends for reuse, so this stays a written exercise.
Come back after an hour. Refresh the newest token from step 4 (
app_refresh "$T"):invalid_grant, Audit reasonrefresh_expired. The one-hour sign-in limit ended the family however busily it was used.
Break it
Steps 1 to 5 are the failure cases, each with your own tokens. The only weakened setting is the reuse grace in step 5, restored there.
Check your work
Press Check my progress. The checks look for, in order: the confidential refresh refused with unsupported_auth_method, refresh_replayed and then refresh_revoked for lab-printer-app, the grace change, and a success with refresh_reuse_grace.
After step 7, Audit also shows refresh_expired.
Cleanup
Confirm
lab-tmp-publicshows a reuse grace of0.Assign Default access tokens back to
lab-printer-app, then deletelab-tmp-public.Run
unset REFRESH P1 P2 Q1 R1 S1 T BACKUP NEXT RESP.
Missing infrastructure
G14 DPoP-bound refresh tokens. With refresh tokens bound to a key the app keeps in secure hardware, the
BACKUPcopy in steps 2 to 5 is refused outright because it comes without the key, which also closes the idle third row without waiting for the absolute lifetime.