Clarify the API key travels in the consent POST, not 'never on the wire'

This commit is contained in:
Jeremiah Lowin 2026-06-02 19:44:47 -04:00
commit 11300950fb
No known key found for this signature in database
3 changed files with 12 additions and 9 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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", ""),