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 (Settings → Connection) and start the server:
export KEYCARD_ZONE_URL=<keycard-issuer-url> # from Settings → Connectionuvicorn main:app --port 8080export KEYCARD_ZONE_URL=<keycard-issuer-url> # from Settings → Connectionnpx 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": "<keycard-issuer-url>", "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 or application that authorized access. Use sub_profile to tell which: user means the user identifier; app means an application acting with no user present, so sub is the application identifier (equal to kc_app_id). |
sub_profile | user when a user authorized access, app when an application acts on its own behalf (client_credentials). |
client_id | The OAuth client_id the calling client authenticated with. Different agents get different values, even for the same user. |
kc_app_id | The Keycard application identifier. Stable per application regardless of who is calling; use this to identify the application. |
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:
- Create the resource. Go to Resources → Add Resource → Add Manually. Set the Identifier to your server’s URL (
http://localhost:8080) and select your Zone Provider as the Credential Provider. No redirect URL needed; redirect URLs are for upstream OAuth providers like GitHub. Click Protect Resource. - Add scopes. Open the resource’s Scopes page and Add scope for each scope your API supports:
read,write,admin. - Create the application. Go to Applications → Add Application. Give it a name and identifier. For local testing, add
http://localhost:8765/callbackunder Redirect URLs. Click Create Application. - Link the resource. On the application’s Dependencies tab, click Add dependency, select the resource you created, and click Connect. (Or set Provided by Application on the resource in step 1.)
- 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.
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("<keycard-issuer-url>", { // from Settings → Connection 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 Keycard authorize endpoint (your Issuer URL + /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