Skip to content

Refresh Token Rotation & Revocation

Key Points

  • Refresh tokens are the long-lived credential. Compromise of one means an attacker can mint access tokens for days or weeks. The whole security model rests on detecting and stopping that compromise quickly.
  • Rotation = single-use refresh tokens. Every refresh exchange invalidates the old token and issues a new one. Reuse of an invalidated token is the theft signal — react by revoking the whole token family.
  • Three lifetimes to tune: access-token TTL (minutes), refresh-token absolute lifetime (days/weeks), refresh-token idle/sliding lifetime (hours/days). Get these wrong and you trade security for UX, or UX for security.
  • Revocation is two endpoints: RFC 7009 revoke (client tells the IdP "this token is dead") and RFC 7662 introspect (resource server asks "is this token still alive?"). Together they support real revocation, not just expiry-based revocation.
  • Storage location decides the threat model. httpOnly cookie (with Secure and SameSite) → XSS-resistant but CSRF-exposed. localStorageXSS-vulnerable, no CSRF. In-memory → forgotten on reload. Pick by what you're defending against.
  • BFF (Backend-for-Frontend) is the modern default for SPAs. Tokens never reach the browser; the server-side BFF holds them, exposes a session cookie to the SPA, and refreshes silently. See auth flows with React + API.
  • "How do I revoke a JWT" is the wrong question. JWTs are valid until they expire; the answer is short access-token lifetimes plus refresh-token revocation, not "blacklist the JWT."

Concepts (deep dive)

Why refresh tokens exist

Access tokens — typically JWTs — are validated statelessly: the resource server checks the signature and expiry without calling the IdP. That's fast (no network hop per request) but it means the resource server can't revoke a token before it expires. The mitigation is to keep access tokens short-lived (5–30 minutes) so a compromised one stops working quickly.

But re-prompting the user every 15 minutes is unacceptable UX. The refresh token is the bridge: a longer-lived credential the client uses, over the back channel, to mint fresh access tokens without user interaction.

   ┌────────┐   POST /token (grant=refresh_token)         ┌──────────┐
   │ Client │ ────────────────────────────────────────►   │   IdP    │
   │        │                                             │          │
   │        │  ◄──── { access_token (15m), refresh_token  │          │
   │        │        (rotated), id_token (sometimes) }    │          │
   └────────┘                                             └──────────┘

The trade is shifted, not eliminated: a compromised refresh token is now the prize. Refresh-token rotation and revocation strategies exist to make that prize less valuable.

Rotation: single-use refresh tokens

Refresh-token rotation means every refresh exchange issues a new refresh token and invalidates the old one. The client immediately replaces its stored refresh token with the new one.

Token Family F:
  RT-1 ──► (refresh) ──► access + RT-2; RT-1 invalidated
  RT-2 ──► (refresh) ──► access + RT-3; RT-2 invalidated
  RT-3 ──► ...

A token's kid or family ID is recorded so the IdP can correlate them. The IdP stores enough state (a row per refresh-token-family per session) to know whether a presented token is the current one for its family.

Reuse detection: the theft signal

The reason rotation matters isn't the rotation itself — it's what happens when an invalidated token is presented again.

Scenario:

  1. Legitimate client uses RT-1, gets RT-2. Stores RT-2. (RT-1 is now invalidated.)
  2. Attacker has somehow stolen RT-1 (XSS exfiltration, log scrape, leaked backup).
  3. Attacker presents RT-1 to the IdP.
  4. IdP sees: a token that has already been used. This is the theft signal.

The IdP's response: revoke the entire token familyRT-1, RT-2, RT-3, every refresh token ever issued under this session. The legitimate user's next refresh attempt fails; they re-authenticate. The attacker is locked out.

Without reuse detection, the attacker silently mints access tokens forever — or at least until the absolute lifetime expires. With reuse detection, the window of compromise collapses to "from the moment the attacker first uses the stolen token, until the legitimate client next refreshes." Usually minutes.

Token family vs token sequence

