# How OAuth Refresh Token Rotation Works in OpenSEO: A Deep Dive into Secure Token Handling

> Discover how OpenSEO's OAuth refresh token rotation ensures secure token handling. Learn about automatic new token issuance and graceful old token invalidation.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: deep-dive
- Published: 2026-08-10

---

**OpenSEO implements OAuth 2.0 refresh token rotation through Cloudflare's `workers-oauth-provider`, automatically issuing new refresh tokens with each exchange while keeping old tokens valid until the new one is first used.**

This article explains the mechanics behind OpenSEO's **OAuth refresh token rotation** implementation, based on the actual source code in the `every-app/open-seo` repository. Understanding these security boundaries helps developers integrate correctly and avoid authentication failures in production.

---

## Token Lifetimes and Configuration

OpenSEO defines strict time-to-live (TTL) constants in [`src/server/mcp/oauth-provider.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-provider.ts) to balance security with user experience:

```typescript
const MCP_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24;          // 1 day
const MCP_REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;   // 30 days

```

These values (lines 46–49) mean access tokens expire quickly to limit exposure, while refresh tokens persist long enough for legitimate re-authentication without requiring users to re-approve access.

---

## How Token Exchange Works

When a client calls `/api/auth/oauth2/token` with `grant_type=refresh_token`, the underlying `OAuthProvider` automatically handles rotation. The `tokenExchangeCallback` in [`src/server/mcp/oauth-provider.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-provider.ts) (lines 15–29) preserves the original authentication context by copying it into new token properties:

```typescript
tokenExchangeCallback: async (props) => {
  // Validate required scope
  if (!props.requestedScope?.includes("mcp")) {
    throw new OAuthErrors.InvalidScopeError("mcp scope required");
  }
  
  // Return new tokens with preserved context
  return {
    accessTokenProps: { ...props },
    refreshTokenProps: { ...props },
  };
}

```

**Key point**: The provider generates **both a new access token AND a new refresh token** on every exchange—this is true rotation, not simple renewal.

---

## Rotation Semantics: When Old Tokens Actually Expire

OpenSEO uses a **graceful rotation strategy** where the previous refresh token remains valid until the new one is consumed. This prevents race conditions when clients retry requests or have network interruption during token exchange.

The end-to-end test in [`src/server/mcp/oauth-refresh.e2e.test.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-refresh.e2e.test.ts) (lines 21–38) explicitly verifies this behavior:

| Step | Action | Result |
|:---|:---|:---|
| 1 | Call `/token` with original refresh token | Returns `new_refresh_token` |
| 2 | Call `/token` with **original** refresh token (again) | **Still succeeds** — old token not yet revoked |
| 3 | Call `/token` with **new** refresh token | Succeeds, now old token invalidated |
| 4 | Any subsequent call with original token | `400 invalid_grant` error |

This "use-to-invalidate" pattern prevents clients from being locked out due to timing issues between token issuance and storage.

---

## Security Enforcement: Scope and Resource Validation

### Required Scope Checking

The `tokenExchangeCallback` enforces that every refresh request includes the `mcp` scope. Lines 16–20 of [`src/server/mcp/oauth-provider.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-provider.ts) throw an `OAuthError` if `requestedScope` lacks this requirement:

```typescript
if (!props.requestedScope?.includes("mcp")) {
  throw new OAuthErrors.InvalidScopeError("mcp scope required");
}

```

### RFC 8707 Resource Constraints

Refresh requests must either:
- Include the canonical resource `/mcp`, **or**
- Omit the `resource` parameter entirely

