Refresh Token Rotation Grace Period (Overlap Window) #3022

Open
opened 2026-03-13 10:34:55 -05:00 by GiteaMirror · 0 comments
Owner

Originally created by @ebramanti on GitHub (Mar 9, 2026).

Is this suited for github?

  • Yes, this is suited for github

Is your feature request related to a problem? Please describe.

In the OAuth 2.1 Provider plugin, the refresh token rotation and reuse detection logic currently treats any reuse of a rotated refresh token as a replay attack and revokes all refresh tokens for that (clientId, userId) pair, forcing the user to re-authorize for that client.

This behavior is implemented directly in the OAuth provider:

  • createRefreshToken sets revoked on the existing oauthRefreshToken row whenever a refresh token is used and a new one is issued.
  • handleRefreshTokenGrant checks the stored refresh token, and if revoked is set, it performs a "replay revoke" by calling deleteMany on oauthRefreshToken with clientId = client_id and userId = refreshToken.userId, then returns invalid_grant.

In practice, this strict "family revocation on reuse" strategy means that any reuse of a rotated refresh token (even from legitimate clients) logs the user out of that client. This is secure, but it produces false positives in common real-world scenarios:

  • Multiple browser tabs or components independently detect an expired access token and trigger refresh calls at nearly the same time.
  • Mobile apps where background tasks and foreground actions both cause refresh attempts.
  • Network timeouts or lost responses where the first refresh succeeds on the server but the client never sees the response and retries with the same refresh token.

In all of these cases, the second (or later) refresh attempt uses an already-rotated token and is treated as a replay attack, causing the entire token family to be revoked and the user to be logged out unnecessarily.

Describe the solution you'd like

Add an optional refresh token grace / overlap period to the OAuth 2.1 Provider plugin so that refresh token rotation becomes idempotent for a short window, avoiding false-positive theft detections in normal concurrent usage.

Proposed API

Add a new option on OAuthOptions (in packages/oauth-provider/src/types/index.ts), for example:

oauthProvider({
  // ...existing config
  /**
   * Optional grace period (in seconds) where a rotated
   * refresh token can be reused without triggering replay revoke.
   *
   * @default 0 (current behavior: any reuse is treated as replay)
   */
  refreshTokenGracePeriod: 30,
});

Default: 0 (preserve today’s behavior).

Expected behavior

With refreshTokenGracePeriod > 0, the refresh token flow would change as follows:

  1. First use of a refresh token

    • createRefreshToken continues to set revoked on the existing oauthRefreshToken row and creates a new row with a new token (same as today).
    • New access token (and optional ID token) are issued as usual.
  2. Reuse within the grace period

    • handleRefreshTokenGrant finds the old refresh token row and sees revoked is set.
    • Instead of immediately treating this as replay, it checks whether now - revoked < refreshTokenGracePeriod.
    • If within the window:
      • It does not call deleteMany and does not revoke all tokens for that user/client.
      • It returns the same token response that was issued on the first successful refresh (idempotent response)
  3. Reuse after the grace period

    • Behavior stays identical to today:
      • Treat as replay: deleteMany all oauthRefreshToken rows with that clientId and userId.
      • Return invalid_grant.

Implementation notes

The existing schema already has a revoked?: Date field on oauthRefreshToken, so this can be implemented without schema changes:

  • Use revoked as the rotation time and compare it with refreshTokenGracePeriod in handleRefreshTokenGrant.
  • Only perform the current replay logic (family-wide deleteMany) when revoked is set and the grace period has elapsed.

This gives Better Auth the same "rotation overlap window" pattern used by other providers, while preserving the strong replay detection semantics outside that window.

Describe alternatives you've considered

  1. Longer-lived access tokens
    Increase accessTokenExpiresIn so refresh calls are less frequent. This reduces the probability of collision but:

    • Weakens security by extending access token lifetime.
    • Does not eliminate false positives; it just makes them rarer.
  2. Custom fork or wrapper around /oauth2/token
    A deployment could fork or wrap the OAuth provider's token endpoint to add its own caching/grace logic. Beyond being fragile across upgrades, this still suffers from the same underlying race condition: the wrapper itself would need to handle concurrent requests that both load the token before either writes, leading to the same double-read / double-use problem this feature request aims to solve. The fix belongs in the core provider where it can be handled atomically.

  3. Disabling rotation or softening replay detection
    Not rotating refresh tokens or dropping the family-wide revoke on reuse would avoid the logout problem but:

    • Violates OAuth 2.1 requirements for the client type I need to support (public clients), which mandate refresh token rotation.
    • Reduces security if a refresh token is leaked.
    • Moves away from the recommended "reuse indicates possible theft" model.

Additional context