A family is the chain of refresh tokens issued in a single user session (or single Authorization Code exchange). Every rotation within that session belongs to the same family. The family has:

  • A family ID (internal to the IdP).
  • A current valid token (the last one issued).
  • An absolute deadline (the user must re-authenticate after N days regardless).
  • A list of invalidated predecessors (the IdP keeps track to detect reuse).

When the family is revoked — by reuse detection, by revoke endpoint call, by admin action, by the user logging out — all tokens in it become invalid simultaneously.

Lifetime tuning: three knobs

Knob Default When to shorten When to extend
Access-token TTL 15 min Token introspection too costly; want fast revocation Network-constrained mobile; offline-tolerant apps
Refresh idle/sliding 30 min – several hours High-risk session (privileged accounts) Low-risk SaaS where users return daily
Refresh absolute 30–90 days Compliance / regulatory Long-running daemons (Client Credentials with rotation)

Common combinations:

  • High-security (banking, healthcare): 5-min access, 30-min idle, 7-day absolute. Re-auth weekly is fine.
  • Consumer SaaS: 15-min access, 24-hour idle, 30-day absolute. Returning users don't notice.
  • Mobile app: 1-hour access, 7-day idle, 90-day absolute. Offline-tolerant.
  • Service-to-service (Client Credentials): typically no refresh token; the client re-requests with credentials whenever needed.

Revocation: making "log out everywhere" real

RFC 7009 defines a /revoke endpoint where a client can explicitly invalidate a refresh token (and optionally an access token). It's how "log out everywhere" actually works:

POST /revoke
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded

token=<refresh_token_or_access_token>
&token_type_hint=refresh_token

The IdP marks the token (and, for refresh tokens, the whole family) as revoked. On the resource-server side:

  • Stateless access-token validation (JWT signature only) won't notice the revocation until the access token expires.
  • Stateful validation via RFC 7662 token introspection asks the IdP on each request — slow but real.

The real-world combo is short access tokens (so stateless validation suffices most of the time) plus refresh-token revocation (so the next refresh attempt fails). Revoking a refresh token doesn't kill the current access token, but it kills the renewal cycle — at most one access-token lifetime later, the attacker is locked out.

Introspection (RFC 7662): asking "is this token alive?"