The test case at lines 45–64 of [`src/server/mcp/oauth-refresh.e2e.test.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-refresh.e2e.test.ts) confirms that mismatched resources return `400` errors rather than proceeding with incorrect authorization contexts.

---

## Edge Case: Near-Expiry Token Handling

OpenSEO handles the 30-second window before refresh token expiration gracefully. The test at lines 11–20 verifies that near-expiry tokens return structured errors rather than 500 internal server errors, preventing cascading "refresh is broken" failures in client applications.

---

## Complete Implementation Example

Here's how to interact with OpenSEO's OAuth refresh token rotation from a client application:

```typescript
// 1) Register a public (secret-less) client
const registration = await fetch(`${BASE}/api/auth/oauth2/register`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    client_name: "MyApp",
    redirect_uris: ["https://myapp.example/callback"],
    grant_types: ["authorization_code", "refresh_token"],
    response_types: ["code"],
  }),
});
const { client_id } = await registration.json(); // token_endpoint_auth_method === "none"

// 2) Perform authorization code flow (PKCE omitted for brevity)
const tokenResp = await fetch(`${BASE}/api/auth/oauth2/token`, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: "https://myapp.example/callback",
    client_id,
  }),
});
const { access_token, refresh_token } = await tokenResp.json();

// 3) Refresh — client_id only (no secret, no scope)
const refreshResp = await fetch(`${BASE}/api/auth/oauth2/token`, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token,
    client_id,
  }),
});
const { access_token: newAccess, refresh_token: newRefresh } = await refreshResp.json();

// 4) Old token still works until this first use of new token
await fetch(`${BASE}/api/auth/oauth2/token`, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: newRefresh,  // <-- first use invalidates old token
    client_id,
  }),
});
// Now original refresh_token will return 400 invalid_grant

```

---

## Key Source Files

| File | Purpose |
|:---|:---|
| [`src/server/mcp/oauth-provider.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-provider.ts) | Configures the Cloudflare `OAuthProvider`, defines TTL constants, and implements scope validation in `tokenExchangeCallback` |
| [`src/server/mcp/oauth-refresh.e2e.test.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-refresh.e2e.test.ts) | End-to-end tests verifying rotation semantics, resource validation, and near-expiry error handling |
| [`src/lib/auth-config.ts`](https://github.com/every-app/open-seo/blob/main/src/lib/auth-config.ts) | Registers the `offline` access type to request refresh tokens during initial authorization |
| [`src/server/mcp/transport.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/transport.ts) | Handles authenticated MCP API calls for testing grant property propagation |

---

## Summary

- **OpenSEO's OAuth refresh token rotation** relies on Cloudflare's `workers-oauth-provider` with 1-day access tokens and 30-day refresh tokens.
- **Old refresh tokens stay valid** until the new token is first used, preventing race condition lockouts.
- **Scope enforcement** requires `mcp` on every refresh; **resource validation** restricts to `/mcp` or omitted.
- **Graceful degradation** near token expiry returns structured errors rather than 500 responses.

---

## Frequently Asked Questions

### What happens if I use an old refresh token after obtaining a new one?

As long as the new refresh token has never been used, the original refresh token remains valid. Once you successfully call `/token` with the new refresh token, the original immediately becomes invalid and returns `400 invalid_grant`. This "use-to-invalidate" pattern protects against network retry issues.

### Why does OpenSEO require the `mcp` scope on every refresh?

The `tokenExchangeCallback` in [`src/server/mcp/oauth-provider.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-provider.ts) explicitly validates `requestedScope.includes("mcp")` and throws `OAuthErrors.InvalidScopeError` if absent. This ensures clients cannot downgrade authorization rights by omitting required scopes during token exchange.

### Do I need a client secret to refresh tokens?

No. OpenSEO supports **public clients** with `token_endpoint_auth_method: "none"`. The code example above demonstrates refresh flows using only `client_id`—no secret required. This aligns with OAuth 2.0 for Browser-Based Apps (BCP) recommendations.

### What error do I get if my refresh token is about to expire?

Rather than a generic 500 error, OpenSEO returns a structured error response when a refresh token is within 30 seconds of expiration. The test case in [`oauth-refresh.e2e.test.ts`](https://github.com/every-app/open-seo/blob/main/oauth-refresh.e2e.test.ts) verifies this behavior, allowing clients to handle near-expiry scenarios gracefully without breaking authentication flows.