Skip to content
API Reference
Guides
Use Keycard SDKs

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.

Your APIAny backend service
Bearer AuthRequests verified by Keycard
Per-Request TokensShort-lived, never stored
Agent IdentityKnow which agent called what
Shared API keysKeycard
IdentityThe userUser + agent + session
ScopeWhatever the OAuth app grantsConstrained by access policy
LifetimeDays or weeks, on diskMinutes, in-memory
RevocationRevoke the whole tokenPer-agent or per-session
Audit”A token was used""This agent, on behalf of this user, accessed this resource with these scopes”

Your server needs discovery endpoints so Keycard can issue tokens for it, and bearer auth middleware to validate those tokens.

Terminal window
pip install keycardai-starlette fastapi uvicorn

keycardai-starlette provides an AuthProvider that installs discovery endpoints and AuthenticationMiddleware in a single call. Works with FastAPI and any Starlette-compatible app.

import os
from fastapi import FastAPI, Request
from 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.

Set KEYCARD_ZONE_URL to your zone URL from Keycard Console (SettingsConnection) and start the server:

Terminal window
export KEYCARD_ZONE_URL=<keycard-issuer-url> # from Settings → Connection
uvicorn main:app --port 8080

Check that discovery responds:

Terminal window
$ 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:

Terminal window
$ curl -D - http://localhost:8080/api/data
HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"

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"
}

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);
});

In Keycard Console:

  1. 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.
  2. Add scopes. Open the resource’s Scopes page and Add scope for each scope your API supports: read, write, admin.
  3. Create the application. Go to Applications → Add Application. Give it a name and identifier. For local testing, add http://localhost:8765/callback under Redirect URLs. Click Create Application.
  4. 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.)
  5. 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.

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, httpx
from 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_token

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 httpx
from keycardai.oauth import AsyncClient, BasicAuth
from 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}"},
)

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.