Core engineering · Practical technical guide · 17 October 2026

How to implement OAuth refresh token rotation with reuse detection as RFC 9700 describes: token families, an atomic rotate operation, the concurrency and lost-response races that log users out, grace windows, and tests.

Why rotation exists

A refresh token lets a client obtain new access tokens without the user signing in again. That makes it valuable to an attacker: it represents the client's full granted scope, and if it leaks it can mint access tokens for as long as it lives. Confidential clients authenticate when they refresh, so a stolen token alone is not enough. Public clients, such as single-page and mobile apps, cannot keep a secret, so possession of the token is the whole credential.

RFC 9700, the OAuth 2.0 Security Best Current Practice published in January 2025, says refresh tokens for public clients must be either sender-constrained or rotated. Sender-constraining binds the token to a key the client holds, using mutual TLS (RFC 8705) or DPoP (RFC 9449). Rotation issues a new refresh token on every refresh, invalidates the old one, and keeps the relationship between them. If an invalidated token is presented again, the server knows that two parties hold copies. It cannot tell which one is the attacker, so it revokes the active token, which forces the legitimate client to sign in again and ends the attacker's access.

The data model: token families

Model each grant as a family. Every refresh token belongs to one family and points to its parent. Store only a hash of each token, never the token, so a database leak does not hand out live credentials.

ColumnMeaning
token_hashSHA-256 of the opaque token, primary key
family_idThe grant this token belongs to
parent_hashThe token it replaced, null for the first
statusactive, rotated or revoked
issued_at, rotated_atFor the grace window and audits
family_expires_atAbsolute lifetime of the grant
last_used_atFor inactivity expiry, which RFC 9700 recommends

Exactly one token per family should be active at a time. That invariant is what makes reuse detectable, and it only holds if rotation is atomic.

An atomic rotate operation

Illustrative python, checked for syntax; adapt names and storage to your system.

import hashlib, secrets, sqlite3

GRACE_SECONDS = 0          # see the section on grace windows before raising this

def h(token: str) -> str:
    return hashlib.sha256(token.encode()).hexdigest()

def rotate(db: sqlite3.Connection, presented: str, now: int):
    """Returns ("ok", new_token), ("reuse", None) or ("invalid", None)."""
    db.execute("BEGIN IMMEDIATE")              # serialize writers on this database
    try:
        row = db.execute(
            "SELECT family_id, status, rotated_at, family_expires_at FROM rt WHERE token_hash=?",
            (h(presented),)).fetchone()
        if row is None:
            db.execute("ROLLBACK")
            return "invalid", None
        family, status, rotated_at, family_expires = row
        if status == "revoked" or now >= family_expires:
            db.execute("ROLLBACK")
            return "invalid", None
        if status == "rotated":
            if rotated_at is not None and now - rotated_at <= GRACE_SECONDS:
                db.execute("ROLLBACK")
                return "invalid", None             # benign race: do not punish, do not reissue
            db.execute("UPDATE rt SET status='revoked' WHERE family_id=? AND status!='revoked'",
                       (family,))
            db.execute("COMMIT")
            return "reuse", None                   # emit a security event here
        new = secrets.token_urlsafe(32)
        db.execute("UPDATE rt SET status='rotated', rotated_at=?, last_used_at=? WHERE token_hash=?",
                   (now, now, h(presented)))
        db.execute("INSERT INTO rt VALUES (?,?,?,?,?,?,?,?)",
                   (h(new), family, h(presented), "active", now, None, family_expires, now))
        db.execute("COMMIT")
        return "ok", new
    except Exception:
        db.execute("ROLLBACK")
        raise

The transaction is the important part. Without it, two concurrent refreshes with the same token can both read status active, both insert a child, and the family ends up with two active tokens, which silently disables reuse detection for the rest of its life. In PostgreSQL the equivalent is SELECT ... FOR UPDATE on the presented token's row inside a transaction. Issue the access token in the same request, after the commit succeeds.

The races that log real users out

Rotation turns two ordinary client behaviours into apparent attacks.

Concurrent refresh. A browser with two tabs, or a mobile app with two background tasks, notices an expired access token at the same moment and both refresh with the same refresh token. One wins; the other presents a token that was rotated milliseconds earlier and triggers reuse detection. The user is signed out for no reason. The primary fix is in the client: a single-flight refresh, where one refresh is in progress at a time and other callers wait for its result, shared across tabs with a lock in storage or a service worker.

Lost response. The server rotates and commits, but the response is lost to a mobile network drop or a gateway timeout. The client still holds the old token and retries. The server sees reuse and revokes the family. Here the client did nothing wrong.

Grace windows and their cost

Many identity providers offer a short reuse interval: within a few seconds of rotation, presenting the previous token is not treated as theft. There are two ways to implement it. The weaker one returns the already-issued child token again; that means storing the child in a recoverable form for the window, and an attacker racing within the window receives a live token. The stricter one, used in the sketch above, refuses the late request without revoking anything: the concurrent tab fails and should retry with the token the winning tab stored, and the lost-response case still fails, but without destroying the session for every device in the family.

Every second of grace is a second in which a stolen token can be used without detection, so keep it short, bind it to signals you can check, such as the same client id and device binding, and record grace hits as their own metric. If grace hits are frequent, fix the client's refresh logic rather than widening the window.

Lifetimes and revocation

  • Absolute family lifetime. Rotation should not make a grant immortal. Expire the family after a fixed period and require sign-in.
  • Inactivity expiry. RFC 9700 recommends that refresh tokens expire when unused for some time.
  • Security events. Revoke families on password change or logout at the authorization server, which the RFC allows.
  • Access token lifetime. Revoking the family does not recall self-contained access tokens already issued. Keep them short, minutes rather than hours, so revocation takes effect quickly.

Rotation or sender-constraining

Sender-constrained tokens with DPoP avoid most of the races above: the refresh token can stay the same across refreshes, because using it requires a proof signed with the client's private key. That key can be non-extractable in a browser via WebCrypto or held in platform key storage on mobile. The costs are client complexity, server-side proof validation with replay protection, and handling of key loss. Rotation is simpler to deploy and works with any client; the two can also be combined. Choose deliberately rather than by default. The surrounding protocol is covered in OAuth 2.0 in depth and the PKCE flow; DPoP for agent clients appears in agents and authentication protocols.

Failure modes

  • Plaintext tokens in the database or in logs; hash them and scrub request logging.
  • Rotation outside a transaction, producing two active children.
  • Revoking only the presented token on reuse instead of the whole family, which leaves the attacker's copy alive.
  • Clients that restore an old token from a device backup and trigger reuse; treat it as a sign-in, and tell users why.
  • No alerting on reuse events, so detection happens and nobody looks.

How to verify

  1. A concurrency test that fires many refreshes with the same token from threads and asserts exactly one success and one active token afterwards.
  2. A lost-response test: rotate, discard the response, retry, and check the outcome matches your grace policy.
  3. A theft test: an attacker and a client alternate; the second use of any rotated token revokes the family and both lose access.
  4. Dashboards for reuse events, grace hits and family revocations by client version, which usually points at a client bug before it points at an attacker.
Rotate refresh tokens atomically within families, revoke the family on reuse, fix client refresh races first, and keep any grace window short and measured.