Skip to content
Build tools

Protect MCP servers and APIs

Put Keycard in front of an MCP server or an API so every request carries a verified user, explicit scopes, and an audit record.

You built something agents will call: an MCP server, an API, or both. Without authentication any agent can call it, and nothing records who did what. This guide puts Keycard in front of it so every request carries a verified user, explicit scopes, and an audit record.

Pick your path:

  • Protect an MCP server. An MCP client such as Cursor or Claude Code discovers how to authenticate, the user signs in, and every tool call arrives with a verified token. Hello world in Python, TypeScript, Go, and Ruby.
  • Protect an API. Any HTTP backend accepts only tokens Keycard issued for it, learns which agent is calling with which scopes, and a trusted service exchanges a user’s token for one your API accepts.

Both paths register your service as a Resource in Keycard and validate bearer tokens the same way. The difference is who the client is.

Your ServerMCP server or API
OAuth DiscoveryClients find how to auth
Bearer AuthEvery request verified
Audit TrailEvery access logged
  • Python 3.10+ or Node.js 18+
  • Keycard account with access to Console
  • For the MCP path: Cursor IDE or another MCP-compatible client for testing

MCP servers expose tools to AI agents. Keycard adds OAuth-based authentication so that every tool call is tied to a verified user, scoped to explicit permissions, and logged for audit. You build a hello world server, register it as a Resource, and call it from your coding agent.

Terminal window
pip install keycardai-fastmcp fastmcp

Your MCP server needs to be registered as a protected Resource in Keycard so that AI agents can authenticate against it.

  1. In Keycard Console, navigate to Resources → Add Resource → Add Manually

  2. Configure the resource:

    Name My MCP Server (Local Dev)
    Identifier http://localhost:8000/mcp
    Credential Provider Zone Provider
  3. Click Protect My MCP Server (Local Dev)

The server reads its Keycard Zone and its own URL from the environment. Export them in the shell you’ll start the server from:

Terminal window
export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connection
export MCP_SERVER_URL=http://localhost:8000
import os
from fastmcp import FastMCP
from keycardai.fastmcp import AuthProvider
auth_provider = AuthProvider(
zone_url=os.getenv("KEYCARD_ISSUER", "<keycard-issuer-url>"),
mcp_server_name="Hello World Server",
mcp_server_url=os.getenv("MCP_SERVER_URL", "http://localhost:8000"),
)
auth = auth_provider.get_remote_auth_provider()
mcp = FastMCP("Hello World Server", auth=auth)
@mcp.tool()
def hello_world(name: str) -> str:
"""Say hello to an authenticated user."""
return f"Hello, {name}! You are authenticated."
def main():
mcp.run(transport="streamable-http")
if __name__ == "__main__":
main()

AuthProvider handles all OAuth metadata and token verification. It connects your server to your Keycard zone so that MCP clients can discover how to authenticate. get_remote_auth_provider() returns the auth configuration that FastMCP needs, and mcp.run() starts the server.

  1. Start the server

    Terminal window
    python server.py
  2. Configure your coding agent

    Add the MCP server to your agent’s configuration. Use the URL your server is running on: port 8000 for Python and Ruby, 8080 for TypeScript and Go. The examples below use 8000.

    Cursor: add to .cursor/mcp.json:

    {
    "mcpServers": {
    "my-mcp-server": {
    "url": "http://localhost:8000/mcp"
    }
    }
    }

    Claude Code: run in your terminal:

    Terminal window
    claude mcp add --transport http my-mcp-server http://localhost:8000/mcp
  3. Authenticate and test

    1. Restart your coding agent to detect the new MCP server
    2. Connect to the MCP server. In Claude Code, run /mcp, select the server and choose Authenticate (or run claude mcp login my-mcp-server).
    3. Complete the OAuth flow. A browser window opens on Keycard; if it asks you to sign in, use the account you use for Keycard Console.
    4. Test the tool. Ask your agent: "Run the hello_world tool with the name Troy"
  4. Verify in Keycard Console

    Check Audit Log for:

    credentials:issue Access token was issued, with an identity chain: your agent, then you
    users:authenticate Only if you signed in during this flow

Your MCP server is now protected. Every tool call carries a token issued to a verified user under your policies, and each issue is in the Audit Log. If your tools need to call GitHub, Google, or another API on behalf of the user who called them, continue with Call External APIs from MCP, or start from Call other services to pick between acting as the user and acting as your Application.

When you give an agent your GitHub token, every agent on your machine shares that token. You can’t tell which agent did what, can’t revoke one without breaking the others, can’t constrain scope. One misconfigured prompt and the agent pushes to main with your credentials.

