Protect Any API
Build an API that knows which agent is calling and why, then give agents scoped credentials without touching their code.
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.
| 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 | Minutes, 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” |
Step 1: Build a Protected API
Section titled “Step 1: Build a Protected API”Your server needs discovery endpoints so Keycard can issue tokens for it, and bearer auth middleware to validate those tokens.
go get github.com/keycardai/credentials-go/mcpThe package name says mcp but the middleware is standard net/http. No MCP transport dependency. Requires Go 1.22+.
package main
import ( "encoding/json" "log" "net/http" "os"
"github.com/keycardai/credentials-go/mcp")
func main() { zoneURL := os.Getenv("KEYCARD_ZONE_URL") port := os.Getenv("PORT") if port == "" { port = "8080" }
mx := http.NewServeMux()
// Tell Keycard how to issue tokens for this API. mx.Handle("/.well-known/", mcp.AuthMetadataHandler( mcp.WithIssuer(zoneURL), mcp.WithScopesSupported([]string{"read", "write", "admin"}), mcp.WithResourceName("My API"), ))
// Validate tokens. Reject anything without "read" scope. handler := mcp.RequireBearerAuth( mcp.WithRequiredScopes("read"), )(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := mcp.AuthInfoFromRequest(r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "data": "hello from protected API", "meta": map[string]any{ "agent": auth.ClientID, "scopes": auth.Scopes, }, }) }))
mx.Handle("GET /api/data", handler) log.Printf("Protected API listening on :%s", port) log.Fatal(http.ListenAndServe(":"+port, mx))}npm install @keycardai/mcp expressThe package name says mcp but this is standard Express middleware. No MCP transport dependency.
import express from "express";import { requireBearerAuth, type AuthenticatedRequest,} from "@keycardai/mcp/server/auth/middleware/bearerAuth";import { mcpAuthMetadataRouter } from "@keycardai/mcp/server/auth/router";
const ZONE_URL = process.env.KEYCARD_ZONE_URL!;const PORT = process.env.PORT ?? "8080";const app = express();
// Discovery endpoints (unauthenticated).app.use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: ZONE_URL }, resourceName: "My API", scopesSupported: ["read", "write", "admin"], }),);
// Bearer auth middleware. Applied per-route, not globally, so discovery// endpoints remain accessible. `issuers` is required — it pins the// verifier to your zone so forged tokens from any other issuer are// rejected before any JWKS lookup.const auth = requireBearerAuth({ issuers: ZONE_URL, requiredScopes: ["read"],});
app.get("/api/data", auth, (req, res) => { const { clientId, scopes } = (req as AuthenticatedRequest).auth; res.json({ data: "hello from protected API", meta: { agent: clientId, scopes } });});
app.listen(Number(PORT), () => console.log(`Protected API listening on :${PORT}`));pip install keycardai-mcp fastapi uvicorn httpxThe package name says mcp but this is standard FastAPI middleware. No MCP transport dependency.
import osfrom fastapi import FastAPI, Request, Dependsfrom keycardai.mcp.server.auth import AuthProviderfrom keycardai.mcp.server.middleware.bearer import BearerAuthMiddlewarefrom keycardai.mcp.server.handlers.metadata import ( InferredProtectedResourceMetadata, protected_resource_metadata, authorization_server_metadata,)
app = FastAPI()zone_url = os.getenv("KEYCARD_ZONE_URL")auth_provider = AuthProvider(zone_url=zone_url)verifier = auth_provider.get_token_verifier()
# Discovery endpoints (unauthenticated).@app.get("/.well-known/oauth-protected-resource")async def well_known_resource(request: Request): return protected_resource_metadata( InferredProtectedResourceMetadata(authorization_servers=[zone_url]) )(request)
@app.get("/.well-known/oauth-authorization-server")async def well_known_authz(request: Request): return authorization_server_metadata(zone_url)(request)
# Bearer auth applied per-route via dependency, not as global middleware.# This keeps discovery endpoints accessible without a token.async def require_auth(request: Request): await BearerAuthMiddleware.verify_request(request, verifier, required_scopes=["read"]) return request.state.auth
@app.get("/api/data")async def get_data(auth=Depends(require_auth)): return {"data": "hello from protected API", "meta": {"agent": auth.client_id, "scopes": auth.scopes}}Try it
Section titled “Try it”Set KEYCARD_ZONE_URL to your zone URL from Keycard Console (zone settings) and start the server:
export KEYCARD_ZONE_URL=https://<your-zone-id>.keycard.cloudgo run ./main.goCheck that discovery responds:
$ curl -s http://localhost:9090/.well-known/oauth-protected-resource | jq{ "resource": "http://localhost:9090", "scopes_supported": ["read", "write", "admin"], "resource_name": "My API"}Check that unauthenticated requests are rejected:
$ curl -D - http://localhost:9090/api/dataHTTP/1.1 401 UnauthorizedWww-Authenticate: Bearer resource_metadata="http://localhost:9090/.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": "https://my-zone-id.keycard.cloud", "sub": "emnagera4jhr4fsqbsje5p7bq6", "aud": ["http://localhost:9090"], "scope": "read", "client_id": "app_agent-framework-456", "sid": "nr3hb6dx0a228kscyasis6uuwp", "exp": 1774137902, "iat": 1774137302, "jti": "019d12d2-ddec-7b0c-b293-52af0ca5a2f0"}| Claim | Meaning |
|---|---|
iss | The Keycard zone that issued this token. Pin this in production to reject tokens from untrusted zones. |
sub | The user who authorized access |
client_id | The application acting on the user’s behalf. Different agents get different values, even for the same user. |
aud | Which API this token targets. A token for one API is rejected by another. |
scope | What the agent is permitted to do, as granted by access policy. Space-delimited string; SDKs parse it into an array. |
sid | Session identifier. All exchanges in a keycard run session share this value. |
exp | Expiry. Configured per zone. |
iat | Issued-at timestamp |
jti | Unique token identifier for audit correlation |
What you can do with these claims
Section titled “What you can do with these claims”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 { clientId, scopes } = (req as AuthenticatedRequest).auth;
// Rate limit per agent, not per user. if (rateLimiter.exceeded(clientId)) { 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: clientId, scopes, path: req.path });
res.json(data);});Register in Keycard Console
Section titled “Register in Keycard Console”In Keycard Console:
- Go to Resources, Create Resource. Set the identifier to your server’s URL (
http://localhost:9090). Add the scopes your API supports:read,write,admin. Select Zone Provider as the credential provider. No redirect URL needed; redirect URLs are for upstream OAuth providers like GitHub. - Go to Applications, Create Application. Set your API as the provided resource. Generate a Client ID and Client Secret. Agents use these when exchanging tokens via the SDK path.
Step 2: Call It From an Agent
Section titled “Step 2: Call It From an Agent”The ambient path: keycard run
Section titled “The ambient path: keycard run”Add the resource to your keycard.toml with the scopes you need. At startup, keycard run exchanges a scoped token for each entry and injects the result as an environment variable.
[[credentials.default]]env_var = "MY_API_TOKEN"resource = "http://localhost:9090"Each entry is a request, not a grant. When keycard run exchanges each credential, the STS checks whether the user has authorized access to that resource and whether access policy permits it. If the user hasn’t consented, keycard run opens the browser. If policy denies access, the exchange fails.
Start the agent:
keycard run -- claudeThe agent finds $MY_API_TOKEN in its environment, scoped to read:
$ curl -s -H "Authorization: Bearer $MY_API_TOKEN" \ http://localhost:9090/api/data | jq{ "data": "hello from protected API", "meta": { "agent": "cli/metadata.json", "scopes": ["read"] }}The agent’s code has no Keycard imports, no exchange calls, no OAuth handling. If the user hasn’t authorized this resource yet, Keycard returns interaction_required and keycard run opens the browser for OAuth consent before launching. The server never participates in consent.
For credentials not in the template, keycard credential read does an on-demand exchange:
TOKEN=$(keycard credential read http://localhost:9090 --scope "read")Note that the ambient path exchanges tokens once at session start. For per-action exchange (a fresh token per tool call), use the SDK path.
The SDK path: agent frameworks and production
Section titled “The SDK path: agent frameworks and production”For agent frameworks and backend services, use @keycardai/oauth directly. These examples run token exchange from a trusted backend using client credentials, not from a browser.
import { TokenExchangeClient } from "@keycardai/oauth/tokenExchange";
const exchange = new TokenExchangeClient(process.env.KEYCARD_ZONE_URL!, { clientId: process.env.KEYCARD_CLIENT_ID!, clientSecret: process.env.KEYCARD_CLIENT_SECRET!,});
const result = await exchange.exchangeToken({ subjectToken: userAccessToken, resource: "http://localhost:9090", scope: "read",});
const data = await fetch("http://localhost:9090/api/data", { headers: { Authorization: `Bearer ${result.accessToken}` },});import httpxfrom keycardai.oauth import AsyncClient, BasicAuthfrom keycardai.oauth.types.models import TokenExchangeRequest
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:9090", scope="read", ))
async with httpx.AsyncClient() as http: data = await http.get( "http://localhost:9090/api/data", headers={"Authorization": f"Bearer {response.access_token}"}, )client := oauth.NewTokenExchangeClient(zoneURL, oauth.WithClientCredentials(clientID, clientSecret),)
result, err := client.ExchangeToken(ctx, &oauth.TokenExchangeRequest{ SubjectToken: userAccessToken, Resource: "http://localhost:9090", Scope: "read",})The subjectToken is the user’s Keycard access token. In a web app, you get it via OAuth 2.0 authorization code + PKCE against your zone’s authorize endpoint (https://<zone-id>.keycard.cloud/oauth/2/authorize). With keycard run, the CLI handles this automatically. In production on EKS, use EKSWorkloadIdentity. See Secure Agentic Coding for the full auth lifecycle.
What’s Next
Section titled “What’s Next”- Setup: Quickstart · Resource Catalog · Access Policies
- Guides: Add Delegated Access · Secure Agentic Coding
- SDK Reference: OAuth SDK · MCP SDK · CLI