diff --git a/docs/integrations/authkit.mdx b/docs/integrations/authkit.mdx
index e99f78351..c77175201 100644
--- a/docs/integrations/authkit.mdx
+++ b/docs/integrations/authkit.mdx
@@ -9,29 +9,32 @@ 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.
-
-
-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.
-
+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 with [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators: AuthKit issues tokens whose `aud` claim is bound to your server's resource URL, and FastMCP validates that claim automatically.
## Configuration
+
### Prerequisites
Before you begin, you will need:
1. A **[WorkOS Account](https://workos.com/)** and a new **Project**.
2. An **[AuthKit](https://www.authkit.com/)** instance configured within your WorkOS project.
-3. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`).
+3. Your FastMCP server's URL (can be localhost for development, e.g., `http://127.0.0.1:8000`).
-### Step 1: AuthKit Configuration
+### Step 1: WorkOS Dashboard
-In your WorkOS Dashboard, enable AuthKit and configure the following settings:
+In the WorkOS Dashboard, go to **Connect → Configuration** and configure:
-
- Go to **Applications → Configuration** and enable **Dynamic Client Registration**. This allows MCP clients register with your application automatically.
+
+ Enable **Dynamic Client Registration** (DCR) so MCP clients can register themselves. Alternatively, enable **Client ID Metadata Document** (CIMD) if your clients support it.
+
- 
+
+ Add your FastMCP server's resource URL (e.g., `http://127.0.0.1:8000/mcp`) as a valid resource indicator.
+
+ This must exactly match what FastMCP advertises in its protected resource metadata. Start your server first and it will log the correct URL on startup — copy that value.
+
+ Without this step, AuthKit falls back to a default environment-scoped audience and audience validation will fail with a 401.
@@ -47,16 +50,18 @@ Create your FastMCP server file and use the `AuthKitProvider` to handle all the
from fastmcp import FastMCP
from fastmcp.server.auth.providers.workos import AuthKitProvider
-# The AuthKitProvider automatically discovers WorkOS endpoints
-# and configures JWT token validation
+# AuthKitProvider automatically discovers WorkOS endpoints, configures JWT
+# validation, and binds the token audience to this server's resource URL.
auth_provider = AuthKitProvider(
authkit_domain="https://your-project-12345.authkit.app",
- base_url="http://localhost:8000" # Use your actual server URL
+ base_url="http://127.0.0.1:8000", # Use your actual server URL
)
mcp = FastMCP(name="AuthKit Secured App", auth=auth_provider)
```
+When the server starts, it logs the resource URL it is validating against. Paste that URL into your Dashboard's **MCP resource indicators** list.
+
## Testing
To test your server, you can use the `fastmcp` CLI to run it locally. Assuming you've saved the above code to `server.py` (after replacing the `authkit_domain` and `base_url` with your actual values!), you can run the following command:
@@ -75,7 +80,7 @@ import asyncio
auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"})
async def main():
- async with Client("http://localhost:8000/mcp", auth=auth) as client:
+ async with Client("http://127.0.0.1:8000/mcp", auth=auth) as client:
assert await client.ping()
if __name__ == "__main__":
@@ -94,7 +99,7 @@ from fastmcp.server.auth.providers.workos import AuthKitProvider
# Load configuration from environment variables
auth = AuthKitProvider(
authkit_domain=os.environ.get("AUTHKIT_DOMAIN"),
- base_url=os.environ.get("BASE_URL", "https://your-server.com")
+ base_url=os.environ.get("BASE_URL", "https://your-server.com"),
)
mcp = FastMCP(name="AuthKit Secured App", auth=auth)
diff --git a/examples/auth/authkit/README.md b/examples/auth/authkit/README.md
new file mode 100644
index 000000000..8c4b8a6aa
--- /dev/null
+++ b/examples/auth/authkit/README.md
@@ -0,0 +1,36 @@
+# AuthKit Example
+
+Protects a FastMCP server with WorkOS AuthKit. The server binds the JWT
+`aud` claim to its own resource URL automatically — you just paste that same
+URL into the WorkOS Dashboard as a resource indicator.
+
+## WorkOS Dashboard setup
+
+In the WorkOS Dashboard for your project, go to **Connect → Configuration** and:
+
+1. Under **MCP Auth**, enable **Dynamic Client Registration** (or **Client ID
+ Metadata Document** if your MCP client supports it).
+2. Under **MCP resource indicators**, add `http://127.0.0.1:8000/mcp` as a
+ valid resource indicator.
+
+## Running
+
+1. Set your AuthKit domain:
+
+ ```bash
+ export AUTHKIT_DOMAIN="https://your-app.authkit.app"
+ ```
+
+2. Start the server. It logs the resource URL it's validating against —
+ that's the URL that must match your dashboard resource indicator:
+
+ ```bash
+ python server.py
+ ```
+
+3. In another terminal, run the client. Your browser will open for AuthKit
+ authentication:
+
+ ```bash
+ python client.py
+ ```
diff --git a/examples/auth/authkit_dcr/client.py b/examples/auth/authkit/client.py
similarity index 100%
rename from examples/auth/authkit_dcr/client.py
rename to examples/auth/authkit/client.py
diff --git a/examples/auth/authkit_dcr/server.py b/examples/auth/authkit/server.py
similarity index 50%
rename from examples/auth/authkit_dcr/server.py
rename to examples/auth/authkit/server.py
index 8974376d2..7611ccddf 100644
--- a/examples/auth/authkit_dcr/server.py
+++ b/examples/auth/authkit/server.py
@@ -1,9 +1,11 @@
-"""AuthKit DCR server example for FastMCP.
+"""AuthKit server example for FastMCP.
-This example demonstrates how to protect a FastMCP server with AuthKit DCR.
+Demonstrates an MCP server secured by WorkOS AuthKit. FastMCP binds the JWT
+audience to this server's resource URL automatically; you configure the same
+URL as an MCP resource indicator in the WorkOS Dashboard.
Required environment variables:
-- FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN: Your AuthKit domain (e.g., "https://your-app.authkit.app")
+- AUTHKIT_DOMAIN: Your AuthKit domain (e.g., "https://your-app.authkit.app")
To run:
python server.py
@@ -16,10 +18,10 @@ from fastmcp.server.auth.providers.workos import AuthKitProvider
auth = AuthKitProvider(
authkit_domain=os.getenv("AUTHKIT_DOMAIN") or "",
- base_url="http://localhost:8000",
+ base_url="http://127.0.0.1:8000",
)
-mcp = FastMCP("AuthKit DCR Example Server", auth=auth)
+mcp = FastMCP("AuthKit Example Server", auth=auth)
@mcp.tool
diff --git a/examples/auth/authkit_dcr/README.md b/examples/auth/authkit_dcr/README.md
deleted file mode 100644
index 808246199..000000000
--- a/examples/auth/authkit_dcr/README.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# AuthKit DCR Example
-
-Demonstrates FastMCP server protection with AuthKit Dynamic Client Registration.
-
-## Setup
-
-1. Set your AuthKit domain:
-
- ```bash
- export AUTHKIT_DOMAIN="https://your-app.authkit.app"
- ```
-
-2. Run the server:
-
- ```bash
- python server.py
- ```
-
-3. In another terminal, run the client:
-
- ```bash
- python client.py
- ```
-
-The client will open your browser for AuthKit authentication.
diff --git a/examples/auth/aws_oauth/README.md b/examples/auth/aws_oauth/README.md
index 9abff838c..c4e25b1f8 100644
--- a/examples/auth/aws_oauth/README.md
+++ b/examples/auth/aws_oauth/README.md
@@ -10,7 +10,7 @@ Demonstrates FastMCP server protection with AWS Cognito OAuth.
- Create an App Client in your User Pool
- Configure the App Client settings:
- Enable "Authorization code grant" flow
- - Add Callback URL: `http://localhost:8000/auth/callback`
+ - Add Callback URL: `http://127.0.0.1:8000/auth/callback`
- Configure OAuth scopes (at minimum: `openid`)
- Note your User Pool ID, App Client ID, Client Secret, and Cognito Domain Prefix
diff --git a/examples/auth/aws_oauth/client.py b/examples/auth/aws_oauth/client.py
index 4043e6d4f..afcf54fd1 100644
--- a/examples/auth/aws_oauth/client.py
+++ b/examples/auth/aws_oauth/client.py
@@ -10,7 +10,7 @@ import asyncio
from fastmcp.client import Client
-SERVER_URL = "http://localhost:8000/mcp"
+SERVER_URL = "http://127.0.0.1:8000/mcp"
async def main():
diff --git a/examples/auth/aws_oauth/server.py b/examples/auth/aws_oauth/server.py
index dfe596a83..261164391 100644
--- a/examples/auth/aws_oauth/server.py
+++ b/examples/auth/aws_oauth/server.py
@@ -31,7 +31,7 @@ auth = AWSCognitoProvider(
or "eu-central-1",
client_id=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET") or "",
- base_url="http://localhost:8000",
+ base_url="http://127.0.0.1:8000",
# redirect_path="/custom/callback"
)
diff --git a/examples/auth/azure_oauth/README.md b/examples/auth/azure_oauth/README.md
index ba0757ca7..98d9ae756 100644
--- a/examples/auth/azure_oauth/README.md
+++ b/examples/auth/azure_oauth/README.md
@@ -10,7 +10,7 @@ This example demonstrates how to use the Azure OAuth provider with FastMCP serve
2. Click "New registration" and configure:
- Name: Your app name
- Supported account types: Choose based on your needs
- - Redirect URI: `http://localhost:8000/auth/callback` (Web platform)
+ - Redirect URI: `http://127.0.0.1:8000/auth/callback` (Web platform)
3. After creation, go to "Certificates & secrets" → "New client secret"
4. Note these values from the Overview page:
- Application (client) ID
diff --git a/examples/auth/azure_oauth/server.py b/examples/auth/azure_oauth/server.py
index d214389aa..e0c9e799e 100644
--- a/examples/auth/azure_oauth/server.py
+++ b/examples/auth/azure_oauth/server.py
@@ -24,7 +24,7 @@ auth = AzureProvider(
client_secret=os.getenv("FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET") or "",
tenant_id=os.getenv("FASTMCP_SERVER_AUTH_AZURE_TENANT_ID")
or "", # Required for single-tenant apps - get from Azure Portal
- base_url="http://localhost:8000",
+ base_url="http://127.0.0.1:8000",
required_scopes=["read"],
# required_scopes is automatically loaded from FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES
# At least one scope is required - use unprefixed scope names from your Azure App (e.g., ["read", "write"])
diff --git a/examples/auth/clerk_oauth/README.md b/examples/auth/clerk_oauth/README.md
index 84d2b44b1..9a79ff566 100644
--- a/examples/auth/clerk_oauth/README.md
+++ b/examples/auth/clerk_oauth/README.md
@@ -9,7 +9,7 @@ Demonstrates FastMCP server protection with Clerk OAuth.
- Create or select an application
- Go to Developers > OAuth Applications
- Create an OAuth application
- - Add Authorized redirect URI: `http://localhost:8000/auth/callback`
+ - Add Authorized redirect URI: `http://127.0.0.1:8000/auth/callback`
- Copy the Client ID and Client Secret
- Note your instance domain (e.g., `saving-primate-16.clerk.accounts.dev`)
diff --git a/examples/auth/clerk_oauth/server.py b/examples/auth/clerk_oauth/server.py
index 74b1e4687..e7d080734 100644
--- a/examples/auth/clerk_oauth/server.py
+++ b/examples/auth/clerk_oauth/server.py
@@ -21,7 +21,7 @@ auth = ClerkProvider(
domain=os.getenv("FASTMCP_SERVER_AUTH_CLERK_DOMAIN") or "",
client_id=os.getenv("FASTMCP_SERVER_AUTH_CLERK_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_CLERK_CLIENT_SECRET") or "",
- base_url="http://localhost:8000",
+ base_url="http://127.0.0.1:8000",
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
# Optional: specify required scopes (defaults to ["openid", "email", "profile"])
# required_scopes=["openid", "email", "profile", "public_metadata"],
diff --git a/examples/auth/discord_oauth/README.md b/examples/auth/discord_oauth/README.md
index 74217f833..e757ad84b 100644
--- a/examples/auth/discord_oauth/README.md
+++ b/examples/auth/discord_oauth/README.md
@@ -8,7 +8,7 @@ Demonstrates FastMCP server protection with Discord OAuth.
- Go to https://discord.com/developers/applications
- Click "New Application" and give it a name
- Go to OAuth2 in the left sidebar
- - Add a Redirect URL: `http://localhost:8000/auth/callback`
+ - Add a Redirect URL: `http://127.0.0.1:8000/auth/callback`
- Copy the Client ID and Client Secret
2. Set environment variables:
diff --git a/examples/auth/discord_oauth/server.py b/examples/auth/discord_oauth/server.py
index 424c97bdb..1e109b76a 100644
--- a/examples/auth/discord_oauth/server.py
+++ b/examples/auth/discord_oauth/server.py
@@ -18,7 +18,7 @@ from fastmcp.server.auth.providers.discord import DiscordProvider
auth = DiscordProvider(
client_id=os.getenv("FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET") or "",
- base_url="http://localhost:8000",
+ base_url="http://127.0.0.1:8000",
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
)
diff --git a/examples/auth/github_oauth/README.md b/examples/auth/github_oauth/README.md
index 557ba7774..dcd5c2205 100644
--- a/examples/auth/github_oauth/README.md
+++ b/examples/auth/github_oauth/README.md
@@ -6,7 +6,7 @@ Demonstrates FastMCP server protection with GitHub OAuth.
1. Create a GitHub OAuth App:
- Go to GitHub Settings > Developer settings > OAuth Apps
- - Set Authorization callback URL to: `http://localhost:8000/auth/callback`
+ - Set Authorization callback URL to: `http://127.0.0.1:8000/auth/callback`
- Copy the Client ID and Client Secret
2. Set environment variables:
diff --git a/examples/auth/github_oauth/client.py b/examples/auth/github_oauth/client.py
index 7158583bc..8722a547c 100644
--- a/examples/auth/github_oauth/client.py
+++ b/examples/auth/github_oauth/client.py
@@ -10,7 +10,7 @@ import asyncio
from fastmcp.client import Client, OAuth
-SERVER_URL = "http://localhost:8000/mcp"
+SERVER_URL = "http://127.0.0.1:8000/mcp"
async def main():
diff --git a/examples/auth/github_oauth/server.py b/examples/auth/github_oauth/server.py
index 1f88c6977..e93d6f01a 100644
--- a/examples/auth/github_oauth/server.py
+++ b/examples/auth/github_oauth/server.py
@@ -18,7 +18,7 @@ from fastmcp.server.auth.providers.github import GitHubProvider
auth = GitHubProvider(
client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET") or "",
- base_url="http://localhost:8000",
+ base_url="http://127.0.0.1:8000",
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
)
diff --git a/examples/auth/google_oauth/README.md b/examples/auth/google_oauth/README.md
index 869718344..82bcd8696 100644
--- a/examples/auth/google_oauth/README.md
+++ b/examples/auth/google_oauth/README.md
@@ -9,7 +9,7 @@ Demonstrates FastMCP server protection with Google OAuth.
- Create or select a project
- Go to APIs & Services > Credentials
- Create OAuth 2.0 Client ID (Web application)
- - Add Authorized redirect URI: `http://localhost:8000/auth/callback`
+ - Add Authorized redirect URI: `http://127.0.0.1:8000/auth/callback`
- Copy the Client ID and Client Secret
2. Set environment variables:
diff --git a/examples/auth/google_oauth/server.py b/examples/auth/google_oauth/server.py
index 2a5b1c7df..2043ed6c3 100644
--- a/examples/auth/google_oauth/server.py
+++ b/examples/auth/google_oauth/server.py
@@ -18,7 +18,7 @@ from fastmcp.server.auth.providers.google import GoogleProvider
auth = GoogleProvider(
client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET") or "",
- base_url="http://localhost:8000",
+ base_url="http://127.0.0.1:8000",
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
# Optional: specify required scopes
# required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"],
diff --git a/examples/auth/keycloak_oauth/README.md b/examples/auth/keycloak_oauth/README.md
index 68bdfeda6..ba6b95bf4 100644
--- a/examples/auth/keycloak_oauth/README.md
+++ b/examples/auth/keycloak_oauth/README.md
@@ -6,7 +6,7 @@ Demonstrates FastMCP server protection with Keycloak OAuth.
## Setup
-1. Configure a Keycloak realm with Dynamic Client Registration enabled and a trusted host policy for your server URL (e.g. `http://localhost:8000/*`).
+1. Configure a Keycloak realm with Dynamic Client Registration enabled and a trusted host policy for your server URL (e.g. `http://127.0.0.1:8000/*`).
2. Set environment variables:
diff --git a/examples/auth/keycloak_oauth/client.py b/examples/auth/keycloak_oauth/client.py
index e180f1c56..4992abbab 100644
--- a/examples/auth/keycloak_oauth/client.py
+++ b/examples/auth/keycloak_oauth/client.py
@@ -8,7 +8,7 @@ import asyncio
from fastmcp import Client
-SERVER_URL = "http://localhost:8000/mcp"
+SERVER_URL = "http://127.0.0.1:8000/mcp"
async def main():
diff --git a/examples/auth/keycloak_oauth/server.py b/examples/auth/keycloak_oauth/server.py
index f236bdcd3..7b4653103 100644
--- a/examples/auth/keycloak_oauth/server.py
+++ b/examples/auth/keycloak_oauth/server.py
@@ -16,8 +16,8 @@ from fastmcp.server.dependencies import get_access_token
auth = KeycloakAuthProvider(
realm_url=os.getenv("KEYCLOAK_REALM_URL") or "http://localhost:8080/realms/fastmcp",
- base_url="http://localhost:8000",
- # audience="http://localhost:8000", # Recommended for production
+ base_url="http://127.0.0.1:8000",
+ # audience="http://127.0.0.1:8000", # Recommended for production
)
mcp = FastMCP("Keycloak Example Server", auth=auth)
diff --git a/examples/auth/mounted/README.md b/examples/auth/mounted/README.md
index 5810dab4c..2bf213094 100644
--- a/examples/auth/mounted/README.md
+++ b/examples/auth/mounted/README.md
@@ -4,12 +4,12 @@ This example demonstrates mounting multiple OAuth-protected MCP servers in a sin
## URL Structure
-- **GitHub MCP**: `http://localhost:8000/api/mcp/github/mcp`
-- **Google MCP**: `http://localhost:8000/api/mcp/google/mcp`
+- **GitHub MCP**: `http://127.0.0.1:8000/api/mcp/github/mcp`
+- **Google MCP**: `http://127.0.0.1:8000/api/mcp/google/mcp`
Discovery endpoints (RFC 8414 path-aware):
-- **GitHub**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github`
-- **Google**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google`
+- **GitHub**: `http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/github`
+- **Google**: `http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/google`
## Setup
@@ -23,8 +23,8 @@ export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="your-google-client-secret"
```
Configure redirect URIs in each provider's developer console (note the `/api/mcp/{provider}` prefix since the servers are mounted):
-- GitHub: `http://localhost:8000/api/mcp/github/auth/callback/github`
-- Google: `http://localhost:8000/api/mcp/google/auth/callback/google`
+- GitHub: `http://127.0.0.1:8000/api/mcp/github/auth/callback/github`
+- Google: `http://127.0.0.1:8000/api/mcp/google/auth/callback/google`
## Running
diff --git a/examples/auth/mounted/server.py b/examples/auth/mounted/server.py
index 24aefdd59..c5b3af593 100644
--- a/examples/auth/mounted/server.py
+++ b/examples/auth/mounted/server.py
@@ -5,10 +5,10 @@ application, each with its own provider. It showcases RFC 8414 path-aware discov
where each server has its own authorization server metadata endpoint.
URL structure:
-- GitHub MCP: http://localhost:8000/api/mcp/github/mcp
-- Google MCP: http://localhost:8000/api/mcp/google/mcp
-- GitHub discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github
-- Google discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google
+- GitHub MCP: http://127.0.0.1:8000/api/mcp/github/mcp
+- Google MCP: http://127.0.0.1:8000/api/mcp/google/mcp
+- GitHub discovery: http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/github
+- Google discovery: http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/google
Required environment variables:
- FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID: Your GitHub OAuth app client ID
@@ -31,7 +31,7 @@ from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.auth.providers.google import GoogleProvider
# Configuration
-ROOT_URL = "http://localhost:8000"
+ROOT_URL = "http://127.0.0.1:8000"
API_PREFIX = "/api/mcp"
# --- GitHub OAuth Server ---
diff --git a/examples/auth/propelauth_oauth/README.md b/examples/auth/propelauth_oauth/README.md
index 575ea7314..aa5b10ecf 100644
--- a/examples/auth/propelauth_oauth/README.md
+++ b/examples/auth/propelauth_oauth/README.md
@@ -36,7 +36,7 @@ Create a `.env` file:
PROPELAUTH_AUTH_URL=https://auth.yourdomain.com
PROPELAUTH_INTROSPECTION_CLIENT_ID=your-client-id
PROPELAUTH_INTROSPECTION_CLIENT_SECRET=your-client-secret
-BASE_URL=http://localhost:8000/
+BASE_URL=http://127.0.0.1:8000/
# Optional: additional scopes tokens must include (comma-separated)
# PROPELAUTH_REQUIRED_SCOPES=read:user_data
```
@@ -50,7 +50,7 @@ Start the server:
uv run python server.py
```
-The server will start on `http://localhost:8000/mcp` with PropelAuth OAuth authentication enabled.
+The server will start on `http://127.0.0.1:8000/mcp` with PropelAuth OAuth authentication enabled.
Test with client:
diff --git a/examples/auth/propelauth_oauth/server.py b/examples/auth/propelauth_oauth/server.py
index 8401882aa..ab1661d22 100644
--- a/examples/auth/propelauth_oauth/server.py
+++ b/examples/auth/propelauth_oauth/server.py
@@ -9,7 +9,7 @@ Required environment variables:
Optional:
- PROPELAUTH_REQUIRED_SCOPES: Comma-separated scopes tokens must include
-- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://localhost:8000/`)
+- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://127.0.0.1:8000/`)
To run:
python server.py
@@ -29,7 +29,7 @@ auth = PropelAuthProvider(
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
- base_url=os.getenv("BASE_URL", "http://localhost:8000/"),
+ base_url=os.getenv("BASE_URL", "http://127.0.0.1:8000/"),
)
mcp = FastMCP("PropelAuth OAuth Example Server", auth=auth)
diff --git a/examples/auth/scalekit_oauth/README.md b/examples/auth/scalekit_oauth/README.md
index c241d76f7..d16b81c37 100644
--- a/examples/auth/scalekit_oauth/README.md
+++ b/examples/auth/scalekit_oauth/README.md
@@ -24,7 +24,7 @@ Create a `.env` file:
# Required Scalekit credentials
SCALEKIT_ENVIRONMENT_URL=
SCALEKIT_RESOURCE_ID= # res_926EXAMPLE5878
-BASE_URL=http://localhost:8000/
+BASE_URL=http://127.0.0.1:8000/
# Optional: additional scopes tokens must include (comma-separated)
# SCALEKIT_REQUIRED_SCOPES=read,write
```
@@ -38,7 +38,7 @@ Start the server:
uv run python server.py
```
-The server will start on `http://localhost:8000/mcp` with Scalekit OAuth authentication enabled.
+The server will start on `http://127.0.0.1:8000/mcp` with Scalekit OAuth authentication enabled.
Test with client:
diff --git a/examples/auth/scalekit_oauth/server.py b/examples/auth/scalekit_oauth/server.py
index 68cef23b5..09d4f5959 100644
--- a/examples/auth/scalekit_oauth/server.py
+++ b/examples/auth/scalekit_oauth/server.py
@@ -8,7 +8,7 @@ Required environment variables:
Optional:
- SCALEKIT_REQUIRED_SCOPES: Comma-separated scopes tokens must include
-- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://localhost:8000/`)
+- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://127.0.0.1:8000/`)
To run:
python server.py
@@ -30,7 +30,7 @@ auth = ScalekitProvider(
environment_url=os.getenv("SCALEKIT_ENVIRONMENT_URL")
or "https://your-env.scalekit.com",
resource_id=os.getenv("SCALEKIT_RESOURCE_ID") or "",
- base_url=os.getenv("BASE_URL", "http://localhost:8000/"),
+ base_url=os.getenv("BASE_URL", "http://127.0.0.1:8000/"),
required_scopes=required_scopes,
)
diff --git a/examples/auth/workos_oauth/server.py b/examples/auth/workos_oauth/server.py
index 08c1db62b..4dba970a8 100644
--- a/examples/auth/workos_oauth/server.py
+++ b/examples/auth/workos_oauth/server.py
@@ -20,7 +20,7 @@ auth = WorkOSProvider(
client_id=os.getenv("WORKOS_CLIENT_ID") or "",
client_secret=os.getenv("WORKOS_CLIENT_SECRET") or "",
authkit_domain=os.getenv("WORKOS_AUTHKIT_DOMAIN") or "https://your-app.authkit.app",
- base_url="http://localhost:8000",
+ base_url="http://127.0.0.1:8000",
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
)
diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py
index c060c0fa6..852f440a4 100644
--- a/src/fastmcp/server/auth/auth.py
+++ b/src/fastmcp/server/auth/auth.py
@@ -477,6 +477,12 @@ class RemoteAuthProvider(AuthProvider):
Creates protected resource metadata routes (RFC 9728).
"""
+ # Lifecycle hook: let subclasses react to the mcp_path becoming known
+ # (e.g., bind token audience to the resource URL). Mirrors the call in
+ # OAuthAuthorizationServerProvider.get_routes so all providers see the
+ # path at the same point in their lifecycle.
+ self.set_mcp_path(mcp_path)
+
routes = []
# Get the resource URL based on the MCP path
diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py
index 17194329f..2417cd067 100644
--- a/src/fastmcp/server/auth/providers/jwt.py
+++ b/src/fastmcp/server/auth/providers/jwt.py
@@ -419,13 +419,16 @@ class JWTVerifier(TokenVerifier):
or "unknown"
)
- # Validate expiration
+ # Validate expiration. Kept at INFO (not WARNING like issuer/
+ # audience/scope mismatches below) — expiry is expected-path noise
+ # from normal token rotation, not a configuration error worth
+ # surfacing by default.
exp = claims.get("exp")
if exp is not None and exp < time.time():
- self.logger.debug(
- "Token validation failed: expired token for client %s", client_id
+ self.logger.info(
+ "Bearer token rejected for client %s: token expired",
+ client_id,
)
- self.logger.info("Bearer token rejected for client %s", client_id)
return None
# Validate issuer - note we use issuer instead of issuer_url here because
@@ -443,11 +446,13 @@ class JWTVerifier(TokenVerifier):
issuer_valid = iss == self.issuer
if not issuer_valid:
- self.logger.debug(
- "Token validation failed: issuer mismatch for client %s",
+ self.logger.warning(
+ "Bearer token rejected for client %s: issuer mismatch "
+ "(got %r, expected %r)",
client_id,
+ iss,
+ self.issuer,
)
- self.logger.info("Bearer token rejected for client %s", client_id)
return None
# Validate audience if configured
@@ -474,11 +479,13 @@ class JWTVerifier(TokenVerifier):
audience_valid = aud == self.audience
if not audience_valid:
- self.logger.debug(
- "Token validation failed: audience mismatch for client %s",
+ self.logger.warning(
+ "Bearer token rejected for client %s: audience mismatch "
+ "(got %r, expected %r)",
client_id,
+ aud,
+ self.audience,
)
- self.logger.info("Bearer token rejected for client %s", client_id)
return None
# Extract scopes
@@ -489,12 +496,13 @@ class JWTVerifier(TokenVerifier):
token_scopes = set(scopes)
required_scopes = set(self.required_scopes)
if not required_scopes.issubset(token_scopes):
- self.logger.debug(
- "Token missing required scopes. Has: %s, Required: %s",
- token_scopes,
- required_scopes,
+ self.logger.warning(
+ "Bearer token rejected for client %s: missing required "
+ "scopes (has %s, requires %s)",
+ client_id,
+ sorted(token_scopes),
+ sorted(required_scopes),
)
- self.logger.info("Bearer token rejected for client %s", client_id)
return None
return AccessToken(
diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py
index a29e331fb..bd6d582ad 100644
--- a/src/fastmcp/server/auth/providers/workos.py
+++ b/src/fastmcp/server/auth/providers/workos.py
@@ -277,17 +277,22 @@ class AuthKitProvider(RemoteAuthProvider):
For detailed setup instructions, see:
https://workos.com/docs/authkit/mcp/integrating/token-verification
+ Token audience is bound to this server automatically: when the MCP
+ mount path becomes known (typically at ``http_app()`` construction),
+ ``JWTVerifier.audience`` is set to the resource URL advertised in
+ ``.well-known/oauth-protected-resource``. Enable Resource Indicators
+ (RFC 8707) in your WorkOS Dashboard and list that same URL — AuthKit
+ will then mint tokens with the matching ``aud`` claim.
+
Example:
```python
from fastmcp.server.auth.providers.workos import AuthKitProvider
- # Create AuthKit metadata provider (JWT verifier created automatically)
workos_auth = AuthKitProvider(
authkit_domain="https://your-workos-domain.authkit.app",
base_url="https://your-fastmcp-server.com",
)
- # Use with FastMCP
mcp = FastMCP("My App", auth=workos_auth)
```
"""
@@ -297,7 +302,7 @@ class AuthKitProvider(RemoteAuthProvider):
*,
authkit_domain: AnyHttpUrl | str,
base_url: AnyHttpUrl | str,
- client_id: str | None = None,
+ resource_base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
scopes_supported: list[str] | None = None,
resource_name: str | None = None,
@@ -309,16 +314,20 @@ 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.
+ resource_base_url: Optional public base URL for the protected resource.
+ When provided, this URL is advertised in protected resource metadata
+ instead of ``base_url``. Useful when OAuth callbacks and the protected
+ MCP resource live under different public URLs.
required_scopes: Optional list of scopes to require for all requests
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
If None, uses required_scopes. Use this when the scopes clients should
request differ from the scopes enforced on tokens.
resource_name: Optional name for the protected resource metadata.
resource_documentation: Optional documentation URL for the protected resource.
- token_verifier: Optional token verifier. If None, creates JWT verifier for AuthKit
+ token_verifier: Optional token verifier. If provided, it is used as-is and
+ audience auto-wiring is skipped — the caller is responsible for setting
+ an appropriate ``audience``. If None (default), a ``JWTVerifier`` is
+ created with audience bound to this server's resource URL.
"""
self.authkit_domain = str(authkit_domain).rstrip("/")
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
@@ -328,19 +337,14 @@ class AuthKitProvider(RemoteAuthProvider):
parse_scopes(required_scopes) if required_scopes is not None else None
)
- # Create default JWT verifier if none provided
+ # When no custom verifier is provided, we own the JWTVerifier and can
+ # bind its audience to our resource URL once set_mcp_path() is called.
+ self._auto_bind_audience = token_verifier is None
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,
)
@@ -349,11 +353,34 @@ class AuthKitProvider(RemoteAuthProvider):
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl(self.authkit_domain)],
base_url=self.base_url,
+ resource_base_url=resource_base_url,
scopes_supported=scopes_supported,
resource_name=resource_name,
resource_documentation=resource_documentation,
)
+ def set_mcp_path(self, mcp_path: str | None) -> None:
+ """Bind the default verifier's audience to this server's resource URL.
+
+ AuthKit with Resource Indicators (RFC 8707) mints tokens whose ``aud``
+ claim equals the resource URL the client requested — which is the URL
+ we advertise in ``.well-known/oauth-protected-resource``. Binding the
+ audience here keeps validation in lock-step with what clients are sent.
+ """
+ super().set_mcp_path(mcp_path)
+ if (
+ self._auto_bind_audience
+ and self._resource_url is not None
+ and isinstance(self.token_verifier, JWTVerifier)
+ ):
+ resource_url = str(self._resource_url)
+ self.token_verifier.audience = resource_url
+ logger.info(
+ "AuthKit tokens will be validated against aud=%s. "
+ "Configure this URL as a Resource Indicator in the WorkOS Dashboard.",
+ resource_url,
+ )
+
def get_routes(
self,
mcp_path: str | None = None,
diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py
index cc6ac742a..2e87956c1 100644
--- a/tests/server/auth/providers/test_workos.py
+++ b/tests/server/auth/providers/test_workos.py
@@ -9,6 +9,7 @@ from pytest_httpx import HTTPXMock
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
+from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.auth.providers.workos import (
AuthKitProvider,
WorkOSProvider,
@@ -173,6 +174,97 @@ class TestAuthKitProvider:
# assert "add" in tools
+class TestAuthKitAudienceBinding:
+ """RFC 8707 resource-indicator audience binding.
+
+ AuthKit mints tokens with ``aud`` equal to the resource URL the client
+ requested — which must equal the URL FastMCP advertises in its protected
+ resource metadata. AuthKitProvider auto-wires that equality: once the
+ MCP mount path is known, ``JWTVerifier.audience`` is set to
+ ``_get_resource_url(mcp_path)``.
+ """
+
+ def test_audience_binds_to_resource_url_on_set_mcp_path(self):
+ provider = AuthKitProvider(
+ authkit_domain="https://test.authkit.app",
+ base_url="http://127.0.0.1:8000",
+ )
+
+ verifier = provider.token_verifier
+ assert isinstance(verifier, JWTVerifier)
+ # Audience unset before the path is known — provider has no way to
+ # compute the resource URL yet.
+ assert verifier.audience is None
+
+ provider.set_mcp_path("/mcp")
+
+ expected = str(provider._get_resource_url("/mcp"))
+ assert verifier.audience == expected
+ assert expected == "http://127.0.0.1:8000/mcp"
+
+ def test_set_mcp_path_none_binds_to_base_url(self):
+ """When no MCP path is provided, the resource URL is ``base_url``
+ itself (an MCP-at-root server) and the audience binds to that."""
+ provider = AuthKitProvider(
+ authkit_domain="https://test.authkit.app",
+ base_url="http://127.0.0.1:8000",
+ )
+
+ provider.set_mcp_path(None)
+
+ verifier = provider.token_verifier
+ assert isinstance(verifier, JWTVerifier)
+ # Matches _get_resource_url(None) which returns base_url unchanged.
+ assert verifier.audience == "http://127.0.0.1:8000/"
+
+ def test_audience_respects_resource_base_url(self):
+ """When ``resource_base_url`` differs from ``base_url``, the audience
+ follows the advertised resource URL, not the OAuth-surface URL."""
+ provider = AuthKitProvider(
+ authkit_domain="https://test.authkit.app",
+ base_url="https://oauth.example.com",
+ resource_base_url="https://api.example.com",
+ )
+ provider.set_mcp_path("/mcp")
+
+ verifier = provider.token_verifier
+ assert isinstance(verifier, JWTVerifier)
+ assert verifier.audience == "https://api.example.com/mcp"
+
+ def test_custom_token_verifier_audience_not_overwritten(self):
+ """If the caller supplies their own verifier, we treat its audience
+ as intentional and do not touch it."""
+ custom_audience = "https://some-other-resource.example.com"
+ custom = JWTVerifier(
+ jwks_uri="https://test.authkit.app/oauth2/jwks",
+ issuer="https://test.authkit.app",
+ audience=custom_audience,
+ )
+ provider = AuthKitProvider(
+ authkit_domain="https://test.authkit.app",
+ base_url="http://127.0.0.1:8000",
+ token_verifier=custom,
+ )
+ provider.set_mcp_path("/mcp")
+
+ assert provider.token_verifier is custom
+ assert custom.audience == custom_audience
+
+ def test_audience_binds_through_http_app(self):
+ """End-to-end: mounting a FastMCP server triggers the lifecycle hook
+ that populates ``JWTVerifier.audience``."""
+ auth = AuthKitProvider(
+ authkit_domain="https://test.authkit.app",
+ base_url="http://127.0.0.1:8000",
+ )
+ mcp = FastMCP("test", auth=auth)
+ mcp.http_app(path="/mcp")
+
+ verifier = auth.token_verifier
+ assert isinstance(verifier, JWTVerifier)
+ assert verifier.audience == "http://127.0.0.1:8000/mcp"
+
+
class TestWorkOSTokenVerifierScopes:
async def test_verify_token_rejects_missing_required_scopes(
self, httpx_mock: HTTPXMock