Keycard issues a separate credential per agent action: short-lived, scoped to one API, tied to a real user’s authorization. Your API validates that credential the same way the MCP server above does, and gets the agent’s identity and scopes as claims.

Shared API keys Keycard
Identity The user User + agent + session
Scope Whatever the OAuth app grants Constrained by access policy
Lifetime Days or weeks, on disk 24 hours by default, in-memory
Revocation Revoke the whole token Per-agent or per-session
Audit “A token was used” “This agent, on behalf of this user, accessed this resource with these scopes”

Your server needs discovery endpoints so Keycard can issue tokens for it, and bearer auth middleware to validate those tokens.

Terminal window
pip install keycardai-starlette fastapi uvicorn

keycardai-starlette provides an AuthProvider that installs discovery endpoints and AuthenticationMiddleware in a single call. Works with FastAPI and any Starlette-compatible app.

import os
from fastapi import FastAPI, Request
from keycardai.starlette import AuthProvider, requires
ZONE_URL = os.environ["KEYCARD_ISSUER"]
# The Resource identifier you registered in Keycard. Binding it here means a
# token minted for any other resource is rejected.
API_URL = "http://localhost:8080"
auth = AuthProvider(zone_url=ZONE_URL, audience=API_URL)
app = FastAPI()
auth.install(app) # mounts /.well-known/* and auth middleware
@app.get("/health")
async def health():
return {"ok": True}
@app.get("/api/data")
@requires("authenticated")
async def get_data(request: Request):
user = request.user
# Identify the caller by keycard_app_id, not client_id (the credential).
# sub_profile is "user" when the token's subject is a user, "app" otherwise.
return {
"data": "hello from protected API",
"meta": {
"application": user.keycard_app_id,
"subject": user.sub,
"subject_type": user.sub_profile,
"scopes": user.scopes,
},
}

