Refresh token security: rotation, reuse detection, and storage
Access tokens expire in minutes, which is why OAuth gives you a refresh token: a long-lived credential that quietly trades itself for new access tokens, often for months. The quiet part is the problem. Refresh tokens are the highest-value credential most integrations hold, and they receive the least attention. A stolen refresh token is a backdoor that outlives every access token it issues.
Why refresh tokens are the real prize
An access token is a single-use window. A refresh token is a key to the window factory. It is typically valid for weeks, months, or until revoked, and mints fresh access tokens with no user interaction. For an attacker that is strictly better: one stolen refresh token keeps working until someone notices. That asymmetry — long lifetime, no human in the loop — is why refresh tokens need the strictest handling of any credential in the system.
Rotation: spend each token once
The standard defense is single-use rotation: every exchange of a refresh token yields a new one and invalidates the old. Each token is then valid for exactly one use. This shrinks the replay window: a token copied from storage or from a log is dead after its first legitimate use.
Rotation adds a failure mode worth knowing. If client and server disagree about which token is current — a lost response, a retried request — the client can end up holding an invalidated token. The robust pattern is to treat the newest token the client has seen as authoritative, and to re-authenticate (or allow a short grace window) when a token comes back rejected. The invariant that matters: a used token must never work again.
Reuse detection: noticing the thief
Rotation protects individual tokens; reuse detection protects the account. When a server sees the same refresh token used twice, that is either a bug or a breach — and a strong signal to react. Real deployments log the event, alert, and often revoke the entire refresh-token family — the token and all its descendants — rather than one copy. It is cheap to implement and converts “a token was stolen” from an invisible event into an alert.
Storage: the unglamorous half
None of this matters if the token is stored poorly. Refresh tokens are credentials and deserve credential-grade storage: encrypted at rest, accessible only to the process that needs them, never in plaintext config files, never committed, never in shell history. For command-line tools and agents — most likely to hold refresh tokens for long periods — that means integrating with a secret store rather than dropping a JSON file next to the script.
Two storage mistakes are common: tokens in log output (a debug trace of an OAuth exchange will happily print the refresh token) and tokens in world-readable files alongside the rest of the app’s config. Both are silent until someone reads the file.
What good looks like
A defensible refresh-token setup has: single-use rotation on the server, reuse detection that revokes the family on a second use, short re-authentication when a token is unexpectedly rejected, and storage in a real secret store with the same access controls as any other credential. One more rule: an invalid_grant response should never be blindly retried — a retry loop on an invalid token is a denial of service waiting to happen.
Terminal tooling often breaks down on exactly these points, which is why some CLI OAuth implementations build the discipline in. trustless’s oauth command stores tokens as single-line JSON entries in your existing pass store, rotates refresh tokens on every refresh, reports status as valid, expired, or reauth_required, and never retries an invalid_grant. The implementation is open source at https://github.com/ikkun1222/trustless if you want to see rotation and reuse handling done with minimal machinery.
The question worth asking about your own integrations: if one refresh token were copied from your disk today, how long would it take anyone to notice?