---
title: Protect Any API | Keycard
description: 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](/console/access-policies/index.md)              |
| **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” |

Note

This guide assumes you have a [Keycard zone](/guides/quickstart/index.md). If not, start with the [Quickstart](/guides/quickstart/index.md).

---

## 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](#tab-panel-82)
- [TypeScript](#tab-panel-83)
- [Python](#tab-panel-84)

Terminal window

```
go get github.com/keycardai/credentials-go/mcp
```

The 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))
}
```

Terminal window

```
npm install @keycardai/mcp express
```

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

Terminal window

```
pip install keycardai-mcp fastapi uvicorn httpx
```

The package name says `mcp` but this is standard FastAPI middleware. No MCP transport dependency.

```
import os
from fastapi import FastAPI, Request, Depends
from keycardai.mcp.server.auth import AuthProvider
from keycardai.mcp.server.middleware.bearer import BearerAuthMiddleware
from 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

Set `KEYCARD_ZONE_URL` to your zone URL from [Keycard Console](https://console.keycard.ai) (zone settings) and start the server:

Terminal window

```
export KEYCARD_ZONE_URL=https://<your-zone-id>.keycard.cloud
go run ./main.go
```

Check that discovery responds:

Terminal window

```
$ 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:

Terminal window

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

### What your middleware validates

When a valid token arrives, your middleware unpacks these claims:

- [Example](#tab-panel-85)
- [Claims Reference](#tab-panel-86)

```
{
  "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](/console/access-policies/index.md). 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

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](/console/access-policies/index.md), 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

In [Keycard Console](https://console.keycard.ai):

1. 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.
2. 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

### 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](/console/access-policies/index.md) permits it. If the user hasn’t consented, `keycard run` opens the browser. If policy denies access, the exchange fails.

Start the agent:

Terminal window

```
keycard run -- claude
```

The agent finds `$MY_API_TOKEN` in its environment, scoped to `read`:

Terminal window

```
$ 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:

Terminal window

```
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

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.

- [TypeScript](#tab-panel-87)
- [Python](#tab-panel-88)
- [Go](#tab-panel-89)

```
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 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: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](/guides/secure-agentic-coding/index.md) for the full auth lifecycle.

Note

`@keycardai/oauth`, `keycardai-oauth`, and `credentials-go/oauth` have zero MCP dependencies. Pure OAuth 2.1 / RFC 8693.

---

## What’s Next

- Setup: [Quickstart](/guides/quickstart/index.md) · [Resource Catalog](/console/resource-catalog/index.md) · [Access Policies](/console/access-policies/index.md)
- Guides: [Add Delegated Access](/guides/delegated-access/index.md) · [Secure Agentic Coding](/guides/secure-agentic-coding/index.md)
- SDK Reference: [OAuth SDK](/sdk/oauth/index.md) · [MCP SDK](/sdk/mcp/index.md) · [CLI](/sdk/cli/guide/index.md)