auth.install(app) mounts the RFC 9728 / RFC 8414 /.well-known/* discovery endpoints and registers AuthenticationMiddleware so all routes have access to request.user. @requires("authenticated") rejects unauthenticated requests with an RFC 6750 WWW-Authenticate challenge.

Set KEYCARD_ISSUER to your zone URL from Keycard Console (Settings → Connection) and start the server:

Save the example as main.py, then:

Terminal window
export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connection
uvicorn main:app --port 8080

Check that discovery responds (Python’s resource ends in / and lists no scopes or name):

Terminal window
$ curl -s http://localhost:8080/.well-known/oauth-protected-resource | jq
{
"resource": "http://localhost:8080",
"scopes_supported": ["read", "write", "admin"],
"resource_name": "My API"
}

Check that unauthenticated requests are rejected (Python and Go point resource_metadata at …/oauth-protected-resource/api/data instead):

Terminal window
$ curl -D - http://localhost:8080/api/data
HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"

When a valid token arrives, your middleware unpacks these claims:

{
"iss": "<keycard-issuer-url>",
"sub": "<user-identifier>",
"sub_profile": "user",
"keycard_app_id": "agent-framework",
"client_id": "<client-id>",
"aud": "http://localhost:8080",
"scope": "read",
"act": { "sub": "agent-framework", "sub_profile": "app" },
"sid": "nr3hb6dx0a228kscyasis6uuwp",
"exp": 1774223702,
"iat": 1774137302,
"jti": "019d12d2-ddec-7b0c-b293-52af0ca5a2f0"
}

Use keycard_app_id to identify the calling Application. It holds the Application identifier. Use client_id when you need the specific credential that requested the token. Use sub_profile to see the subject type: user for a user, app for an Application.

Your API can use the delegation context for decisions that a standard OAuth resource server can’t make. You can enforce these in your code, in Keycard’s access policy, or both.

app.get("/api/data", auth, (req, res) => {
const { keycardAppId, scopes } = (req as AuthenticatedRequest).auth;
// Rate limit per agent, not per user.
if (rateLimiter.exceeded(keycardAppId)) {
return res.status(429).json({ error: "rate limit exceeded for this agent" });
}
// Scope-aware responses.
const data = scopes.includes("admin") ? getFullData() : getReadOnlyData();
// Agent-attributed logs.
logger.info({ agent: keycardAppId, scopes, path: req.path });
res.json(data);
});

In Keycard Console:

  1. Create the application. Go to Applications → Add Application. Give it a name and identifier. For local testing, add both http://localhost:8765/callback and http://127.0.0.1:8765/callback under Redirect URLs: the Python and TypeScript examples below call back on localhost, the Go and Ruby examples on 127.0.0.1, and Keycard matches the host exactly. Click Create application.

  2. Create the resource. Go to Resources → Add Resource → Add Manually. Name it My API, set the Identifier to your server’s URL (http://localhost:8080), select your Zone Provider as the Credential Provider, and set Provided by Application to the Application from step 1. No redirect URL needed; redirect URLs are for upstream OAuth providers like GitHub. Click Protect My API.

  3. Add scopes. In the resource’s Scopes section, click Add scope for each of read, write, and admin, then click Save Changes.

  4. Optional: set the user identifier. If you can edit your Zone’s identity Provider, set its user identifier claim to the claim you want in sub, such as the provider’s user id. New users then get their sub from that claim. In your organization’s Zone, the identity Provider is Keycard ID, which you can’t edit.

  5. Generate credentials. On the application’s Application Credentials tab, click Add credential and choose Client ID & Secret. Agents use these when exchanging tokens via the SDK.

Use the SDK to exchange tokens from a trusted backend using client credentials.

The exchange takes a subjectToken: the user’s Keycard access token. In production this comes from your app’s OAuth flow. For local testing, the SDKs have a built-in PKCE helper that opens the browser and returns the token:

import asyncio, os, httpx
from keycardai.oauth.pkce import authenticate
async def get_subject_token():
async with httpx.AsyncClient() as http:
r = await http.get("http://localhost:8080/api/data")
www_auth = r.headers["www-authenticate"]
token = await authenticate(
client_id=os.environ["KEYCARD_CLIENT_ID"],
client_secret=os.environ["KEYCARD_CLIENT_SECRET"],
resource_url="http://localhost:8080",
www_authenticate_header=www_auth,
scopes=["read"],
)
return token.access_token

This opens a browser window on Keycard; if it asks you to sign in, use the account you use for Keycard Console.

The exchange below takes the subject token from that helper. Keep your API running and, in another shell, export your Issuer URL and the Client ID and Secret from Generate credentials above:

Terminal window
export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connection
export KEYCARD_CLIENT_ID=<client-id>
export KEYCARD_CLIENT_SECRET=<client-secret>

Save the helper and this code together as agent.py in your API project, then run python agent.py.

import os
import httpx
from keycardai.oauth import AsyncClient, BasicAuth
from keycardai.oauth.types.models import TokenExchangeRequest
async def main():
zone_url = os.environ["KEYCARD_ISSUER"]
client_id = os.environ["KEYCARD_CLIENT_ID"]
client_secret = os.environ["KEYCARD_CLIENT_SECRET"]
user_access_token = await get_subject_token() # the PKCE helper above
async with AsyncClient(zone_url, auth=BasicAuth(client_id, client_secret)) as client:
response = await client.exchange_token(TokenExchangeRequest(
subject_token=user_access_token,
resource="http://localhost:8080",
scope="read",
))
async with httpx.AsyncClient() as http:
data = await http.get(
"http://localhost:8080/api/data",
headers={"Authorization": f"Bearer {response.access_token}"},
)
print(data.json())
asyncio.run(main())

The subjectToken is the user’s Keycard access token, obtained via OAuth 2.0 authorization code + PKCE against your Keycard authorize endpoint (your Issuer URL + /oauth/2/authorize).

When you deploy either server:

  • Update the Resource identifier in Keycard Console to your production URL (for example https://<your-domain>/mcp). It must use HTTPS.
  • Set environment variables securely in your hosting platform: KEYCARD_ISSUER, KEYCARD_CLIENT_ID, KEYCARD_CLIENT_SECRET, PORT, and MCP_SERVER_URL set to the production origin, such as https://<your-domain> (no /mcp; the server adds it). The Python and Go MCP samples derive the audience they accept from MCP_SERVER_URL; left unset, a deployed server falls back to localhost and rejects every production token. Never commit secrets to source control.
  • Listen on the host’s address and port. The Python MCP sample’s mcp.run() listens on 127.0.0.1:8000 and ignores PORT, so a deployed server is unreachable. Pass them in: mcp.run(transport="streamable-http", host="0.0.0.0", port=int(os.environ.get("PORT", 8000))).
  • Update hard-coded audiences. The TypeScript and Ruby MCP samples (audiences) and every API sample pin a localhost audience in code. Change them to the production Resource identifier.
  • Trust your TLS proxy in the TypeScript MCP server. requireBearerAuth rejects a token unless its audience has the request’s scheme and host, and behind a proxy that terminates HTTPS, Express sees http. Add app.set("trust proxy", 1) after const app = express();.
  • Allow your production host in the Ruby MCP server. The mcp gem’s transport answers any Host other than localhost with 403, so pass allowed_hosts: ["<your-domain>"] to StreamableHTTPTransport.new.
  • Drop the client secret where you can. Hosts with workload identity (Render, Fly.io) can prove the service’s identity to Keycard with a runtime OIDC token instead. See Run on Render without secrets and Run on Fly.io without secrets.