If you stand up an MCP server with GoogleProvider, OAuth works on the first try. Clients authorize, tools run, and refresh appears to work — until the server restarts.
That is not a bug you fix after the fact. It is a configuration decision you make up front, because of how OAuthProxy implements refresh. This post covers what refresh actually requires from your deployment, and how to satisfy it.
Verified against fastmcp==3.4.2 and py-key-value-aio==0.4.5.
Refresh in OAuthProxy is a storage lookup
The key thing to understand: GoogleProvider extends OAuthProxy, and the token your MCP client holds is not the upstream Google token. The proxy issues its own credential, which is a reference into a key-value store. Google's tokens stay server-side.
That design is what makes the proxy useful — clients never see upstream credentials, and the proxy can refresh upstream tokens on their behalf. It also means refresh has a hard requirement: the store must still contain the entry.
In FastMCP 3.4.2 the proxy keeps six collections:
default_collection="mcp-upstream-tokens" # upstream Google access/refresh tokens
default_collection="mcp-oauth-proxy-clients" # DCR client registrations
default_collection="mcp-oauth-transactions" # in-flight IdP callbacks
default_collection="mcp-authorization-codes"
default_collection="mcp-jti-mappings"
default_collection="mcp-refresh-tokens" # refresh token metadata, keyed by hash
When a refresh arrives, the proxy hashes the incoming refresh token and looks up its metadata:
token_hash = _hash_token(refresh_token)
metadata = await self._refresh_token_store.get(key=token_hash)
if not metadata:
logger.warning(
"Refresh token not found for client=%s (token_hash=%s); it was "
"already rotated, expired, or revoked. Rejecting with invalid_grant, "
"which forces the client to re-authenticate.", ...
)
No entry, no refresh. invalid_grant, and the client falls back to a full re-authorization. Two of those six collections gate every refresh: mcp-refresh-tokens for the grant itself, and mcp-oauth-proxy-clients for the Dynamic Client Registration the grant belongs to.
So "supporting refresh tokens" reduces to one question: where does that store live, and who can see it?
The default store answers that question badly for servers
If you never pass client_storage, the proxy builds one:
# fastmcp/server/auth/oauth_proxy/proxy.py
if client_storage is None:
storage_encryption_key = derive_jwt_key(
high_entropy_material=jwt_signing_key.decode(),
salt="fastmcp-storage-encryption-key",
)
key_fingerprint = hashlib.sha256(storage_encryption_key).hexdigest()[:12]
storage_dir = settings.home / "oauth-proxy" / key_fingerprint
storage_dir.mkdir(parents=True, exist_ok=True)
file_store = FileTreeStore(data_directory=storage_dir, ...)
client_storage = FernetEncryptionWrapper(
key_value=file_store,
fernet=Fernet(key=storage_encryption_key),
raise_on_decryption_error=False,
)
An encrypted file store on local disk. Ideal for a long-lived single process; wrong for anything that redeploys or scales horizontally. On Cloud Run, Fly, ECS or Kubernetes the container filesystem is per-revision and per-replica, so refresh state is either erased on deploy or invisible to the instance handling the request.
Worth noting because it makes the failure counterintuitive: the JWT signing key is not ephemeral.
if jwt_signing_key is None:
if upstream_client_secret is None:
raise ValueError(...)
jwt_signing_key = derive_jwt_key(
high_entropy_material=upstream_client_secret,
salt="fastmcp-jwt-signing-key",
)
It is derived deterministically from the upstream client secret, which lives in your secret manager. So tokens still verify after a restart — they just resolve to nothing. You see a 401 on /token, not a signature error:
12:53:44 200 /.well-known/oauth-authorization-server
12:53:44 401 https://mcp-dev.example.com/token <- refresh rejected
12:54:37 401 /mcp
And because the same store backs every instance, the identical failure shows up under scale-out with no deploy involved.
Configure a shared store
client_storage accepts any AsyncKeyValue from py-key-value-aio, which FastMCP already depends on. Version 0.4.5 ships stores for Firestore, PostgreSQL, Redis, S3, DynamoDB and MongoDB, so the backend is a deployment choice, not a code change.
We chose Firestore: it authenticates with ADC from the runtime service account, so there is no connection string and no additional secret to rotate.
from cryptography.fernet import Fernet
from fastmcp.server.auth.jwt_issuer import derive_jwt_key
from key_value.aio.protocols import AsyncKeyValue
_JWT_SIGNING_KEY_SALT = "fastmcp-jwt-signing-key"
_STORAGE_ENCRYPTION_KEY_SALT = "fastmcp-storage-encryption-key"
def _derive_storage_encryption_key(client_secret: str) -> bytes:
"""Reproduce FastMCP's own key derivation chain."""
jwt_signing_key = derive_jwt_key(
high_entropy_material=client_secret,
salt=_JWT_SIGNING_KEY_SALT,
)
return derive_jwt_key(
high_entropy_material=jwt_signing_key.decode(),
salt=_STORAGE_ENCRYPTION_KEY_SALT,
)
def build_client_storage(database: str, namespace: str, project: str, client_secret: str) -> AsyncKeyValue:
from key_value.aio.stores.firestore import (
FirestoreStore,
FirestoreV1CollectionSanitizationStrategy,
FirestoreV1KeySanitizationStrategy,
)
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from key_value.aio.wrappers.prefix_collections import PrefixCollectionsWrapper
return FernetEncryptionWrapper(
key_value=PrefixCollectionsWrapper(
key_value=FirestoreStore(
project=project,
database=database,
key_sanitization_strategy=FirestoreV1KeySanitizationStrategy(),
collection_sanitization_strategy=FirestoreV1CollectionSanitizationStrategy(),
),
prefix=namespace,
),
fernet=Fernet(key=_derive_storage_encryption_key(client_secret)),
raise_on_decryption_error=False,
)
Wire it into the provider:
auth_provider = GoogleProvider(
client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID"),
client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET"),
base_url=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_BASE_URL"),
client_storage=build_client_storage(...),
)
Every layer in that stack is load-bearing. Here is why each one is there.
Re-apply encryption yourself
Look at where FernetEncryptionWrapper sits in the default path: inside the if client_storage is None branch. Pass your own store and that wrapper is never applied. Upstream Google access and refresh tokens then land in mcp-upstream-tokens in whatever form the backend writes — plaintext documents, for Firestore.
Deriving the key from the same client secret keeps the number of managed secrets at zero, and it rotates with the credential it protects.
Pass raise_on_decryption_error=False explicitly
FastMCP passes False to its own wrapper. FernetEncryptionWrapper defaults to True.
The difference only surfaces when you rotate the upstream client secret: with True, stale ciphertext raises and you serve 500s; with False it counts as a cache miss, so clients re-register and re-authenticate. Degraded beats broken — match FastMCP.
Sanitize keys for your backend
OAuthProxy enables CIMD (Client ID Metadata Document) by default (enable_cimd: bool = True), and CIMD client IDs are URLs:
if self._cimd_manager is not None and self._cimd_manager.is_cimd_client_id(client_id):
cimd_client = await self._cimd_manager.get_client(client_id)
if cimd_client is not None:
await self._client_store.put(key=client_id, value=cimd_client)
That URL becomes a storage key verbatim, and a Firestore document ID cannot contain /. FirestoreStore defaults to passthrough sanitization, so you must opt in. FastMCP's own default explicitly passes FileTreeV1KeySanitizationStrategy for the same reason — whichever backend you pick, check what its key constraints are.
Prefix collections if a database is shared
Those six collection names are constants. Two MCP servers pointed at one database share mcp-oauth-proxy-clients and everything else. PrefixCollectionsWrapper(prefix=<service>) namespaces them, which is what makes a single shared database viable when you expect to run more than one Google-OAuth MCP server.
Never fall back to the ephemeral store
It is tempting to catch storage errors and degrade to the default file store. Don't. A silent fallback restores precisely the behavior you configured the store to avoid, except now it is invisible — the service looks healthy and quietly drops every session on the next deploy. Fail loudly at startup on misconfiguration and let runtime storage errors surface.
Summary
- In
OAuthProxy, the token a client holds is a reference into a key-value store; refresh is a lookup inmcp-refresh-tokensplus the client registration inmcp-oauth-proxy-clients. - The default store is a container-local file store, so refresh survives neither redeploys nor scale-out. The JWT signing key is derived from the client secret and does survive, which is why the symptom is a
/token401 rather than a signature failure. - Supporting refresh means passing a shared
client_storage— then re-applying the encryption wrapper, matchingraise_on_decryption_error=False, and sanitizing keys for the backend. - Prefix collections when a database is shared across servers, and never silently fall back to the ephemeral store.
Top comments (0)