Add JWT audience validation and RFC 8707 warnings to auth providers (#3204)

* Add JWT audience validation and RFC 8707 warnings to auth providers

* chore: Update SDK documentation

* Update AuthKit example README env var name

* Move RFC 8707 warnings inside default verifier guard

* chore: Update SDK documentation

---------

Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
This commit is contained in:
Jeremiah Lowin 2026-02-17 18:16:05 -05:00 committed by GitHub
commit a8b100eb8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 78 additions and 48 deletions

1
.gitignore vendored
View file

@ -70,6 +70,7 @@ dmypy.json
/PLAN.md
/TODO.md
/STATUS.md
plans/
# Common FastMCP test files
/test.py

View file

@ -11,6 +11,9 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
This guide shows you how to secure your FastMCP server using WorkOS's **AuthKit**, a complete authentication and user management solution. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where AuthKit handles user login and your FastMCP server validates the tokens.
<Warning>
AuthKit does not currently support [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators, so FastMCP cannot validate that tokens were issued for the specific resource server. If you need resource-specific audience validation, consider using [WorkOSProvider](/integrations/workos) (OAuth proxy pattern) instead.
</Warning>
## Configuration
### Prerequisites
@ -62,21 +65,23 @@ To test your server, you can use the `fastmcp` CLI to run it locally. Assuming y
fastmcp run server.py --transport http --port 8000
```
Now, you can use a FastMCP client to test that you can reach your server after authenticating:
AuthKit defaults DCR clients to `client_secret_basic` for token exchange, which conflicts with how some MCP clients send credentials. To avoid token exchange errors, register as a public client by setting `token_endpoint_auth_method` to `"none"`:
```python
```python client.py
from fastmcp import Client
from fastmcp.client.auth import OAuth
import asyncio
auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"})
async def main():
async with Client("http://localhost:8000/mcp", auth="oauth") as client:
async with Client("http://localhost:8000/mcp", auth=auth) as client:
assert await client.ping()
if __name__ == "__main__":
asyncio.run(main())
```
## Production Configuration
For production deployments, load sensitive configuration from environment variables:
@ -93,4 +98,4 @@ auth = AuthKitProvider(
)
mcp = FastMCP(name="AuthKit Secured App", auth=auth)
```
```

View file

@ -11,21 +11,43 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
This guide shows you how to secure your FastMCP server using **Supabase Auth**. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where Supabase handles user authentication and your FastMCP server validates the tokens.
<Warning>
Supabase Auth does not currently support [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators, so FastMCP cannot validate that tokens were issued for the specific resource server.
</Warning>
## Consent UI Requirement
Supabase's OAuth Server delegates the user consent screen to your application. When an MCP client initiates authorization, Supabase authenticates the user and then redirects to your application at a configured callback URL (e.g., `https://your-app.com/oauth/callback?authorization_id=...`). Your application must host a page that calls Supabase's `approveAuthorization()` or `denyAuthorization()` APIs to complete the flow.
`SupabaseProvider` handles the resource server side (token verification and metadata), but you are responsible for building and hosting the consent UI separately. See [Supabase's OAuth Server documentation](https://supabase.com/docs/guides/auth/oauth-server/getting-started) for details on implementing the authorization page.
## Configuration
### Prerequisites
Before you begin, you will need:
1. A **[Supabase Account](https://supabase.com/)** with a project or a self-hosted **Supabase Auth** instance
2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`)
2. **OAuth Server enabled** in your Supabase Dashboard (Authentication → OAuth Server)
3. **Dynamic Client Registration enabled** in the same settings
4. A **consent UI** hosted at your configured authorization path (see above)
5. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`)
### Step 1: Get Supabase Project URL
### Step 1: Enable Supabase OAuth Server
In your Supabase Dashboard:
1. Go to **Authentication → OAuth Server**
2. Enable the **OAuth Server**
3. Set your **Site URL** to where your consent UI is hosted
4. Set the **Authorization Path** (e.g., `/oauth/callback`)
5. Enable **Allow Dynamic OAuth Apps** for MCP client registration
### Step 2: Get Supabase Project URL
In your Supabase Dashboard:
1. Go to **Project Settings**
2. Copy your **Project URL** (e.g., `https://abc123.supabase.co`)
### Step 2: FastMCP Configuration
### Step 3: FastMCP Configuration
Create your FastMCP server using the `SupabaseProvider`:
@ -33,12 +55,9 @@ Create your FastMCP server using the `SupabaseProvider`:
from fastmcp import FastMCP
from fastmcp.server.auth.providers.supabase import SupabaseProvider
# Configure Supabase Auth
auth = SupabaseProvider(
project_url="https://abc123.supabase.co",
base_url="http://localhost:8000",
# Optional: customize auth_route for self-hosted Supabase Auth with custom routes
# auth_route="/my/auth/route"
)
mcp = FastMCP("Supabase Protected Server", auth=auth)
@ -62,8 +81,6 @@ Start your FastMCP server with HTTP transport to enable OAuth flows:
fastmcp run server.py --transport http --port 8000
```
Your server is now running and protected by Supabase authentication.
### Testing with a Client
Create a test client that authenticates with your Supabase-protected server:
@ -73,12 +90,9 @@ from fastmcp import Client
import asyncio
async def main():
# The client will automatically handle Supabase OAuth
async with Client("http://localhost:8000/mcp", auth="oauth") as client:
# First-time connection will open Supabase login in your browser
print("✓ Authenticated with Supabase!")
print("Authenticated with Supabase!")
# Test the protected tool
result = await client.call_tool("protected_tool", {"message": "Hello!"})
print(result)
@ -87,9 +101,9 @@ if __name__ == "__main__":
```
When you run the client for the first time:
1. Your browser will open to Supabase's authorization page
2. After you authorize, you'll be redirected back
3. The client receives the token and can make authenticated requests
1. Your browser will open to Supabase's authorization endpoint
2. After authenticating, Supabase redirects to your consent UI
3. After you approve, the client receives the token and can make authenticated requests
## Production Configuration
@ -100,11 +114,9 @@ import os
from fastmcp import FastMCP
from fastmcp.server.auth.providers.supabase import SupabaseProvider
# Load configuration from environment variables
auth = SupabaseProvider(
project_url=os.environ["SUPABASE_PROJECT_URL"],
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
auth_route=os.environ.get("SUPABASE_AUTH_ROUTE", "/auth/v1"), # Optional: for custom routes
)
mcp = FastMCP(name="Supabase Secured App", auth=auth)

View file

@ -44,7 +44,7 @@ https://docs.scalekit.com/mcp/overview/
**Methods:**
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/scalekit.py#L144" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/scalekit.py#L145" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]

View file

@ -50,7 +50,7 @@ https://supabase.com/docs/guides/auth/jwts
**Methods:**
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/supabase.py#L120" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/supabase.py#L126" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]

View file

@ -85,7 +85,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification
**Methods:**
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L290" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L301" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_routes(self, mcp_path: str | None = None) -> list[Route]

View file

@ -7,7 +7,7 @@ Demonstrates FastMCP server protection with AuthKit Dynamic Client Registration.
1. Set your AuthKit domain:
```bash
export FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN="https://your-app.authkit.app"
export AUTHKIT_DOMAIN="https://your-app.authkit.app"
```
2. Run the server:

View file

@ -9,23 +9,24 @@ To run:
import asyncio
from fastmcp.client import Client
from fastmcp.client.auth import OAuth
SERVER_URL = "http://127.0.0.1:8000/mcp"
async def main():
try:
async with Client(SERVER_URL, auth="oauth") as client:
assert await client.ping()
print("✅ Successfully authenticated!")
# AuthKit defaults DCR clients to client_secret_basic, which conflicts
# with how MCP SDKs send credentials. Force "none" to register as a
# public client and avoid token exchange errors.
auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"})
async with Client(SERVER_URL, auth=auth) as client:
assert await client.ping()
print("Successfully authenticated!")
tools = await client.list_tools()
print(f"🔧 Available tools ({len(tools)}):")
for tool in tools:
print(f" - {tool.name}: {tool.description}")
except Exception as e:
print(f"❌ Authentication failed: {e}")
raise
tools = await client.list_tools()
print(f"Available tools ({len(tools)}):")
for tool in tools:
print(f" - {tool.name}: {tool.description}")
if __name__ == "__main__":

View file

@ -15,8 +15,7 @@ from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import AuthKitProvider
auth = AuthKitProvider(
authkit_domain=os.getenv("FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN")
or "",
authkit_domain=os.getenv("AUTHKIT_DOMAIN") or "",
base_url="http://localhost:8000",
)

View file

@ -15,13 +15,7 @@ SERVER_URL = "http://localhost:8000/mcp"
async def main():
try:
async with Client(
SERVER_URL,
auth=OAuth(
# Replace with your own CIMD document URL
client_metadata_url="https://www.jlowin.dev/mcp-client.json",
),
) as client:
async with Client(SERVER_URL, auth=OAuth()) as client:
assert await client.ping()
print("✅ Successfully authenticated!")

View file

@ -127,6 +127,7 @@ class ScalekitProvider(RemoteAuthProvider):
jwks_uri=f"{self.environment_url}/keys",
issuer=self.environment_url,
algorithm="RS256",
audience=self.resource_id,
required_scopes=self.required_scopes or None,
)
else:

View file

@ -103,10 +103,16 @@ class SupabaseProvider(RemoteAuthProvider):
# Create default JWT verifier if none provided
if token_verifier is None:
logger.warning(
"SupabaseProvider cannot validate token audience for the specific resource "
"because Supabase Auth does not support RFC 8707 resource indicators. "
"This may leave the server vulnerable to cross-server token replay."
)
token_verifier = JWTVerifier(
jwks_uri=f"{self.project_url}/{self.auth_route}/.well-known/jwks.json",
issuer=f"{self.project_url}/{self.auth_route}",
algorithm=algorithm,
audience="authenticated",
required_scopes=parsed_scopes,
)

View file

@ -252,6 +252,7 @@ class AuthKitProvider(RemoteAuthProvider):
*,
authkit_domain: AnyHttpUrl | str,
base_url: AnyHttpUrl | str,
client_id: str | None = None,
required_scopes: list[str] | None = None,
token_verifier: TokenVerifier | None = None,
):
@ -260,6 +261,9 @@ class AuthKitProvider(RemoteAuthProvider):
Args:
authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app")
base_url: Public URL of this FastMCP server
client_id: Your WorkOS project client ID (e.g., "client_01ABC..."). Used to
validate the JWT audience claim. Found in your WorkOS Dashboard under
API Keys. This is the project-level client ID, not individual MCP client IDs.
required_scopes: Optional list of scopes to require for all requests
token_verifier: Optional token verifier. If None, creates JWT verifier for AuthKit
"""
@ -273,10 +277,17 @@ class AuthKitProvider(RemoteAuthProvider):
# Create default JWT verifier if none provided
if token_verifier is None:
logger.warning(
"AuthKitProvider cannot validate token audience for the specific resource "
"because AuthKit does not support RFC 8707 resource indicators. "
"This may leave the server vulnerable to cross-server token replay. "
"Consider using WorkOSProvider (OAuth proxy) for audience-bound tokens."
)
token_verifier = JWTVerifier(
jwks_uri=f"{self.authkit_domain}/oauth2/jwks",
issuer=self.authkit_domain,
algorithm="RS256",
audience=client_id,
required_scopes=parsed_scopes,
)

View file

@ -94,7 +94,7 @@ class TestScalekitProvider:
assert isinstance(provider.token_verifier, JWTVerifier)
assert provider.token_verifier.jwks_uri == "https://my-env.scalekit.com/keys"
assert provider.token_verifier.issuer == "https://my-env.scalekit.com"
assert provider.token_verifier.audience is None
assert provider.token_verifier.audience == "sk_resource_456"
def test_required_scopes_hooks_into_verifier(self):
"""Token verifier should enforce required scopes when provided."""