Protect an 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.
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_ZONE_URL"]
auth = AuthProvider(zone_url=ZONE_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 return { "data": "hello from protected API", "meta": {"agent": user.client_id, "scopes": list(request.auth.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_ZONE_URL!;const PORT = process.env.PORT ?? "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.const auth = requireBearerAuth({ zoneUrl: 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}`));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.clouduvicorn main:app --port 8080export KEYCARD_ZONE_URL=https://<your-zone-id>.keycard.cloudnpx tsx src/server.tsCheck that discovery responds:
$ 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:
$ 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": "https://<your-zone-id>.keycard.cloud", "sub": "emnagera4jhr4fsqbsje5p7bq6", "aud": ["http://localhost:8080"], "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 token exchanges in a 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:8080). 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. For local testing, also add
http://localhost:8765/callbackas a redirect URI. - In Zone Settings, turn off “Require invitation to sign in to this zone” so users can self-register when they authenticate for the first time.
Step 2: Call It From an Agent
Section titled “Step 2: 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, both SDKs have a built-in PKCE helper that opens the browser and returns the token:
import asyncio, 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="<client-id>", client_secret="<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() { return authenticate("https://<zone-id>.keycard.cloud", { clientId: "<client-id>", clientSecret: "<client-secret>", resource: "http://localhost:8080", port: 8765, scopes: ["read"], });}This opens the browser for the Keycard zone login. The zone login is a separate account from your Keycard Console login. If it’s your first time, click Sign up to create one.
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: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}"}, )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:8080", scope: "read",});
const data = await fetch("http://localhost:8080/api/data", { headers: { Authorization: `Bearer ${result.accessToken}` },});The subjectToken is the user’s Keycard access token, obtained via OAuth 2.0 authorization code + PKCE against your zone’s authorize endpoint (https://<zone-id>.keycard.cloud/oauth/2/authorize). In production on EKS, use EKSWorkloadIdentity instead of ClientSecret.
What’s Next
Section titled “What’s Next”- Setup: Quickstart · Resource Catalog · Access Policies
- Guides: Add delegated access · Run coding agents with Keycard
- SDK Reference: OAuth SDK · MCP SDK · CLI