For resource servers that need immediate revocation (an admin clicks "log this user out NOW" and you can't wait 15 minutes), introspection is the answer:

POST /introspect
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded

token=<access_token>

Response:

{
  "active": true,
  "scope": "orders.read",
  "client_id": "contoso-web",
  "sub": "user-123",
  "exp": 1716000000
}

If "active": false, the resource server rejects the request. This makes access-token validation stateful — every request is a network call to the IdP — which is why most apps avoid it for general traffic. Use introspection for:

  • High-privilege operations (financial transactions, admin actions).
  • Specific endpoints where immediate revocation matters more than throughput.
  • Opaque (non-JWT) tokens, which require introspection because there's no signature to validate.

For most endpoints in most apps, stateless JWT validation + short access-token TTL + revoke-on-logout is the right balance.

Where to store the refresh token (the threat-model decision)

This is the single most-contested security decision in modern SPA/mobile auth. Each storage choice has a different threat model:

Location XSS risk CSRF risk Page-reload survival Notes
httpOnly cookie, Secure, SameSite=Strict or Lax Resistant (JS can't read) Mitigated by SameSite Yes Modern default for server-rendered or BFF apps.
localStorage Vulnerable (any XSS reads it) None Yes Avoid for refresh tokens; common but wrong.
sessionStorage Vulnerable None No (tab close = gone) Slightly better than localStorage but same XSS class.
In-memory (JS variable) XSS still reads it but loses on reload None No OK for access tokens with silent renewal; bad for refresh.
Native secure storage (Keychain, Keystore) Strong N/A (no browser) Yes Mobile apps.

The modern consensus for browser apps: don't hand the refresh token to JavaScript at all. Use a Backend-for-Frontend (BFF) that holds the refresh token server-side and exposes only a session cookie to the SPA. The SPA's threat model becomes "an XSS can act on the user's behalf during the session" — which is bad, but recoverable — not "an XSS exfiltrates a refresh token that's valid for 30 days off the user's machine."

BFF pattern: tokens never reach the browser

   ┌─────────┐                ┌─────────┐                ┌────────┐
   │  SPA    │ ─── cookie ──► │   BFF   │ ─── token ───► │  API   │
   │         │ ◄── cookie ─── │ (.NET)  │ ◄── data ───── │        │
   └─────────┘                └─────────┘                └────────┘
                                    │ holds access + refresh
                                    │ refreshes silently
                              ┌─────────┐
                              │   IdP   │
                              └─────────┘
  • SPA authenticates by redirect through the BFF (Authorization Code + PKCE).
  • BFF stores tokens in a server-side session (Redis, cookie-encrypted, or in-process for small apps).
  • SPA gets a session cookie (SameSite=Strict; Secure; httpOnly).
  • SPA's API calls go to BFF; BFF attaches the access token and forwards.
  • BFF refreshes the access token using the refresh token when needed.
  • BFF revokes the refresh token on logout.

The SPA never sees a refresh token; XSS can't exfiltrate what isn't there. Implementation in .NET typically uses Duende.BFF or hand-rolled with Yarp.ReverseProxy + cookie auth.

Sliding vs absolute lifetimes

A refresh token has two clocks:

  • Idle (sliding) lifetime — invalidates if not used within this window. "Use it or lose it." Keeps idle sessions from lingering.
  • Absolute lifetime — invalidates regardless of activity, after this duration from issuance. Forces periodic re-authentication for security.

A 30-minute idle + 30-day absolute combination means: active users keep refreshing without re-auth for up to 30 days; an idle session expires within 30 minutes of last use; everyone re-authenticates at least monthly. Tune to your risk tolerance.

Client Credentials and refresh tokens

The Client Credentials flow (service-to-service) typically doesn't issue refresh tokens — the client has the credentials, so it can re-request whenever. Some IdPs ignore the offline_access scope in this flow; some return a refresh token anyway. Don't depend on either; design the service to re-acquire on token expiry.

For long-running daemons, libraries like Microsoft.Identity.Client (MSAL) for .NET handle token caching and re-acquisition; you don't usually write the refresh logic yourself.


How it works under the hood

What the IdP stores

For every refresh-token family, the IdP persists at minimum:

session_id  | family_id  | current_token_hash | absolute_exp        | sliding_exp        | predecessor_hashes        | revoked
abc-...     | f1         | sha256(RT-3)       | 2026-06-12T00:00Z   | 2026-05-14T08:30Z  | [sha256(RT-1), sha256(RT-2)]| false

When a refresh request arrives:

  1. Hash the presented token.
  2. Look up the family containing it.
  3. If revoked → reject.
  4. If hash matches current_token_hash → happy path: issue new access + refresh, update current_token_hash, append old to predecessor_hashes, slide sliding_exp.
  5. If hash matches a predecessor_hashtheft detected. Set revoked = true. Reject. (Optionally: notify the user.)
  6. If hash matches nothing → reject (invalid token).

The hashes are stored, not the raw tokens — the same DB protections that apply to passwords apply here.

Why a database hit is unavoidable

You may have noticed: refresh-token validation is not stateless. Even with JWT-style refresh tokens, the IdP has to look up whether the token has been used. That's by design — stateless validation can't detect reuse.

The good news: refresh exchanges are infrequent (once per access-token lifetime, every 15 minutes at most). The DB hit is per-refresh, not per-API-call. Access tokens themselves are still stateless JWTs validated against JWKS in-memory.

Detecting reuse without exact-match storage

Some IdPs use a different scheme: a token contains an embedded family ID and a monotonically-increasing sequence number. The IdP stores just the current sequence number per family. A presented token with a sequence number older than the current is reuse — without storing every predecessor hash.

Both schemes solve the same problem; the trade is storage volume vs token size.

Token rotation with multiple concurrent clients

Edge case: the same user logged in from a desktop and a phone, both running the same SPA, both holding refresh tokens for the same client. Are they in the same family or different families?

Standard answer: different families. Each Authorization Code exchange creates a new family, so each login session has independent rotation. Revoking one device's family doesn't kick the user off the other.

If two browser tabs share a session and both try to refresh at the same instant, you can get a race: tab A refreshes (consumes RT-1, gets RT-2); tab B presents RT-1, IdP sees reuse, kills the family. Solutions:

  • Single refresh broker in the SPA (mutex around the refresh call).
  • Grace window in the IdP — accept a recently-rotated predecessor token once within the last N seconds. (Some IdPs offer this.)
  • BFF — only the server refreshes; the SPA doesn't.

What revocation actually does in the DB

On POST /revoke:

  1. Look up the token's family.
  2. Set revoked = true on the family row.
  3. (Optionally) propagate: revoke any active access tokens by adding them to a revocation list checked at introspection time.

For stateless JWT access tokens, step 3 is meaningless until you introspect — which is why the access token survives until expiry unless the resource server is also checking introspection.

Logout flow (front-channel + back-channel)

A full "log out everywhere" involves:

  1. Front-channel (OIDC end_session_endpoint) — redirect the user to the IdP's logout URL; the IdP clears its session cookie and redirects back. SSO into other clients is killed.
  2. Back-channel revocation — the client (or BFF) calls /revoke with the refresh token to invalidate the family server-side.
  3. Local session destruction — the client deletes its own session cookie / clears stored tokens.

Many apps do (1) and (3) but skip (2), which means the refresh token remains valid until idle/absolute expiry. For a real logout, do all three.

Token binding (DPoP, mTLS-bound)

Recent extensions bind a refresh token to a specific client cryptographic key (DPoP — Demonstrating Proof of Possession; RFC 9449) so a stolen token alone can't be used. The attacker also needs the client's private key. DPoP is the 2026 standard for high-security SPAs. Adoption is uneven; Azure Entra ID, Auth0, and Keycloak all support it; the .NET ecosystem has support via Microsoft.Identity.Web and Duende.


Code: correct vs wrong

❌ Wrong: long-lived access tokens with no refresh

// IdP config:
options.AccessTokenLifetime = TimeSpan.FromDays(30);
options.IssueRefreshToken = false;

A 30-day JWT access token can't be revoked. Compromise = a month of unauthorized access. Don't.

✅ Correct: short access tokens + rotating refresh tokens

// IdP config (e.g., Duende, OpenIddict):
options.AccessTokenLifetime  = TimeSpan.FromMinutes(15);
options.RefreshTokenUsage    = TokenUsage.OneTimeOnly;    // ← rotation
options.RefreshTokenExpiration = TokenExpiration.Sliding; // ← idle window
options.SlidingRefreshTokenLifetime  = TimeSpan.FromHours(8);
options.AbsoluteRefreshTokenLifetime = TimeSpan.FromDays(30);

❌ Wrong: refresh tokens in localStorage

// In a React SPA:
localStorage.setItem("refresh_token", token);  // ← any XSS reads this

A single XSS in any third-party script the page loads (analytics, ads, a compromised npm package) exfiltrates the refresh token. The attacker then has 30 days of access from anywhere.

// In ASP.NET Core BFF:
builder.Services
    .AddAuthentication(o =>
    {
        o.DefaultScheme          = CookieAuthenticationDefaults.AuthenticationScheme;
        o.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie(o =>
    {
        o.Cookie.HttpOnly = true;
        o.Cookie.Secure   = true;
        o.Cookie.SameSite = SameSiteMode.Strict;
    })
    .AddOpenIdConnect(o =>
    {
        o.Authority    = "https://idp.contoso.com/realms/prod";
        o.ClientId     = "contoso-bff";
        o.ClientSecret = config["Idp:ClientSecret"];
        o.ResponseType = "code";
        o.UsePkce      = true;
        o.SaveTokens   = true;  // tokens stored server-side in the auth cookie/session
        o.Scope.Add("openid");
        o.Scope.Add("offline_access");
    });

The SPA sees a session cookie. The refresh token never crosses into JS.

❌ Wrong: detecting reuse but not revoking the family

// IdP-side:
if (presentedToken == predecessor)
{
    return Unauthorized(); // ← detected, but the attacker just tries the current token next.
}

Detection without revocation is useless. The whole family must die.

✅ Correct: revoke the family on reuse

if (presentedToken == predecessor)
{
    await refreshFamilies.RevokeAsync(family.Id, ct);
    logger.LogWarning("Refresh-token reuse detected for family {FamilyId} (user {Sub})", family.Id, family.UserId);
    return Unauthorized();
}

Most IdPs do this for you. If you're rolling your own (Duende, OpenIddict, or hand-rolled), make sure it's wired up.

❌ Wrong: logout deletes local state but not the refresh token

app.MapPost("/logout", (HttpContext http) =>
{
    http.Response.Cookies.Delete(".AspNetCore.Cookies");
    return Results.Ok();
});

The refresh token is still valid at the IdP. "Logged out" only in the local session.

✅ Correct: revoke the refresh token, then end the local session

app.MapPost("/logout", async (HttpContext http, IHttpClientFactory httpFactory, IConfiguration cfg, CancellationToken ct) =>
{
    var refreshToken = await http.GetTokenAsync("refresh_token");
    if (refreshToken is not null)
    {
        var client = httpFactory.CreateClient();
        await client.PostAsync($"{cfg["Idp:Authority"]}/protocol/openid-connect/revoke",
            new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["token"]           = refreshToken,
                ["token_type_hint"] = "refresh_token",
                ["client_id"]       = cfg["Idp:ClientId"]!,
                ["client_secret"]   = cfg["Idp:ClientSecret"]!,
            }), ct);
    }
    await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    await http.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme); // front-channel
    return Results.Ok();
});

Refresh token revoked at the IdP; local cookie cleared; OIDC end-session triggered.


Design patterns for this topic

Pattern 1 — "Short access, rotating refresh"

  • Intent: stateless access-token validation; revoke via refresh-token revocation.

Pattern 2 — "Reuse detection kills the family"

  • Intent: turn token theft into a stop signal, not silent persistent access.

Pattern 3 — "BFF for SPAs"

  • Intent: tokens never touch JavaScript; SPA threat model is session-cookie level.

Pattern 4 — "Sliding + absolute lifetimes"

  • Intent: active users stay logged in; idle sessions die; everyone re-auths eventually.

Pattern 5 — "Revoke on logout"

  • Intent: logout is real — refresh token dies at the IdP, not just locally.

Pattern 6 — "Introspect on high-privilege endpoints"

  • Intent: immediate revocation where it matters; stateless validation everywhere else.

Pattern 7 — "DPoP for high-security tokens"

  • Intent: bind the token to a client key; stealing the token alone isn't enough.

Pros & cons / trade-offs

Strategy Pros Cons
Stateless JWT access tokens Fast (no IdP hop) Can't revoke until expiry
Refresh-token rotation Detects theft; bounds compromise window Per-refresh DB hit; race conditions across tabs
RFC 7009 revocation Real logout; admin-driven kicks Doesn't kill current access token until expiry
RFC 7662 introspection Immediate revocation Network hop on every request
httpOnly cookie storage XSS-resistant CSRF surface (mitigate with SameSite)
localStorage storage Easy XSS-vulnerable; avoid for refresh tokens
BFF Tokens never in JS; clean threat model Extra server tier; deploy + ops cost
DPoP / token binding Stolen token alone is useless Lower adoption; library support uneven

When to use / when to avoid

  • Use rotating refresh tokens for every interactive app. There's no good reason not to in 2026.
  • Use BFF for any SPA where the threat model includes XSS risk (which is all SPAs).
  • Use introspection on high-privilege endpoints (admin actions, financial transactions).
  • Use DPoP for high-security apps where token theft is a credible threat.
  • Avoid localStorage for refresh tokens. Period.
  • Avoid long-lived access tokens (anything more than 30 minutes) without a strong reason.
  • Avoid Resource Owner Password Credentials flow — no refresh-token rotation can save you from the underlying anti-pattern.

Interview Q&A

Q1. Why are refresh tokens necessary? Access tokens are short-lived to limit the blast radius of compromise; refresh tokens let the client mint new access tokens without re-prompting the user. The refresh token concentrates the long-lived credential into a single object you can protect carefully.

Q2. What's refresh-token rotation? Every refresh exchange issues a new refresh token and invalidates the old one. The client immediately stores the new token. Reuse of an invalidated token signals theft.

Q3. What's reuse detection? When the IdP sees a refresh token that's been used once before, it concludes the token (or its successor) was stolen. The whole token family is revoked — the legitimate user and the attacker are both locked out, forcing re-authentication.

Q4. What's a token family? The chain of refresh tokens issued in one user session. All tokens in a family share an internal family ID; revoking the family invalidates all of them simultaneously.

Q5. Can a JWT be revoked? Not by itself — its validation is stateless until expiry. Revocation works by killing the refresh token (so no new access tokens get minted) and either keeping access-token TTL short or introspecting on every request.

Q6. What's the difference between RFC 7009 revoke and RFC 7662 introspect? Revoke (7009): client tells the IdP "this token is dead." Introspect (7662): resource server asks "is this token still alive?" Revocation is one-time; introspection is per-request.

Q7. Where should a SPA store its refresh token? Nowhere. Use a Backend-for-Frontend that holds tokens server-side; the SPA gets only a session cookie. localStorage is XSS-vulnerable; httpOnly cookies leak less but expose the SPA architecturally.

Q8. What's the difference between sliding and absolute refresh lifetimes? Sliding (idle) — invalidates if not used within the window; resets on use. Absolute — invalidates after a fixed duration from issuance regardless of activity. A typical combo is 30-min sliding + 30-day absolute.

Q9. How do you handle multiple browser tabs racing on refresh? Either a refresh broker (mutex) in the SPA, an IdP grace window for recently-rotated tokens, or a BFF where only the server refreshes. The BFF approach eliminates the race entirely.

Q10. What does "log out everywhere" require? Three steps: end-session redirect to the IdP (kill the SSO cookie), revoke the refresh token via RFC 7009 (kill the family at the IdP), destroy the local session. Skipping any leaves a hole.

Q11. What's DPoP and why is it a 2026 default? DPoP (RFC 9449) binds a token to a client-held cryptographic key. The token is useless without the matching private key, so stealing it alone doesn't grant access. Major IdPs (Entra ID, Auth0, Keycloak) support it; the .NET ecosystem has libraries.

Q12. Why don't service-to-service flows use refresh tokens? The client has the underlying credentials (client ID + secret, or a certificate). It can re-request a fresh access token whenever — no refresh-token indirection needed. Some IdPs issue refresh tokens in Client Credentials anyway, but you shouldn't rely on it.

Q13. How does revocation interact with the current access token? For stateless JWT validation, the current access token survives until expiry (typically ≤15 minutes). For real-time revocation, the resource server has to introspect — a per-request IdP hop. Most apps accept the short window to keep validation fast.


Gotchas / common mistakes

  • ⚠️ Refresh tokens in localStorageXSS exfiltration becomes 30-day persistent access. Use BFF or httpOnly cookies.
  • ⚠️ Disabling rotation — "the legitimate client gets confused on tab races, so we turned rotation off." That's a UX bug to fix in the client, not a security feature to remove.
  • ⚠️ Detecting reuse but not revoking the family — the attacker just uses the current token instead of the predecessor.
  • ⚠️ Long-lived access tokens as a "performance optimization" — you've traded a 15-minute exposure for an N-hour exposure.
  • ⚠️ Logout that only clears local state — refresh token is still valid at the IdP; attacker who already has it isn't kicked.
  • ⚠️ No absolute lifetime — sliding alone means an attacker with a refresh token can keep it alive forever by refreshing.
  • ⚠️ Resource Owner Password Credentials with rotating refresh — rotating a token issued via a fundamentally insecure flow is putting a screen door on a submarine. Don't use ROPC.
  • ⚠️ Two SPAs sharing the same refresh token across tabs with no broker — guaranteed reuse-detection trips on the second tab; users get logged out for no reason.
  • ⚠️ Forgetting to validate the access token's audience (aud) at the resource server — even with great refresh hygiene, an access token issued for client A used at API B is a vulnerability.

Further reading