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.
Prerequisites
Section titled “Prerequisites”- 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
Protect an MCP server
Section titled “Protect an MCP server”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.
Install dependencies
Section titled “Install dependencies”pip install keycardai-fastmcp fastmcpnpm install @keycardai/mcp @modelcontextprotocol/sdk express zodnpm install -D typescript @types/express tsxgo mod init hello-mcp-servergo get github.com/keycardai/go-sdk/mcpgo get github.com/mark3labs/mcp-gobundle initbundle add keycardai-mcp mcp rackup webrickRegister your MCP server in Keycard
Section titled “Register your MCP server in Keycard”Your MCP server needs to be registered as a protected Resource in Keycard so that AI agents can authenticate against it.
-
In Keycard Console, navigate to Resources → Add Resource → Add Manually
-
Configure the resource:
Name My MCP Server (Local Dev)Identifier http://localhost:8000/mcpCredential Provider Zone ProviderName My MCP Server (Local Dev)Identifier http://localhost:8080/mcpCredential Provider Zone ProviderName My MCP Server (Local Dev)Identifier http://localhost:8080/mcpCredential Provider Zone ProviderName My MCP Server (Local Dev)Identifier http://localhost:8000/mcpCredential Provider Zone Provider -
Click Protect My MCP Server (Local Dev)
Set environment variables
Section titled “Set environment variables”The server reads its Keycard Zone and its own URL from the environment. Export them in the shell you’ll start the server from:
export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connectionexport MCP_SERVER_URL=http://localhost:8000export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connectionexport PORT=8080export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connectionexport MCP_SERVER_URL=http://localhost:8080export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → ConnectionWrite the server
Section titled “Write the server”import os
from fastmcp import FastMCPfrom 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.
import express from "express";import { z } from "zod";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";import { mcpAuthMetadataRouter } from "@keycardai/mcp/server/auth/router";import { requireBearerAuth } from "@keycardai/mcp/server/auth/middleware/bearerAuth";
const ZONE_URL = process.env.KEYCARD_ISSUER ?? "<keycard-issuer-url>"; // from Settings → Connectionconst PORT = Number(process.env.PORT ?? 8080);
const app = express();app.use(express.json());
// OAuth metadata endpoints (unauthenticated)app.use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: ZONE_URL }, resourceName: "Hello World MCP Server", }),);
// MCP transport (protected by bearer auth)app.post( "/mcp", requireBearerAuth({ issuers: ZONE_URL, // Bind accepted tokens to this server's Resource identifier. audiences: `http://localhost:${PORT}/mcp`, }), async (req, res) => { const server = new McpServer({ name: "Hello World Server", version: "1.0.0" });
server.tool( "hello_world", "Say hello to an authenticated user.", { name: z.string().describe("Name to greet") }, async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}! You are authenticated.` }], }), );
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res, req.body); },);
// Stateless server: no SSE stream and no session to endapp.get("/mcp", (_req, res) => { res.set("Allow", "POST").status(405).end(); });app.delete("/mcp", (_req, res) => { res.set("Allow", "POST").status(405).end(); });
app.listen(PORT, () => { console.log(`Hello World MCP Server running on http://localhost:${PORT}`);});mcpAuthMetadataRouter serves the .well-known OAuth endpoints that MCP clients use to discover how to authenticate. requireBearerAuth verifies the JWT and rejects tokens from any issuer other than your zone. The MCP transport is mounted at /mcp in stateless mode. Each request is handled independently with no session state, so GET and DELETE on /mcp answer 405, which tells MCP clients there is no stream to open or session to end.
package main
import ( "context" "fmt" "log" "net/http" "os"
"github.com/keycardai/go-sdk/mcp" "github.com/keycardai/go-sdk/oauth" mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server")
func main() { issuer := os.Getenv("KEYCARD_ISSUER") serverURL := os.Getenv("MCP_SERVER_URL") if serverURL == "" { serverURL = "http://localhost:8080" }
// Build the MCP server with your preferred library (mcp-go here) s := server.NewMCPServer("Hello World Server", "1.0.0") s.AddTool( mcpgo.NewTool("hello_world", mcpgo.WithDescription("Say hello to an authenticated user."), mcpgo.WithString("name", mcpgo.Required()), ), func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { name, _ := req.GetArguments()["name"].(string) return mcpgo.NewToolResultText( fmt.Sprintf("Hello, %s! You are authenticated.", name), ), nil }, )
// The verifier trusts only tokens issued by your Zone, bound to this Resource verifier, err := mcp.NewZoneTokenVerifier(issuer, oauth.WithAudiences(serverURL+"/mcp")) if err != nil { log.Fatal(err) }
mux := http.NewServeMux()
// OAuth metadata endpoints (unauthenticated) mux.Handle("/.well-known/", mcp.AuthMetadataHandler( mcp.WithIssuer(issuer), mcp.WithResourceName("Hello World MCP Server"), ))
// MCP transport (protected by bearer auth) mux.Handle("/mcp", mcp.RequireBearerAuth(verifier)( server.NewStreamableHTTPServer(s), ))
log.Printf("Hello World MCP Server running on %s", serverURL) log.Fatal(http.ListenAndServe(":8080", mux))}The Go SDK is composable http.Handler middleware, so it works with any MCP library that produces an HTTP handler (mcp-go here). AuthMetadataHandler serves the .well-known OAuth endpoints that MCP clients use to discover how to authenticate. NewZoneTokenVerifier pins verification to your Zone’s issuer and binds accepted tokens to this Resource’s identifier, and RequireBearerAuth wraps the MCP transport so every request to /mcp carries a verified token.
require "json"require "keycardai/mcp"require "mcp"
ISSUER = ENV.fetch("KEYCARD_ISSUER", "<keycard-issuer-url>")
mcp_server = MCP::Server.new(name: "Hello World Server", version: "1.0.0")mcp_server.define_tool( name: "hello_world", description: "Say hello to an authenticated user.", input_schema: { properties: { name: { type: "string" } }, required: ["name"] },) do |name:, server_context: nil| MCP::Tool::Response.new([{ type: "text", text: "Hello, #{name}! You are authenticated." }])end
# OAuth metadata endpoints (unauthenticated)metadata = Keycardai::MCP::MetadataApp.new( issuer: ISSUER, resource_name: "Hello World MCP Server",)
# MCP endpoint (protected by bearer auth). audiences binds accepted tokens to# this server's Resource identifier.verifier = Keycardai::OAuth::TokenVerifier.new(issuers: ISSUER, audiences: "http://localhost:8000/mcp")# The mcp gem's Streamable HTTP transport acknowledges notifications with 202# and answers GET and DELETE as a stateless server should.mcp_endpoint = MCP::Server::Transports::StreamableHTTPTransport.new( mcp_server, stateless: true, enable_json_response: true,)protected_mcp = Keycardai::MCP::RequireBearerAuth.new(mcp_endpoint, verifier: verifier)
run lambda { |env| case env["PATH_INFO"] when "/.well-known/oauth-protected-resource" then metadata.call(env.merge("PATH_INFO" => "/.well-known/oauth-protected-resource/mcp")) when %r{\A/\.well-known/} then metadata.call(env) when "/mcp" then protected_mcp.call(env) else [404, { "content-type" => "application/json" }, [JSON.dump({ "error" => "not_found" })]] end}The keycardai-mcp gem attaches at the Rack seam and wraps no MCP SDK, so any Rack-compatible server works (the official mcp gem here). MetadataApp serves the .well-known OAuth endpoints that MCP clients use to discover how to authenticate. RequireBearerAuth verifies the bearer token and rejects tokens from any issuer other than your Zone before the MCP endpoint runs.
Run and test
Section titled “Run and test”-
Start the server
Terminal window python server.pyTerminal window npx tsx server.tsTerminal window go mod tidygo run server.goTerminal window bundle exec rackup -p 8000 -
Configure your coding agent
Add the MCP server to your agent’s configuration. Use the URL your server is running on: port
8000for Python and Ruby,8080for TypeScript and Go. The examples below use8000.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 -
Authenticate and test
- Restart your coding agent to detect the new MCP server
- Connect to the MCP server. In Claude Code, run
/mcp, select the server and choose Authenticate (or runclaude mcp login my-mcp-server). - 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.
- Test the tool. Ask your agent:
"Run the hello_world tool with the name Troy"
-
Verify in Keycard Console
Check Audit Log for:
credentials:issueAccess token was issued, with an identity chain: your agent, then you users:authenticateOnly 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.
Protect an API
Section titled “Protect an API”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” |
Build a protected API
Section titled “Build a protected API”Your server needs discovery endpoints so Keycard can issue tokens for it, and bearer auth middleware to validate those tokens.
pip install keycardai-starlette fastapi uvicornkeycardai-starlette provides an AuthProvider that installs discovery endpoints and AuthenticationMiddleware in a single call. Works with FastAPI and any Starlette-compatible app.
import osfrom fastapi import FastAPI, Requestfrom 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.
npm install @keycardai/express expressimport express from "express";import { requireBearerAuth, keycardMetadataRouter, type AuthenticatedRequest,} from "@keycardai/express";
const ZONE_URL = process.env.KEYCARD_ISSUER!;const PORT = process.env.PORT ?? "8080";// The Resource identifier you registered in Keycard.const API_URL = "http://localhost:8080";const app = express();
// Discovery endpoints (unauthenticated).app.use( keycardMetadataRouter({ issuer: ZONE_URL, resourceName: "My API", scopesSupported: ["read", "write", "admin"], }),);
// Bearer auth middleware. Applied per-route, not globally, so discovery// endpoints remain accessible. `zoneUrl` pins the verifier to your zone// so forged tokens from any other issuer are rejected before any JWKS lookup;// `audience` binds accepted tokens to this API, so a token minted for another// resource is rejected too.const auth = requireBearerAuth({ zoneUrl: ZONE_URL, audience: API_URL, requiredScopes: ["read"],});
app.get("/api/data", auth, (req, res) => { // Identify the caller by keycardAppId, not clientId (the credential). // subProfile is "user" when the token's subject is a user, "app" otherwise. const { keycardAppId, sub, subProfile, scopes } = (req as AuthenticatedRequest).auth; res.json({ data: "hello from protected API", meta: { application: keycardAppId, subject: sub, subject_type: subProfile, scopes }, });});
app.listen(Number(PORT), () => console.log(`Protected API listening on :${PORT}`));go mod init my-apigo get github.com/keycardai/go-sdk/mcpThe mcp package is composable net/http middleware. Despite the name it has no MCP dependency and protects any http.Handler, plain APIs included.
package main
import ( "encoding/json" "log" "net/http" "os"
"github.com/keycardai/go-sdk/mcp" "github.com/keycardai/go-sdk/oauth")
func main() { zoneURL := os.Getenv("KEYCARD_ISSUER")
mux := http.NewServeMux()
// Discovery endpoints (unauthenticated). mux.Handle("/.well-known/", mcp.AuthMetadataHandler( mcp.WithIssuer(zoneURL), mcp.WithResourceName("My API"), mcp.WithScopesSupported([]string{"read", "write", "admin"}), ))
// The verifier is pinned to your zone so forged tokens from any other // issuer are rejected before any JWKS lookup, and bound to this API's // Resource identifier so a token minted for another resource is rejected too. verifier, err := mcp.NewZoneTokenVerifier(zoneURL, oauth.WithAudiences("http://localhost:8080")) if err != nil { log.Fatal(err) }
// Bearer auth middleware. Applied per route, not globally, so discovery // endpoints remain accessible. auth := mcp.RequireBearerAuth(verifier, mcp.WithRequiredScopes("read"))
mux.Handle("GET /api/data", auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authInfo := mcp.AuthInfoFromRequest(r) w.Header().Set("Content-Type", "application/json") // Identify the caller by KeycardAppID, not ClientID (the credential). // SubProfile is "user" when the token's subject is a user, "app" otherwise. json.NewEncoder(w).Encode(map[string]any{ "data": "hello from protected API", "meta": map[string]any{ "application": authInfo.KeycardAppID, "subject": authInfo.Subject, "subject_type": authInfo.SubProfile, "scopes": authInfo.Scopes, }, }) })))
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]bool{"ok": true}) })
log.Println("Protected API listening on :8080") log.Fatal(http.ListenAndServe(":8080", mux))}bundle initbundle add keycardai-mcp rackup webrickThe keycardai-mcp gem attaches at the Rack seam. It has no MCP dependency and protects any Rack app, plain APIs included.
require "json"require "keycardai/mcp"
zone_url = ENV.fetch("KEYCARD_ISSUER")
# The verifier is pinned to your zone so forged tokens from any other# issuer are rejected before any JWKS lookup, and bound to this API's# Resource identifier so a token minted for another resource is rejected too.verifier = Keycardai::OAuth::TokenVerifier.new(issuers: zone_url, audiences: "http://localhost:8080")
# Discovery endpoints (unauthenticated).metadata = Keycardai::MCP::MetadataApp.new( issuer: zone_url, resource_name: "My API", scopes_supported: ["read", "write", "admin"],)
api_data = lambda do |env| auth = Keycardai::MCP.auth_info(env) # Identify the caller by keycard_app_id, not client_id (the credential). # subject_profile is "user" when the token's subject is a user, "app" otherwise. body = { data: "hello from protected API", meta: { application: auth.keycard_app_id, subject: auth.subject, subject_type: auth.subject_profile, scopes: auth.scopes } } [200, { "content-type" => "application/json" }, [JSON.dump(body)]]end
# Bearer auth middleware. Wraps individual endpoints, not the whole app,# so discovery endpoints remain accessible.protected_data = Keycardai::MCP::RequireBearerAuth.new( api_data, verifier: verifier, required_scopes: ["read"],)
run lambda { |env| case env["PATH_INFO"] when "/health" then [200, { "content-type" => "application/json" }, ['{"ok":true}']] when %r{\A/\.well-known/} then metadata.call(env) when "/api/data" then protected_data.call(env) else [404, { "content-type" => "application/json" }, ['{"error":"not_found"}']] end}Try it
Section titled “Try it”Set KEYCARD_ISSUER to your zone URL from Keycard Console (Settings → Connection) and start the server:
Save the example as main.py, then:
export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connectionuvicorn main:app --port 8080Save the example as server.ts, then:
export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connectionnpx tsx server.tsexport KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connectiongo run .export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connectionbundle exec rackup -p 8080Check that discovery responds (Python’s resource ends in / and lists no scopes or name):
$ 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):
$ curl -D - http://localhost:8080/api/dataHTTP/1.1 401 UnauthorizedWww-Authenticate: Bearer resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"What your middleware validates
Section titled “What your middleware validates”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);});Register in Keycard Console
Section titled “Register in Keycard Console”In Keycard Console:
-
Create the application. Go to Applications → Add Application. Give it a name and identifier. For local testing, add both
http://localhost:8765/callbackandhttp://127.0.0.1:8765/callbackunder Redirect URLs: the Python and TypeScript examples below call back onlocalhost, the Go and Ruby examples on127.0.0.1, and Keycard matches the host exactly. Click Create application. -
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. -
Add scopes. In the resource’s Scopes section, click Add scope for each of
read,write, andadmin, then click Save Changes. -
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 theirsubfrom that claim. In your organization’s Zone, the identity Provider is Keycard ID, which you can’t edit. -
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.
Call it from an agent
Section titled “Call it from an agent”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, httpxfrom 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_tokenimport { authenticate } from "@keycardai/oauth";
async function getSubjectToken() { const token = await authenticate(process.env.KEYCARD_ISSUER!, { clientId: process.env.KEYCARD_CLIENT_ID!, clientSecret: process.env.KEYCARD_CLIENT_SECRET!, resource: "http://localhost:8080", port: 8765, scopes: ["read"], }); return token.accessToken;}package main
import ( "context" "os"
"github.com/keycardai/go-sdk/oauth")
func getSubjectToken(ctx context.Context) (string, error) { token, err := oauth.Authenticate(ctx, os.Getenv("KEYCARD_ISSUER"), oauth.AuthenticateRequest{ ClientID: os.Getenv("KEYCARD_CLIENT_ID"), ClientSecret: os.Getenv("KEYCARD_CLIENT_SECRET"), Resource: "http://localhost:8080", CallbackPort: 8765, Scopes: []string{"read"}, }) if err != nil { return "", err } return token.AccessToken, nil}require "keycardai/oauth"
def get_subject_token token = Keycardai::OAuth.authenticate( issuer: ENV.fetch("KEYCARD_ISSUER"), client_id: ENV.fetch("KEYCARD_CLIENT_ID"), client_secret: ENV.fetch("KEYCARD_CLIENT_SECRET"), resources: ["http://localhost:8080"], port: 8765, scope: "read", ) token.access_tokenendThis 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:
export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connectionexport 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 httpxfrom keycardai.oauth import AsyncClient, BasicAuthfrom 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())Save the helper and this code together as agent.ts in your API project, add the oauth package with npm install @keycardai/oauth, then run npx tsx agent.ts.
import { TokenExchangeClient } from "@keycardai/oauth/tokenExchange";
async function main() { const exchange = new TokenExchangeClient(process.env.KEYCARD_ISSUER!, { clientId: process.env.KEYCARD_CLIENT_ID!, clientSecret: process.env.KEYCARD_CLIENT_SECRET!, });
const userAccessToken = await getSubjectToken(); // the PKCE helper above
const result = await exchange.exchangeToken({ subjectToken: userAccessToken, resource: "http://localhost:8080", scope: "read", });
const data = await fetch("http://localhost:8080/api/data", { headers: { Authorization: `Bearer ${result.accessToken}` }, }); console.log(await data.json());}
main();The agent is its own main package, so save the helper as pkce.go and this file as main.go in a new directory, run go mod init my-agent and go get github.com/keycardai/go-sdk/oauth there, then go run ..
package main
import ( "context" "fmt" "io" "log" "net/http" "os"
"github.com/keycardai/go-sdk/oauth")
func main() { ctx := context.Background()
userAccessToken, err := getSubjectToken(ctx) // the PKCE helper above if err != nil { log.Fatal(err) }
exchange := oauth.NewTokenExchangeClient( os.Getenv("KEYCARD_ISSUER"), oauth.WithClientCredentials(os.Getenv("KEYCARD_CLIENT_ID"), os.Getenv("KEYCARD_CLIENT_SECRET")), )
result, err := exchange.ExchangeToken(ctx, oauth.TokenExchangeRequest{ SubjectToken: userAccessToken, Resource: "http://localhost:8080", Scope: "read", }) if err != nil { log.Fatal(err) }
req, _ := http.NewRequestWithContext(ctx, "GET", "http://localhost:8080/api/data", nil) req.Header.Set("Authorization", "Bearer "+result.AccessToken) data, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer data.Body.Close() body, _ := io.ReadAll(data.Body) fmt.Println(string(body))}Save the helper and this code together as agent.rb in your API project, then run bundle exec ruby agent.rb.
require "keycardai/oauth"require "net/http"
exchange = Keycardai::OAuth::TokenExchangeClient.new( issuer: ENV.fetch("KEYCARD_ISSUER"), client_id: ENV.fetch("KEYCARD_CLIENT_ID"), client_secret: ENV.fetch("KEYCARD_CLIENT_SECRET"),)
user_access_token = get_subject_token # the PKCE helper above
result = exchange.exchange_token( subject_token: user_access_token, resource: "http://localhost:8080", scope: "read",)
data = Net::HTTP.get( URI("http://localhost:8080/api/data"), { "Authorization" => "Bearer #{result.access_token}" },)puts dataThe 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).
Deploy to production
Section titled “Deploy to production”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, andMCP_SERVER_URLset to the production origin, such ashttps://<your-domain>(no/mcp; the server adds it). The Python and Go MCP samples derive the audience they accept fromMCP_SERVER_URL; left unset, a deployed server falls back tolocalhostand 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 on127.0.0.1:8000and ignoresPORT, 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 alocalhostaudience in code. Change them to the production Resource identifier. - Trust your TLS proxy in the TypeScript MCP server.
requireBearerAuthrejects a token unless its audience has the request’s scheme and host, and behind a proxy that terminates HTTPS, Express seeshttp. Addapp.set("trust proxy", 1)afterconst app = express();. - Allow your production host in the Ruby MCP server. The
mcpgem’s transport answers anyHostother thanlocalhostwith 403, so passallowed_hosts: ["<your-domain>"]toStreamableHTTPTransport.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.
What’s next
Section titled “What’s next”- Call another service from your tools. Call other services helps you pick between acting as the signed-in user and acting as your Application. Call External APIs from MCP is the SDK walkthrough for the user case.
- Run at the edge. Deploy an MCP server on Cloudflare Workers.
- Prefer a coding agent over the SDK? Run coding agents with Keycard sets up Claude Code or Cursor through the Keycard CLI.
- Upgrade an older FastMCP server. Migrate to FastMCP 3.0.
- Reference. MCP SDK · OAuth SDK · Token Claims · Access Policies