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_ISSUER"]
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_ISSUER!;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}`));go 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")
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. verifier, err := mcp.NewZoneTokenVerifier(zoneURL) 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") json.NewEncoder(w).Encode(map[string]any{ "data": "hello from protected API", "meta": map[string]any{"agent": authInfo.ClientID, "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 add keycardai-mcp rackupThe 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.verifier = Keycardai::OAuth::TokenVerifier.new(issuers: zone_url)
# 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) body = { data: "hello from protected API", meta: { agent: auth.client_id, 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:
export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connectionuvicorn main:app --port 8080export KEYCARD_ISSUER=<keycard-issuer-url> # from Settings → Connectionnpx tsx src/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:
$ 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": "<okta-userid>", "sub_profile": "user", "keycard_app_id": "agent-framework", "client_id": "xtidzxgh5gld1pklgcvev5ca7f", "aud": "http://localhost:8080", "scope": "read", "sid": "nr3hb6dx0a228kscyasis6uuwp", "exp": 1774137902, "iat": 1774137302, "jti": "019d12d2-ddec-7b0c-b293-52af0ca5a2f0"}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. - Set the user identifier. On your Zone’s identity Provider, set the user identifier claim to the claim you want in
sub, such as the provider’s user id. New users then get theirsubfrom that claim instead of the default Keycard ID. - 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, the 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"], });}import ( "context"
"github.com/keycardai/go-sdk/oauth")
func getSubjectToken(ctx context.Context) (string, error) { token, err := oauth.Authenticate(ctx, "<keycard-issuer-url>", oauth.AuthenticateRequest{ // from Settings → Connection ClientID: "<client-id>", ClientSecret: "<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: "<keycard-issuer-url>", # from Settings → Connection client_id: "<client-id>", client_secret: "<client-secret>", resource: "http://localhost:8080", port: 8765, scope: "read", ) token.access_tokenendThis 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_ISSUER!, { 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}` },});import ( "log" "net/http" "os"
"github.com/keycardai/go-sdk/oauth")
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)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"),)
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}" },)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