Originally created by @ebramanti on GitHub (Mar 9, 2026). ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. In the OAuth 2.1 Provider plugin, the refresh token rotation and reuse detection logic currently treats any reuse of a rotated refresh token as a replay attack and revokes all refresh tokens for that `(clientId, userId)` pair, forcing the user to re-authorize for that client. This behavior is implemented directly in the OAuth provider: - `createRefreshToken` sets `revoked` on the existing `oauthRefreshToken` row whenever a refresh token is used and a new one is issued. - `handleRefreshTokenGrant` checks the stored refresh token, and if `revoked` is set, it performs a "replay revoke" by calling `deleteMany` on `oauthRefreshToken` with `clientId = client_id` and `userId = refreshToken.userId`, then returns `invalid_grant`. In practice, this strict "family revocation on reuse" strategy means that **any** reuse of a rotated refresh token (even from legitimate clients) logs the user out of that client. This is secure, but it produces false positives in common real-world scenarios: - Multiple browser tabs or components independently detect an expired access token and trigger refresh calls at nearly the same time. - Mobile apps where background tasks and foreground actions both cause refresh attempts. - Network timeouts or lost responses where the first refresh succeeds on the server but the client never sees the response and retries with the same refresh token. In all of these cases, the second (or later) refresh attempt uses an already-rotated token and is treated as a replay attack, causing the entire token family to be revoked and the user to be logged out unnecessarily. ### Describe the solution you'd like Add an optional **refresh token grace / overlap period** to the OAuth 2.1 Provider plugin so that refresh token rotation becomes **idempotent for a short window**, avoiding false-positive theft detections in normal concurrent usage. ### Proposed API Add a new option on `OAuthOptions` (in `packages/oauth-provider/src/types/index.ts`), for example: ```ts oauthProvider({ // ...existing config /** * Optional grace period (in seconds) where a rotated * refresh token can be reused without triggering replay revoke. * * @default 0 (current behavior: any reuse is treated as replay) */ refreshTokenGracePeriod: 30, }); ``` Default: `0` (preserve today’s behavior). ### Expected behavior With `refreshTokenGracePeriod > 0`, the refresh token flow would change as follows: 1. **First use of a refresh token** - `createRefreshToken` continues to set `revoked` on the existing `oauthRefreshToken` row and creates a new row with a new token (same as today). - New access token (and optional ID token) are issued as usual. 2. **Reuse within the grace period** - `handleRefreshTokenGrant` finds the old refresh token row and sees `revoked` is set. - Instead of immediately treating this as replay, it checks whether `now - revoked < refreshTokenGracePeriod`. - If within the window: - It does **not** call `deleteMany` and does **not** revoke all tokens for that user/client. - It returns the **same token response** that was issued on the first successful refresh (idempotent response) 3. **Reuse after the grace period** - Behavior stays identical to today: - Treat as replay: `deleteMany` all `oauthRefreshToken` rows with that `clientId` and `userId`. - Return `invalid_grant`. ### Implementation notes The existing schema already has a `revoked?: Date` field on `oauthRefreshToken`, so this can be implemented without schema changes: - Use `revoked` as the rotation time and compare it with `refreshTokenGracePeriod` in `handleRefreshTokenGrant`. - Only perform the current replay logic (family-wide `deleteMany`) when `revoked` is set **and** the grace period has elapsed. This gives Better Auth the same "rotation overlap window" pattern used by other providers, while preserving the strong replay detection semantics outside that window. ### Describe alternatives you've considered 1. **Longer-lived access tokens** Increase `accessTokenExpiresIn` so refresh calls are less frequent. This reduces the probability of collision but: - Weakens security by extending access token lifetime. - Does not eliminate false positives; it just makes them rarer. 2. **Custom fork or wrapper around `/oauth2/token`** A deployment could fork or wrap the OAuth provider's token endpoint to add its own caching/grace logic. Beyond being fragile across upgrades, this still suffers from the same underlying race condition: the wrapper itself would need to handle concurrent requests that both load the token before either writes, leading to the same double-read / double-use problem this feature request aims to solve. The fix belongs in the core provider where it can be handled atomically. 3. **Disabling rotation or softening replay detection** Not rotating refresh tokens or dropping the family-wide revoke on reuse would avoid the logout problem but: - Violates OAuth 2.1 requirements for the client type I need to support (public clients), which mandate refresh token rotation. - Reduces security if a refresh token is leaked. - Moves away from the recommended "reuse indicates possible theft" model. ### Additional context - Lucid on avoiding false positives in refresh token theft detection: https://lucid.co/techblog/2023/09/18/avoiding-false-positives-in-oauth-2-0-refresh-token-theft-detection - Auth0's documentation on configuring rotation overlap: https://auth0.com/docs/secure/tokens/refresh-tokens/configure-refresh-token-rotation - Okta's grace period documentation: https://developer.okta.com/docs/guides/refresh-tokens/main/
GiteaMirror added the enhancement label 2026-03-13 10:34:56 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#3022