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.
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 small window after rotation where presenting the previous refresh token still returns the same new pair (idempotent), to absorb retries from racing clients.
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.
Reuse detection invalidates the chain
If a previously-rotated refresh token is presented again, the OP treats it as a stolen-credential signal and revokes the entire chain — both the stolen token and the legitimate token derived from it. Both parties have to re-authenticate. This is intentional: it's the strongest signal the OP can give that something has gone wrong.
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.
This is mandated by RFC 9700 §2.2.2 for public clients and is how the library treats every refresh chain regardless of 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, the previous token still returns the same new token (idempotent). After d seconds, replay is treated as theft.
Acceptance window — what's that, and why it's not a security hole
The grace period is sometimes called an acceptance window: the OP accepts the previous refresh token as if it were still current, but only for the same idempotent answer it already gave the legitimate client. It's not a relaxation of single-use — the OP doesn't issue new tokens during the window, it just keeps replaying the same fresh pair to absorb retries from a flaky network. Once the window closes, the previous token reverts to "already rotated → reuse → revoke chain." Pass zero to disable replay entirely (strict single-use); the cost is occasional false-positive chain revocations on mobile networks.
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 positive 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.
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 a refresh token is issued only when both conditions hold:
- The client lists
refresh_tokenin itsGrantTypes. - The granted scope contains
openid(refresh tokens are an OIDC construct in this library).
Drop either and the token endpoint (/token) succeeds with access_token + id_token and no refresh_token field — exactly mirroring the "client has no refresh_token grant" path. The RP must re-authenticate the user when the access token expires.
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 original login's authentication context, not just the subject and scope. When a refresh exchange mints a fresh id_token or JWT access token, the OP reproduces the context the user actually authenticated with — auth_time, acr, amr, and the granted authorization_details — 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 refresh record persists these fields (alongside the token's origin) so a stored chain reproduces them faithfully.
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 two slog audit events through op.WithAuditLogger:
| Event | Fired on |
|---|---|
op.AuditTokenIssued | Refresh minted on authorization_code exchange. |
op.AuditTokenRefreshed | Refresh rotated on refresh_token grant. |
A refresh.replay_detected event is emitted before the best-effort chain revoke when an already-rotated token is presented (reuse detection).
Both 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.