Refresh tokens
A refresh token is a long-lived credential the RP exchanges for a fresh access token without re-authenticating the user. It's how "stay signed in" works: access tokens stay short-lived, and the refresh token quietly replaces them.
Specs referenced on this page
- RFC 6749 — OAuth 2.0 Authorization Framework (§6 refresh)
- RFC 9700 — OAuth 2.0 Security Best Current Practice (rotation, reuse detection)
- OpenID Connect Core 1.0 — §11 (
offline_access)
Vocabulary refresher
- Rotation — every successful refresh-token exchange invalidates the old refresh token and issues a new one. The pair (old, new) form a chain of refreshes for the same login.
- Reuse detection — if a refresh token that's already been rotated shows up again, the OP treats it as a stolen-credential signal and invalidates the entire chain. See the warning below.
- Grace period — a short window in which a retry can recover the same successor refresh token without rotating the chain again. A sender-constrained retry may receive a newly bound access token.
offline_accessscope — OIDC's standard way for the user to consent to "the app may keep working when I'm not present." By default it selects the offline TTL bucket;op.WithStrictOfflineAccess()makes it an issuance gate.
How rotation works
Every successful grant_type=refresh_token call rotates the refresh token: the old one is invalidated and a new one is returned. The FAPI 2.0 Final profiles are the exception: they do not rotate, and the presented refresh token stays valid (see Grace period).
Reuse detection invalidates the chain
If a previously-rotated refresh token is presented again, the OP answers 400 invalid_grant, emits refresh.replay_detected, and revokes the entire chain — both the stolen token and the legitimate token derived from it. It also retires the access tokens under the grant, since an attacker who redeemed the stolen token holds one. Both parties have to re-authenticate. This is intentional: it's the strongest signal the OP can give that something has gone wrong.
The scope is the whole grant, which is shared by every session of one user on one client. Under the default grant-tombstone strategy the grant is closed to further issuance, so the user's other refresh chains on that client are refused at their next refresh too. The cascade runs once the invalid_grant response is settled and is best effort: a store fault raises a warn-level audit event and does not change the answer the client already got.
Rotation, reuse detection, family revocation — what's that?
Three terms that get used interchangeably in blog posts but mean different things in this codebase:
- Rotation — the normal successful path. Each
grant_type=refresh_tokenreturns a new refresh token and invalidates the previous one. Single-use by default. - Reuse detection — the OP saw an already-rotated refresh token come back. That can only happen if it leaked, was caught by malware, or a confused client kept a copy. The library treats it as theft.
- Family revocation (also called chain revocation) — the OP's response to reuse: every refresh token in the same lineage as the offending one is invalidated, including the legitimate descendant the real client is currently using. The next legitimate refresh fails, the user re-authenticates, and the attacker's stolen token is dead too. The OP walks the parent pointers to the chain root and revokes from there; if no root resolves, it revokes the grant's refresh tokens instead.
RFC 9700 §4.14.2 requires replay detection for public-client refresh tokens through sender constraint or rotation. When rotation detects reuse, it describes revoking the active refresh token. This library invalidates the whole lineage for every client type.
Grace period
A racing legitimate client (e.g. a tab that double-fetched the same refresh) would otherwise hit reuse detection. op.WithRefreshGracePeriod(d) widens the rotation acceptance window:
op.WithRefreshGracePeriod(2 * time.Second)Within d seconds of a successful rotation, a valid retry can recover the same successor refresh token. For a sender-constrained request, the OP may issue a new access token bound to the key or certificate presented on the retry. After d seconds, replay is treated as reuse.
The retry works because the rotation stores the response sealed with the cookie keys (op.WithCookieKeys) next to the successor. The bundled inmem, sql, and dynamodb adapters implement the optional store.RefreshRetryResponseStore for this; a custom store that does not has no stored response to replay, so a retry inside the window gets invalid_grant.
What the grace period permits
The OP does not rotate the refresh-token chain again during a grace retry: it returns the saved successor refresh token. Bearer responses can be replayed unchanged. For DPoP or mTLS, the OP reissues the access token for the sender constraint on the retry while keeping the same successor refresh token and ID token. If no saved retry response is available, the retry returns invalid_grant without revoking the live successor. Once the window closes, presenting the predecessor triggers reuse detection and chain revocation. Pass zero to disable the window, accepting a greater chance that a legitimate lost-response retry is treated as reuse.
Default is 60 seconds
The default grace period is 60 seconds (refresh.GraceTTLDefault) when WithRefreshGracePeriod is not supplied. Pass op.WithRefreshGracePeriod(0) to disable grace entirely (strict single-use), or a non-negative duration to set the window explicitly. Negative values are rejected at construction time. The OFCS refresh-token regression test waits ~32 s between rotation and retry, so any grace below that range will regress conformance.
profile.FAPI2Baseline and profile.FAPI2MessageSigning do not rotate refresh tokens, as FAPI 2.0 Security Profile §5.3.2.1(9) requires, so no grace window exists under them. A refresh response carries a new access token and no refresh_token, the presented refresh token stays valid until it expires or is revoked, and op.New rejects an explicit op.WithRefreshGracePeriod for any value. profile.FAPICIBA keeps rotating and may use a wider window.
// Rejected: a FAPI 2.0 profile has no rotation window to configure.
op.New(
op.WithProfile(profile.FAPI2Baseline),
op.WithRefreshGracePeriod(60*time.Second),
)Current client registration scopes apply on rotation
A refresh token records the scope granted when its chain started, but rotation does not bypass a later registration change. Before consuming the presented token, the OP derives the effective scope. It intersects the requested scope with the token's bound scope — an omitted request reuses the bound scope — and then re-intersects the result with the client's current Scopes registration, checking any per-scope AllowedClients policy. Every scope to issue must still be registered; if the re-check would remove one, /token returns invalid_scope and leaves the presented token unconsumed. This lets an operator narrow a client's registration without allowing an existing chain to keep minting the removed scope.
A request that asks for a narrower scope only narrows the access token it receives. The rotated refresh token keeps the scope of the token it replaces, so a later refresh can still ask for the full set.
TTL buckets
| Option | Default | Applies to |
|---|---|---|
op.WithRefreshTokenTTL(d) | 30 days | Conventional refresh tokens. |
op.WithRefreshTokenOfflineTTL(d) | inherits WithRefreshTokenTTL | Refresh tokens issued under the offline_access scope. |
Splitting the buckets lets offline_access carry an operationally observable difference (longer lifetime for stay-signed-in flows) while conventional refresh keeps a shorter rotation cadence.
Issuance gate
By default, refresh-token issuance requires all three conditions:
- The Provider enables the
refresh_tokengrant. - The client lists
refresh_tokenin itsGrantTypes. - The granted scope contains
openid.
If a gate fails, the authorization-code exchange can still succeed with an access token but without a refresh token. An ID token is included only when the granted scope contains openid. op.WithOpenIDScopeOptional() removes the openid condition for OAuth-only grants; op.WithStrictOfflineAccess() adds an offline_access condition instead. These two options cannot be combined.
In the default (lax) reading of OIDC Core 1.0 §11, offline_access is not an issuance gate: it only governs consent-prompt UX and which TTL bucket the refresh token falls into (WithRefreshTokenTTL vs WithRefreshTokenOfflineTTL). To make offline_access a hard gate, opt in with op.WithStrictOfflineAccess() — see the section below.
op.WithStrictOfflineAccess — strict OIDC Core §11 reading
op.WithStrictOfflineAccess() switches the issuance and refresh exchange paths to the strict §11 reading: refresh tokens are issued (and accepted on grant_type=refresh_token) only when the granted scope contains offline_access. Pick this when you want consent prompts and the actual issuance gate to agree byte-for-byte on what the user authorised — at the cost of every RP that wants stay-signed-in behaviour explicitly requesting offline_access.
The option is mutually exclusive with op.WithOpenIDScopeOptional (strict §11 has no meaning when openid itself is optional) — the constructor refuses the combination.
Authentication context survives rotation
A refresh token carries the context of the authorization that issued it, not just the subject and scope. When a refresh exchange mints a fresh id_token or access token, the OP reproduces what the user actually authenticated with — auth_time, acr, amr, the granted authorization_details, and the OIDC claims request — rather than stamping the moment of the refresh. So an RP that asked for acr_values=aal2 at login still sees acr reflect that strength after a week of background refreshes, and a step-up's freshness signal does not silently reset on every rotation.
The context belongs to the chain, not to the grant shared by every session of one user on one client. A second login by the same user on the same client does not change what an existing chain reports. The refresh record persists these fields (alongside the token's origin); a custom store that drops them makes the chain fall back to reading the grant, with the shared-grant behaviour that implies.
Sender-constrained chains
A chain issued to a client that presented a DPoP proof or a client certificate may be bound to that key or certificate (see DPoP and mTLS). A bound chain refuses a refresh that omits the proof or certificate, or presents a different one, with invalid_grant. Binding is also checked against the current configuration: if DPoP or mTLS has been switched off since the chain was issued, a bound chain is refused rather than downgraded to bearer.
Revoking a chain
/revoke on a refresh token (RFC 7009) revokes that chain and the access tokens under its grant, and nothing more: the user's other refresh chains on the same client and any unredeemed authorization codes stay valid. For a long chain the endpoint still finds the chain root; if none resolves, it revokes the grant's refresh tokens and records surface: "refresh_grant" on token.revoked instead of refresh_chain. A refresh token issued to another client is ignored, and the answer is 200 either way.
When the user is deleted or deprovisioned, the next refresh fails with invalid_grant and the OP tears the grant down.
At rest: hashed, constant-time
Refresh-token handles are opaque bearer secrets: possession alone redeems them. The OP never stores the presented value — it stores a hash, and on Find / Consume it hashes the presented value to look the digest up, comparing in constant time. That holds the public store lookups to a hash-only, timing-flat shape, hardening against both store disclosure and timing side channels. The internal reuse-detection chain walk resolves stored handles through a separate RefreshChainResolver path so the public lookups stay hash-only. A custom store must persist hashed ids to satisfy the contract.
Audit trail
The token endpoint emits these slog audit events through op.WithAuditLogger:
| Event | Fired on |
|---|---|
audit.TokenIssued (token.issued) | Refresh minted on authorization_code exchange. |
audit.TokenRefreshed (token.refreshed) | Refresh rotated on refresh_token grant, recorded once the rotation is durable. A refresh that lost a concurrent presentation or rolled back records nothing. |
audit.RefreshReplayDetected (refresh.replay_detected) | An already-rotated token was presented (reuse detection), before the best-effort chain revoke. |
audit.TokenRevoked (token.revoked) | /revoke retired a refresh chain; surface is refresh_chain, or refresh_grant when the fallback ran. |
The issued and refreshed records carry an offline_access boolean and a ttl_bucket string ("offline" or "default") in extras, so SOC dashboards can split stay-signed-in chains from conventional rotation without re-reading the granted scope set.
Read next
- ID Token vs access token vs userinfo — what each token actually contains.
- Sender constraint — bind the access token (and the refresh token) to a key the client holds.
- Access token format — what a rotation does to the prior access token under JWT and opaque formats.