From 11300950fbe387232a43e83e83ae180b1d26e57c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:44:47 -0400 Subject: [PATCH] Clarify the API key travels in the consent POST, not 'never on the wire' --- docs/servers/auth/api-key-oauth.mdx | 2 +- examples/auth/api_key_oauth/README.md | 12 +++++++----- examples/auth/api_key_oauth/provider.py | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/servers/auth/api-key-oauth.mdx b/docs/servers/auth/api-key-oauth.mdx index 2097a0f69..bfa791e9d 100644 --- a/docs/servers/auth/api-key-oauth.mdx +++ b/docs/servers/auth/api-key-oauth.mdx @@ -104,7 +104,7 @@ async def exchange_authorization_code(self, client, authorization_code): ) ``` -The token the provider issues is a *reference token*: it carries only a `jti`, while the API key itself lives in a Fernet-encrypted store keyed by that `jti`. The key is encrypted at rest and never travels on the wire. This reuses the primitives the OAuth proxy is built on, so the security-sensitive parts are not hand-rolled. +The token the provider issues is a *reference token*: it carries only a `jti`, while the API key itself lives in a Fernet-encrypted store keyed by that `jti`. The key is encrypted at rest and never appears in a URL or in the token itself—the user submits it once in the consent POST, so the server should be served over HTTPS. This reuses the primitives the OAuth proxy is built on, so the security-sensitive parts are not hand-rolled. Both the token signing key and the storage encryption key derive from a single configured secret with `derive_jwt_key`, so the same secret across restarts keeps previously issued tokens valid. `JWTIssuer` mints the tokens, and the store defaults to the same encrypted file store the [OAuth proxy](/servers/auth/oauth-proxy) uses. diff --git a/examples/auth/api_key_oauth/README.md b/examples/auth/api_key_oauth/README.md index 3c827e04a..631456fe7 100644 --- a/examples/auth/api_key_oauth/README.md +++ b/examples/auth/api_key_oauth/README.md @@ -30,7 +30,8 @@ unchanged) and reuses the same primitives FastMCP's OAuth proxy is built on: Fernet storage-encryption key, `JWTIssuer` issues *reference tokens* that carry only a `jti`, and a Fernet-encrypted store holds the transaction, the authorization code, and the API key. The key is encrypted at rest and never -travels on the wire; tools read it back from the access token claims. +appears in a URL or in the issued token; tools read it back from the access +token claims. Two integration points are yours to fill in. First, verify the pasted key against your backend before a token is issued — the hook may be async, so it can @@ -79,10 +80,11 @@ To wire it into a real client, point Claude Desktop / ChatGPT at This is a reference, not a drop-in. Before shipping: -- **The API key is encrypted at rest and never on the wire.** The access token - is a reference token carrying only a `jti`; the key lives in the Fernet- - encrypted store keyed by that `jti`. It also never travels in a URL — it is - submitted in the form POST body and bound to an opaque authorization code. +- **The API key is encrypted at rest and stays out of URLs and tokens.** The + access token is a reference token carrying only a `jti`; the key lives in the + Fernet-encrypted store keyed by that `jti`, and never appears in a redirect + URL. The user submits it once in the consent form POST, so serve the server + over HTTPS to protect it in transit. - **Load `jwt_signing_key` from your secret store.** Both the token signing key and the storage encryption key derive from it, so the same secret across restarts keeps previously issued tokens valid. diff --git a/examples/auth/api_key_oauth/provider.py b/examples/auth/api_key_oauth/provider.py index 20152f050..5f1472109 100644 --- a/examples/auth/api_key_oauth/provider.py +++ b/examples/auth/api_key_oauth/provider.py @@ -23,8 +23,9 @@ is built on: carries only a `jti`, never the API key itself. - A Fernet-encrypted key-value store holds the transient transaction, the authorization code, and the API key — each keyed and TTL-bound, encrypted at - rest. The key never travels on the wire; `load_access_token` validates the JWT - and looks the key back up. + rest. The key never appears in a URL or in the issued token; `load_access_token` + validates the JWT and looks the key back up. (The user submits it once in the + consent POST, so serve the server over HTTPS.) Tools read the key from the access token claims (e.g. via `CurrentAccessToken`). @@ -467,7 +468,7 @@ class APIKeyOAuthProvider(OAuthProvider): scope = payload.get("scope", "") # Surface the decrypted key on the token's claims so tools can read it - # via get_access_token(). It lives only in memory here, never on the wire. + # via get_access_token(). It lives only in memory here, never in the token. return AccessToken( token=token, client_id=payload.get("client_id", ""),