Skip to content

Refresh token expiry hardcoded to 30 days in OAuthProxy - ignoring upstream provider configuration #3987

@thisisarko

Description

@thisisarko

What happened?

⚠️ Disclaimer: This issue was identified through a combination of AI-assisted code tracing and manual research. While I've traced through the relevant code paths in exchange_authorization_code and exchange_refresh_token and believe the analysis to be accurate, I may have missed edge cases or misread parts of the flow. I'd encourage a maintainer to verify the analysis before acting on it. Happy to discuss or provide more context.

Summary

OAuthProxy has no way to configure a fallback refresh token expiry. The code hardcodes 30 days in multiple places, which silently overrides the actual expiry configured in the upstream provider. This mirrors the existing fallback_access_token_expiry_seconds parameter which correctly handles the same problem for access tokens.

Bug 1: exchange_authorization_code hardcodes 30-day JTI mapping TTL

https://github.com/PrefectHQ/fastmcp/blob/v3.2.4/src/fastmcp/server/auth/oauth_proxy/proxy.py#L1081-L1089

The JTI mapping for the refresh token is hardcoded to 30 days regardless of the actual refresh token expiry.
This is inconsistent - _refresh_token_store and _upstream_token_store are stored with ttl=refresh_expires_in, but the JTI mapping that links them together expires early.

Bug 2: exchange_refresh_token ignores refresh_expires_in when issuing the new FastMCP refresh JWT

When Cognito (and other providers) don't return a refresh_token in the refresh response, new_refresh_expires_in stays None, causing the new FastMCP refresh token JWT to be issued with a hardcoded 30-day expiry.

# exchange_refresh_token()
new_refresh_expires_in = None
if new_upstream_refresh := token_response.get("refresh_token"):
    # Cognito does NOT return refresh_token on refresh → block skipped
    # new_refresh_expires_in stays None
    ...

new_fastmcp_refresh = self.jwt_issuer.issue_refresh_token(
    ...
    expires_in=new_refresh_expires_in or 60 * 60 * 24 * 30,  # ❌ falls back to 30 days
)

Even though upstream_token_set.refresh_token_expires_at correctly holds the real expiry and refresh_ttl is calculated correctly, it is never used when issuing the JWT. So the store has the correct TTL but the JWT itself bakes in a 30-day exp claim — and since verify_token() checks exp first, users are forced to re-login after 30 days regardless of what's in client storage.

Proposed Fix

Add a fallback_refresh_token_expiry_seconds parameter to OAuthProxy mirroring the existing fallback_access_token_expiry_seconds.

Example Code

Version Information

FastMCP version:                                                                  3.2.0
MCP version:                                                                     1.27.0
Python version:                                                                  3.14.3
Platform:                                             macOS-26.4-arm64-arm-64bit-Mach-O

Metadata

Metadata

Assignees

No one assigned

    Labels

    authRelated to authentication (Bearer, JWT, OAuth, WorkOS) for client or server.bugSomething isn't working. Reports of errors, unexpected behavior, or broken functionality.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions