awesome-everything RU
↑ Back to the climb

Security

Refresh token rotation and scope-based least privilege

Crux How OAuth 2.1 refresh token rotation detects stolen tokens via replay, why granular scopes limit breach radius, and the difference between introspection and local JWT validation.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 9 min

A refresh token leaks via a log entry. Without rotation, the attacker silently refreshes new access tokens for months — until the refresh token expires naturally. With rotation, the first time either the attacker or the legitimate client uses the token after its counterpart, the server detects the replay, invalidates the entire chain, and forces re-authentication.

How refresh token rotation works

OAuth 2.1 mandates that every refresh token use issues a new refresh token and invalidates the old one. The server tracks which refresh token superseded which.

Normal flow:

  1. Client holds RT1. Presents to /token. Server mints access_token_2 + RT2. Marks RT1 as used, linked to RT2. Client stores RT2.

Under attack:

  1. Attacker steals RT1 (via log leak, network capture, etc.).
  2. Attacker uses RT1 first → gets access_token_A + RT2. RT1 is now marked used.
  3. Legitimate client later presents RT1 (unaware of theft) → server sees RT1 is already used → replay detected.
  4. Server revokes the entire chain (RT1, RT2, all descendants). Both attacker and legitimate client lose access. User is forced to re-authenticate.

Without rotation: a stolen RT1 works until its absolute expiry — days to months. With rotation: the window of harm is bounded by the rotation interval — typically minutes to hours until the next legitimate refresh.

Refresh token rotation under attack
Attacker steals RT1 from a log file
ATKPOST /token { refresh_token: RT1 } → gets RT2 + access_token_A (RT1 marked used)
CLIPOST /token { refresh_token: RT1 } → REPLAY DETECTED
SRVRevokes RT1 + RT2 + all descendants. Returns invalid_grant: token replay detected.
Both attacker and client must re-authenticate.

Scopes and least privilege

Every access token carries a scope claim — the exact permissions granted at consent time. The app requests a scope set at /authorize; the user sees the consent screen and approves or denies.

Why granular scopes matter:

  • read:photos vs read:everything — the photo-printing app should only see photos, not email or calendar.
  • Blast radius: if the app is compromised, attackers can only do what the granted scopes allow. read:photos lets an attacker download photos. write:photos delete:photos also lets them destroy them.
  • User trust: a consent screen asking for read:photos is reasonable. One asking for full account access is alarming — users decline, and they are right to.

Industry pattern: read scopes are freely granted; write scopes require explicit per-action consent; admin scopes never appear in third-party flows. RFC 9396 (Rich Authorization Requests) extends this further, allowing requests like “transfer up to $500 to account 12345” instead of just write:transfers.

Introspection vs JWT validation: the real tradeoff

When the resource server receives an access token, it must verify it. Two approaches:

Token introspection (/oauth/introspect): real-time call to the IdP per request. Always current — knows about revocations immediately. Costs one extra round trip per request (~5–50ms latency). Required for opaque tokens.

Local JWT validation: the token is a signed JWT. The resource server validates the signature using JWKS. Fast — microseconds. Scalable to millions of RPS. Flaw: cannot see revocations until the token expires. A revoked JWT keeps working for its remaining TTL.

Production pattern: local JWT validation for the hot path + short access token TTL (5–15 minutes, accepting that revocations propagate within that window). For sensitive writes (transfers, deletes), layer introspection on top. A small server-side cache of “recently introspected tokens” (TTL 30s) amortizes the introspection cost.

Quiz

An attacker steals a refresh token. With refresh-token rotation enabled, what is the maximum window of harm?

Quiz

Why is local JWT validation problematic for high-sensitivity write endpoints like 'transfer funds'?

Order the steps

Order the events in a rotation-based stolen-token detection:

  1. 1 Attacker obtains refresh token RT1 from a leaked log
  2. 2 Attacker presents RT1 to /token — server issues RT2, marks RT1 as used
  3. 3 Legitimate client presents RT1 at next scheduled refresh
  4. 4 Server detects RT1 was already used — replay detected
  5. 5 Server revokes entire chain: RT1, RT2, and any descendants
  6. 6 Both attacker and legitimate client receive invalid_grant error
  7. 7 User is forced to re-authenticate; attacker loses access
Recall before you leave
  1. 01
    How does refresh token rotation detect a stolen refresh token?
  2. 02
    Why use granular scopes like read:photos instead of a broad scope?
  3. 03
    When should a resource server use introspection instead of local JWT validation?
Recap

Refresh token rotation (mandatory in OAuth 2.1) converts a stolen token from an unlimited credential into a timed detection window: the next legitimate refresh call sees a replay and kills the chain. Granular scopes enforce least privilege — the consent screen shows what the app can do, and the token enforces it. Token validation offers two modes: local JWT validation is fast but cannot see revocations; introspection is real-time but adds latency. Production systems use local validation for read paths, introspection for sensitive writes, and short TTLs (5–15 min) as the backstop.

Connected lessons
appears again in178
Continue the climb ↑Sender-constrained tokens: DPoP and mTLS
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.