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

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.

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. Present a confidential client's refresh token without its credential

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

  2. Present a used copy of the app's refresh token

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

  3. See the newer token refused after the reuse

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

  4. Set a ten-second reuse grace for the app

    Recorded as tenant.oauth.managers.update succeeded.

  5. See a copy used inside the grace go undetected

    Recorded as oauth.token succeeded (refresh_reuse_grace) for lab-printer-app.

Setup

  1. Choose Lab Photos as the lab tenant and press Start.

  2. In Access Token Management, create lab-tmp-public: Signed JWT, Maximum lifetime 600, Refresh token lifetime in seconds 86400, Sign-in limit for refresh tokens in seconds 3600, Refresh token reuse grace in seconds 0. Assign it to lab-printer-app.

  3. Confirm lab-printer and lab-printer-app both allow the refresh token grant. Load CLIENT_ID and CLIENT_SECRET for lab-printer and APP_ID for lab-printer-app, and the helpers from Rotate a refresh token family and Sign a desktop app in.

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

  1. When the thief also needs the client. Get a lab-printer refresh 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.

  1. The app refreshes first. Get a lab-printer-app refresh token for Ava (a new sign-in with CLIENT_ID=$APP_ID and app_exchange), keep it as P1 and a second copy as BACKUP, 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.

  1. The copy refreshes first. Start a new family Q1 and keep BACKUP=$Q1. This time run app_refresh "$BACKUP" first (the copy rotates), then app_refresh "$Q1" as the app returning: refresh_replayed, and the copy's replacement $NEXT is 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.

  1. The app stays idle. Start a family R1 and 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.

  1. See what a reuse grace costs. Edit lab-tmp-public and set Refresh token reuse grace in seconds to 10. Start a family S1, keep BACKUP=$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.

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

  1. Come back after an hour. Refresh the newest token from step 4 (app_refresh "$T"): invalid_grant, Audit reason refresh_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

  1. Confirm lab-tmp-public shows a reuse grace of 0.

  2. Assign Default access tokens back to lab-printer-app, then delete lab-tmp-public.

  3. 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 BACKUP copy 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.

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