How OAuth Refresh Token Rotation Works in OpenSEO: A Deep Dive into Secure Token Handling
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 to balance security with user experience:
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 (lines 15–29) preserves the original authentication context by copying it into new token properties:
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 (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 throw an OAuthError if requestedScope lacks this requirement:
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
resourceparameter entirely
The test case at lines 45–64 of 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:
// 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 |
Configures the Cloudflare OAuthProvider, defines TTL constants, and implements scope validation in tokenExchangeCallback |
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 |
Registers the offline access type to request refresh tokens during initial authorization |
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-providerwith 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
mcpon every refresh; resource validation restricts to/mcpor 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 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 verifies this behavior, allowing clients to handle near-expiry scenarios gracefully without breaking authentication flows.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →