# Keycard Documentation (Full) Complete documentation corpus concatenated for LLM ingestion. Source of truth: https://docs.keycard.ai Curated index: https://docs.keycard.ai/llms.txt --- # Get Started ## https://docs.keycard.ai/guides/quickstart # Quickstart By the end of this quickstart, you'll have: - Keycard protecting all tool calls via policies - Audit log of all tool authorization decisions You should be able to complete this quickstart in about 10 minutes. > **Note:** This quickstart uses [Claude Code](https://code.claude.com/docs/en/overview) and the Keycard CLI. If you're not using the CLI, see [SDK guides](/guides/#use-the-sdk) for manual setup. If you use Cursor or another MCP-compatible agent, Console's **Add to Coding Agent** option supports other coding agents too. 1. **Sign up for Keycard** Go to [console.keycard.ai](https://console.keycard.ai), create your account, and sign in. > **Note:** Keycard is currently in Early Access. You can [sign up here](https://keycard.ai/pricing). 2. **Install the Keycard CLI** Install the Keycard CLI from your computer's terminal: ```bash brew install keycardai/tap/keycard ``` This gives you the `keycard` binary, Claude Code plugin, and a set of [Skills](/skills/) Claude uses to configure your [access policy](/admin/access-policies/). 3. **Install the Keycard Claude plugin** ```bash claude plugin marketplace add keycardai/plugins claude plugin install keycard-cli@keycardai ``` 4. **Install an MCP server from the catalog** The Keycard [Catalog](/admin/catalog/) lets you install official MCP servers like Linear, Sentry, Notion, Jira, GitHub, and more. Pick one to install: 1. Open [Console](https://console.keycard.ai) → **Resources** → **Add Resource** → **Explore Resources**. 2. Pick a server (this quickstart uses **Linear** as the running example) and click **Install**. The server appears in your **Resources** list with a Keycard MCP Gateway URL. 3. Open the installed resource, click **Add to Coding Agent** → **Claude Code**, and copy the displayed `claude mcp add` command into your terminal: ```bash claude mcp add --transport http --scope user ``` > **Tip:** Want to install to Cursor or another client instead of Claude Code? The **Add to Coding Agent** dropdown in Console lists every supported client. 5. **Run your agent in a secure session** Find the CLI configuration snippet with your **Organization ID** and **Zone ID** in the [Keycard Console](https://console.keycard.ai): 1. Open **Settings** → **Connection**, then copy the **CLI configuration** code block. 2. In the root of your project, create a `keycard.toml` and paste your CLI configuration into the file: ```toml [org] id = "" [zone] id = "" ``` Then start a Keycard-protected Claude Code session: ```bash keycard run -- claude ``` Every tool call Claude makes is evaluated by Keycard before it runs. 6. **Have the agent write a policy** Keycard lets you control sensitive actions agents may take. Configure access controls by asking the agent to write a [policy](/concepts/policies/) for you. Your policy should have at least one rule that **requires Human-in-the-Loop** or **denies** a specific action. For example, if you're using Linear, try a prompt like this: > Help me set up a Keycard policy for the Linear MCP server. Allow me to read issues and require In-the-Loop approval for saving and updating issues. Claude may trigger an initial connection to Linear via Keycard. You'll be prompted to authorize access between Keycard and Linear: ![Keycard requesting access to Linear](./images/keycard-linear-authorization.png) Default policies require your approval. You will see Keycard ITL Prompt like this: ![Keycard ITL Prompt requiring approval](./images/keycard-itl-prompt.png) Claude proposes a policy written in the [Cedar policy language](/admin/access-policies/#policy-language) for you to review. It might look something like this: ```cedar @description("Allow get and list.") permit (principal, action == Action::"Agent::ToolUse", resource) when { resource like Tool::"mcp__linear__get_*" || resource like Tool::"mcp__linear__list_*" }; @description("Require in-the-loop prompt for saving new issues and updating.") @itl("prompt") permit (principal, action == Action::"Agent::ToolUse", resource) when { resource like Tool::"mcp__linear__save_issue_*" || resource like Tool::"mcp__linear__update_*" }; ``` Type `yes` to apply it. Claude writes the policy to your Keycard state directory and confirms it's active. The exact tool names will match what your installed MCP server actually exposes. 7. **Trigger an action governed by your policy** Ask Claude to complete a task the policy now gates or forbids: > Create a new issue. The Keycard hook intercepts the tool call before it runs and prompts you for approval: Claude uses the [`/keycard-query-policy`](/skills/keycard-query-policy/) skill to explain what happened. > **Note:** Keycard policies replace the default Claude permissions system. 8. **Check the session log** In your [Keycard Console](https://console.keycard.ai), click **Sessions** to see tool calls made and denied, during your session with Claude. ## What's Next Now that you have Claude Code running in a secure session with token exchange and policy governance, here's where to go next: - **Install more MCP and API servers** for Sentry, Notion, Atlassian, Gmail, Slack, and more in the [Catalog](/admin/catalog/) - **[Access APIs on Behalf of Users](/guides/access-apis-on-behalf-of-users/)** so each agent call is scoped to the signed-in user's identity, permissions, and audit attribution - **[Run Apps Without Static Secrets](/guides/run-apps-without-static-secrets/)** so workloads authorize every call with their own identity instead of long-lived API keys - **[Grant Agent Access to APIs](/guides/grant-agent-access-to-apis/)** so autonomous agents get their own scoped identity and audit trail, independent of any human ## Troubleshooting
Why am I being prompted each time? - The default policy at `~/.local/state/keycard/policy.cedar` configures Bash, Edit, and Write actions to require ITL approval these are the tools most commonly used during development. - To reduce prompts, ask Claude to update your policy with more permissive rules for actions you trust.
`keycard run` fails to start - Verify `keycard auth signin` succeeded by running `keycard whoami` - Check that `keycard.toml` exists in the project root and that its `[org] id` and `[zone] id` match the ones shown in Console under **Settings** → **Connection**
The policy isn't blocking what I expect - Ask Claude: "What's my current policy?" to trigger [/keycard-query-policy](/skills/keycard-query-policy/) and see the active rules - Make sure the Cedar diff included a `forbid` clause, or that the tool was simply omitted from any `permit` clause (allow-list policies deny by omission) - Restart `keycard run -- claude` after editing the policy if the change isn't picked up
MCP server OAuth fails on first tool call - Open the application in [Console](https://console.keycard.ai) → **Applications** and re-run the OAuth flow from the install dropdown - Confirm your `claude mcp add` command used the correct Gateway URL
## https://docs.keycard.ai/guides/how-keycard-works # How Keycard works Keycard is a unified identity and access management (IAM) platform that governs human _and_ agent access to resources. While it excels at traditional identity management, it is designed to enable trusted collaboration between people and agents. This guide covers the core architecture, detailing how the platform provides an identity fabric that controls access in human-to-agent, agent-to-agent, and agent-to-service scenarios, across highly-federated and open ecosystems while preserving complete delegation chains. ## Agentic Access Loop When a person gives an agent a task, the agent enters a loop of reasoning and acting. The agent thinks about the information it has and determines which set of tools it can use to make progress toward its goals. The agent uses these tools, which supply it with more information - more context - which it incorporates in a continuous cycle. This dynamic approach allows agents to adapt to new information and unexpected obstacles, unlike traditional software with predefined, deterministic workflows. As software becomes more human-like - more artificially intelligent - people are able to delegate more tasks, more effectively. At the same time, the more agents act on their own, the more security controls are needed to effectively govern access. Keycard secures agentic access by introducing an access loop into the agentic loop:
AGENTIC LOOP
Reason Act Observe
agent needs a resource
### Authorization Challenge When an agent uses tools, it is accessing resources. These resources could be tools exposed directly via AI-native protocols such as MCP, existing web APIs accessed via HTTP, or databases accessed through CLIs and TCP-based protocols. In order to ensure security, the resource must verify that the agent is _authorized_ to perform the operation it is requesting. This requires the agent to present a _credential_, often referred to as an access token, which provides a set of claims regarding the identity of the agent, who authorized the agent, and which actions the agent is allowed to take. If the agent does not present a credential (the agent is unauthenticated) or the credential presented is insufficient (the agent is unauthorized), the resource responds with an _authorization challenge_ to inform the agent of its status. This causes the agent to request authorization from an _authorization server_. ### Authorization Request In order to obtain a credential permitting access to a resource, an agent makes an _authorization request_ to an _authorization server_. The authorization server is responsible for authenticating the agent, as well as the person it is acting on behalf of (if any), and evaluating whether or not the agent and person are _authorized_ to access the desired resource. At the core of Keycard is Keycard STS, short for Security Token Service. Keycard STS is an _authorization server_, and is responsible for authenticating people and agents and authorizing their access to resources. Keycard STS implements OAuth 2.1, an industry-standard authorization protocol, along with a suite of extensions. ### Policy Evaluation When Keycard STS receives an authorization request, it initiates a sequence of challenges, the responses to which are needed to obtain the necessary information required to authorize the request. When authorizing a person, the challenges consist of prompts to login, step-up authentication, and grant consent. When authorizing an agent, the challenges consist of authenticating the agent's identity, attesting to its software environment, and, if required, bringing a human into the loop for approval. As each challenge and response is completed, [policy](/concepts/policies/) is evaluated against the current authorization context. The authorization context contains information about the person: identity, level of authentication, and device posture. It also contains information about the agent: identity, software attestation, and delegated authority. Policy can permit access, deny access, or trigger additional challenges. > **Note:** When policy triggers additional challenges, the authorization flow loops: Keycard STS requests more information (step-up auth, human approval) before re-evaluating. This is how sensitive operations get escalated without blocking routine access. ### Credential Issuance Assuming the agent is authorized, Keycard STS issues a [_credential_](/concepts/credentials/), often referred to as an access token. The agent then presents this credential to the resource it is accessing, thereby permitting access. This sequence creates an access loop within the agentic loop, wherein resources are placed under the protection of an authorization server. The authorization server provides a centralized point from which to control access, ensuring that agents are properly authenticated and authorized according to policy.
ZONE
User
App
Resource
Keycard STS
## Identity & Access Keycard can be used to manage access using core identity concepts: _users_, _applications_, and _resources_. Each of these entities exist within a security boundary called a _zone_. ### Zones A [zone](/concepts/zones/) is a logical grouping of users, applications, and resources that share a common set of security controls and policies. A zone has two primary responsibilities: 1. Authenticating users and applications. 2. Authorizing access by users and applications to resources. In order to fulfill these responsibilities, a zone is both a credential _verifier_ and a credential _issuer_. A zone verifies _authentication credentials_ presented by users and applications and issues _access credentials_ that authorize access to resources. Resources validate credentials issued by a zone, thereby ensuring end-to-end access control. Every Keycard zone has an instance of Keycard STS. Your organization is itself a zone, the security domain for its users, so no separate setup is required.
ZONE
Users
& Apps
Verify Keycard STS Issue
Resource
### Users [Users](/concepts/users/) are people who access protected resources, either directly or by delegating access to agents that act on their behalf. Users establish a set of credentials that allow them to authenticate to a zone. In the case of credentials using shared secrets, such as a password or one-time password (OTP), the zone is both the issuer and verifier of the credential. In cases where credentials employ cryptographic key pairs, such as passkeys, the public key is registered with the zone, while the private key remains in the user's possession. ### Applications [Applications](/concepts/applications/) are software that access protected resources, either autonomously or as a result of a user delegating access to the application. Applications establish a set of credentials that allow them to authenticate to a zone. Commonly, an application is issued a client ID and secret by the zone. The zone verifies this secret when authenticating the application. Applications can also register a public key with the zone, avoiding use of shared secrets. ### Resources [Resources](/concepts/resources/) are any service which exposes protected data. Resources take many forms, including web APIs, MCP servers, and databases. More generally, they are any service which makes access available via a network protocol, such as HTTP, FTP, or SSH. Resources validate credentials issued by a zone when they are presented by applications requesting access. This validation ensures that end-to-end access control is enforced. ## Federation Keycard's core access management capabilities can be extended to support federated ecosystems using an additional concept: _providers_. Providers represent trust relationships with other security domains. Such relationships allow access between a zone and an external domain in what is referred to as _federation_.
User IdPs
e.g. Okta, Entra
Workload IdPs
e.g. AWS, GCP, Vercel
Access Credentials
e.g. GitHub, Slack
ZONE
Keycard STS
### User Identity Providers Providers can assert the identity of users. Such providers are referred to as [_identity providers_ (IdPs)](/concepts/providers/#user-identity). IdPs allow a single set of credentials to be used to authenticate to the provider, while simultaneously allowing the underlying identity to be used in other domains. IdPs reduce password fatigue for users and streamline management for administrators. In enterprise scenarios, this is referred to as single sign-on (SSO), allowing employees to sign in using their corporate IdP, such as Okta or Microsoft Entra. In consumer situations, this is known as social login, and enables people to sign in with an existing account at Google, Apple, or a social network. Adding a user identity provider to a zone allows users to authenticate to the zone via the IdP. Keycard supports standard federated identity protocols, including OpenID Connect and SAML. ### Application Identity Providers Providers can assert the identity of applications. Applications that run on cloud infrastructure are referred to as _workloads_. Cloud service providers (CSPs) can assert the identity of these workloads. As a result of this terminology, CSPs are often referred to as [_workload identity providers_](/concepts/providers/#workload-identity). Adding a workload identity provider to a zone allows applications to authenticate to the zone using their workload identity, eliminating the need for service accounts with long-lived secrets. Keycard supports workload identity from major cloud providers, including Amazon Web Services (AWS), Microsoft Azure, Google Cloud, Vercel, and GitHub Actions. ### Access Credential Providers Services which issue access credentials for resources hosted by third-parties are [_access credential providers_](/concepts/providers/#access-federation). Adding an access credential provider to a zone allows the zone to broker credentials for external resources. Keycard supports brokering via a wide variety of mechanisms. Most commonly, delegated access tokens are vaulted and brokered to services. OAuth 2.0 token exchange can be used to support cross domain access while reducing consent fatigue for users. Provider-specific programmatic access interfaces are also supported. Keycard normalizes these differences, providing a standard way for applications to request credentials. > **Note: What's next** Explore the [domain model](/concepts/) to go deeper on zones, users, applications, resources, and providers. Or jump to the [quickstart](/guides/quickstart/) to get a working agent-to-tool connection. --- # Guides ## https://docs.keycard.ai/guides/access-apis-on-behalf-of-users # Access APIs on Behalf of Users Ask Claude to build an app for you. By the end you'll have a running application, whether a custom MCP server or any backend service, where users sign in once and every API call to a third-party service (like Linear) uses a short-lived token scoped to that user. Tokens are minted per-request and never stored.
MCP Server Your server running locally
User Auth Users sign in once via Keycard
Per-Request Tokens Scoped, short-lived, never stored
Audit Trail Every access logged in Console
> **Tip:** Prefer to integrate Keycard directly in your code? See the [SDK guides](/guides/#use-the-sdk). ## Prerequisites - **Keycard session active**. If you haven't set one up yet, complete the [Quickstart](/guides/quickstart/) first (steps 1-4). - Your `keycard run -- claude` session should be running ## Walkthrough 1. **Ask Claude to create your MCP server** Inside your Keycard session, paste this prompt: ``` Build a Linear MCP proxy using the `mcp-brokered-credentials-typescript` template from https://github.com/keycardai/templates. Name the project ``. Clone the templates repo to `~/.cache/keycard/templates` (reuse if already cached), then copy `mcp-brokered-credentials-typescript/` to `./`. Read `zone.id` and `org.id` from `keycard.toml`, then follow `SPEC.md` §1 to provision all primitives in order: Linear OAuth provider → Linear application and resource → STS provider (discover, don't create) → proxy application and resource. Three constraints to get right: (1) Wire Linear as a proxy app dependency with `PUT /zones//applications//dependencies/` and `{}` body — the `dependencies` field in POST is silently ignored. (2) Create vault credentials only via `scripts/provision-credentials.sh` — do not write secrets into tool-call output. (3) Set proxy app `consent` to `"implicit"` and Linear app `consent` to `"required"`. Write `.env` with `KEYCARD_URL=` (the Issuer URL from Settings → Connection) and `PORT=8000` — no `KEYCARD_CLIENT_ID` or `KEYCARD_CLIENT_SECRET` there. Add two `[[credentials.default]]` entries to `keycard.toml` for `urn::client_id` and `urn::client_secret` (no `${...}` interpolation — TOML uses literal strings). Run `npm install && npm run build`, then register: `claude mcp add --transport http http://localhost:8000/mcp`. Print startup instructions. ``` 2. **Claude scaffolds the project and provisions Keycard resources** Claude generates the code, wires it to your Keycard instance, and provisions the resources needed for credential brokering through the Management API. > **Tip:** While Claude is building out the MCP server, this is a good time to read the [Patterns](#patterns) section below. Look at how the scaffolded code handles auth and resource access: **Authentication:** ```ts // server.ts — every request is authenticated before any tool runs const bearerAuth = requireBearerAuth({ issuers: KEYCARD_URL, requiredScopes: ["mcp:tools"], }); app.post("/mcp", bearerAuth, async (req, res) => { ... }); ``` **Resource Access:** ```ts // upstream.ts — per-request token exchange inside a tool handler const accessCtx = await authProvider.exchangeTokens( subjectToken, // user's bearer token from the incoming request LINEAR_RESOURCE, // the upstream API being accessed ); const linearToken = accessCtx.access(LINEAR_RESOURCE).accessToken; ``` 3. **Claude registers the server in Claude's MCP config** Claude runs `claude mcp add` to register your server so it's available in future sessions. 4. **Start the server** Claude will provide detailed instructions with the exact steps to start and test your server. In a new terminal, start your MCP server with Keycard: ```bash cd keycard run -- npm start ``` `keycard run` injects short-lived credentials from your Keycard instance at startup. No secrets live in a file. Keep this terminal open. You may see a consent screen like this when the CLI requests access to your application's credentials: ![Keycard consent screen](./images/keycard-consent-screen.png) 5. **Connect and test** In another terminal, start a new Claude session from the same project directory: ```bash keycard run -- claude ``` Your MCP server is registered but needs you to authenticate before Claude can call its tools on your behalf. Type `/mcp` to open the server list, then select your server and complete the login flow: ``` > /mcp Local MCPs ❯ · △ needs authentication ``` Select the server and authorize in your browser when prompted. You may see a consent screen from Linear that establishes trust between Keycard and Linear: ![Linear consent screen](./images/linear-consent-screen.png) Once connected, you'll see `✔ connected` next to it. Now try calling a tool: > List my Linear issues. Each tool call triggers a fresh credential exchange. The upstream token is minted per-request and never stored. 6. **Verify in Keycard Console** Open [Keycard Console](https://console.keycard.ai) → **Audit Log**. You should see entries for: | | | | ---------------------- | -------------------------- | | `users:authenticate` | You logged in successfully | | `users:authorize` | Your access was authorized | | `credentials:issue` | Access token was issued | These entries map directly to the [delegation chain](#ephemeral-resource-access) described below. ## Patterns Your server is running. Here's what Claude set up and why it matters when you build your own servers or debug this one. ### Application Authorization Every request to your MCP server is authenticated before any tool runs. Whether the caller is a human or another agent, the flow is the same. The Keycard SDK handles the protocol details: advertising how callers should authenticate, validating credentials, and passing the verified identity into your tool handlers. The audit log entries from step 6 show this in action: `users:authenticate` confirms the caller's identity was verified, `users:authorize` confirms their access was checked against [policy](/admin/access-policies/). For the full authorization model, see [How Keycard Works](/guides/how-keycard-works/). ### Application Identity Your MCP server needs its own identity to access upstream resources. Keycard provisions application credentials and stores them on the platform. Nothing lives locally or gets committed to source control. At startup, `keycard run` resolves the credentials declared in `keycard.toml` and injects them into the runtime environment. Your server uses those credentials to authenticate itself to Keycard when requesting resources on behalf of callers. How credentials are delivered depends on where you're running. Locally, `keycard run` pulls them from the Keycard platform. In production, your service needs to authenticate itself. The [next guide](/guides/run-apps-without-static-secrets/) covers how. See [Applications](/platform/concepts/applications/) for the full identity and credential model. ### Ephemeral Resource Access When a tool call needs to reach an upstream API, your server doesn't hold a long-lived token. It uses delegated token exchange to get a short-lived, scoped token for that single request.
User
MCP Client
MCP Server
Resource
Keycard ephemeral credential
User
authorize
MCP Client
authenticateKeycard
caller identity
MCP Server
exchangecaller identityKeycard
ephemeral credential
Resource
The `credentials:issue` audit log entry from step 6 is the result of this exchange. Every tool call repeats the same cycle. Nothing is stored. Every access is scoped and logged. ## What's Next Your application is running locally with per-user token exchange. In production, nobody is there to inject credentials at startup, so your application needs to authenticate itself. The next guide covers that: - **[Run Apps Without Static Secrets](/guides/run-apps-without-static-secrets/)**: deploy your service so it authenticates itself, no API keys or .env files ## Troubleshooting
Why am I seeing so many ITL prompts? During the walkthrough, Claude provisions Keycard resources using `keycard agent api` commands. If your policy has a broad `@itl("prompt")` rule on `Bash`, every one of those provisioning calls triggers an approval prompt. - Ask Claude to update your policy to allow `keycard` CLI commands without ITL. - Check your active policy with: *"What's my current Keycard policy?"*
`keycard agent api` POST returns "Unsupported Media Type" or 400 Provisioning commands run inside a `keycard run` session can fail with a 400 or `"Unsupported Media Type"` error. - Prompt Claude to look up the Keycard API reference and retry the command with the correct invocation.
## https://docs.keycard.ai/guides/run-apps-without-static-secrets # Run Apps Without Static Secrets Ask Claude to deploy your application to Fly.io. The deployed service identifies itself with a runtime OIDC token -- no API keys to store or rotate.
Register Provider Trust Fly.io's OIDC issuer in Keycard
Deploy App App runs on Fly.io runtime
Token Exchange Runtime OIDC token → Keycard credential
No Secrets Ephemeral credentials, nothing to rotate
> **Tip:** Prefer to integrate Keycard directly in your code? See the [SDK guides](/guides/#use-the-sdk). ## Prerequisites - **Application running locally** from [Access APIs on Behalf of Users](/guides/access-apis-on-behalf-of-users/) - **Fly.io account** with `flyctl` installed ([sign up](https://fly.io) if you don't have one) > **Tip:** You can ask Claude to install `flyctl` for you if you don't have it yet. - Your `keycard run -- claude` session should still be running ## Walkthrough 1. **Ask Claude to deploy** Inside your Keycard session, send a prompt like: ``` Deploy my MCP server to Fly.io. Read `zone.id` and `org.id` from `keycard.toml` — stop and explain if either is missing. Check that `flyctl` is installed (`command -v flyctl`); if not, install it with `curl -L https://fly.io/install.sh | sh`. Probe `flyctl auth whoami` to check if a deploy credential is wired; if not, stop and tell me to follow https://docs.keycard.ai/platform/concepts/resources/#vaulted-static-credentials to configure a Fly.io org token as a brokered credential, then restart the session. Run `flyctl orgs list` and ask me to confirm the org slug before proceeding. Generate or validate `fly.toml`. Determine the app's port from `.env` (`PORT`), `keycard.toml`, `package.json` scripts, or fall back to `8080`. Align `[http_service].internal_port` to that port. Write `KEYCARD_URL = ''` (the Issuer URL from Settings → Connection) into the `[env]` section. Surface `fly.toml` (and `Dockerfile` if generated) before continuing. Provision Keycard primitives idempotently using list-then-create (reuse on 409): (1) Register OIDC provider with name `fly-` and issuer `https://oidc.fly.io/` — the slug must appear in the issuer URL. (2) Look up or create Application with identifier ``. (3) Find the zone's `keycard-sts` provider and use its ID as `credential_provider_id` when creating the deployed Resource `https://.fly.dev/mcp` with `scopes: ["mcp:tools"]` — omitting `credential_provider_id` causes credential requests to fail with "missing a credential provider". (4) Create Application Credential with `subject: "::*"` — bare `*` accepts any workload from the org. Run `flyctl deploy --remote-only` from the app directory. Verify with `flyctl status` and `curl -sf https://.fly.dev/.well-known/oauth-protected-resource` — confirm the `resource` field equals `https://.fly.dev/mcp`. Run `claude mcp add --transport http https://.fly.dev/mcp` to register the production server. Print a summary with the deployed URL, both Resource identifiers (local dev + production), and the registered Provider/Application/Credential IDs. ``` 2. **Authenticate to Fly.io** The prompt above probes for credentials automatically. If `flyctl auth whoami` shows no active credential, Claude stops and walks you through one of the two options below before continuing: **Keycard-brokered access:** Fly.io doesn't support federated token exchange, so you store a Fly.io deploy token in Keycard as a [vaulted static credential](/concepts/resources/#vaulted-static-credentials). Keycard brokers access without persisting credentials on your machine. Create a deploy token at [fly.io/tokens](https://fly.io/tokens) scoped to your Fly.io organization, then open the Keycard Console link Claude gives you and paste the token under **Credentials**. Restart your `keycard run` session afterward so it picks up the new credential — Claude will tell you when this is necessary and how to resume. The token is encrypted at rest. Claude never sees the raw value; it only receives it through token exchange at deployment time. When Claude needs the token, Keycard shows a consent screen so you can approve before it proceeds: ![Keycard consent screen for Fly.io](./images/flyio-consent-screen.png) > **Caution:** Never paste credentials into an agent conversation. Always use [Keycard Console](https://console.keycard.ai) to store credentials. This keeps secrets off the LLM path entirely. **Direct login:** Run `flyctl auth login` in your terminal. This authenticates your local user to Fly.io, and Claude can use `flyctl` commands on your behalf within the Keycard session. ```bash flyctl auth login ``` Use a Keycard [access policy](/admin/access-policies/) to restrict which `flyctl` commands the agent can run. For example, you can allow `flyctl deploy` but require ITL approval for `flyctl destroy`. > **Caution:** This is the quickest path. The downside is that `flyctl` credentials persist on your machine beyond the agent session. 3. **Claude configures and deploys** Claude generates the Fly.io config, wires up Keycard trust for workload identity, registers the production resource, and deploys the container. > **Tip:** While Claude is building out the deployment, this is a good time to read the [Patterns](#patterns) section below. Here's how the scaffolded code handles credential discovery and workload identity: **Credential Discovery:** ```ts // credentials.ts - auto-detects the runtime and picks the right credential export function discoverApplicationCredential( options: DiscoverOptions, ): ApplicationCredential { const clientId = process.env.KEYCARD_CLIENT_ID; const clientSecret = process.env.KEYCARD_CLIENT_SECRET; if (clientId && clientSecret) { return new ClientSecret(clientId, clientSecret); // local dev via keycard run } if (process.env.FLY_APP_NAME) { return new FlyWorkloadIdentity({ audience: options.zoneUrl }); // production } // ... EKS, web identity, and other runtimes } ``` **Workload Identity:** ```ts // credentials.ts - Fly.io OIDC token fetch via the runtime metadata endpoint class FlyWorkloadIdentity implements ApplicationCredential { async prepareTokenExchangeRequest(subjectToken: string, resource: string) { const oidcToken = await this.#getToken(); // fetched from /.fly/api return { subjectToken, resource, clientAssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", clientAssertion: oidcToken, }; } } ``` 4. **Test the production deployment** Your MCP server is live at `https://.fly.dev`. Ask Claude to call one of your server's tools. The same tools that worked locally now run through the deployed server, but the server authenticates with workload identity instead of `keycard run`. 5. **Verify in Keycard Console** Open [Keycard Console](https://console.keycard.ai) > **Audit Log**. You should see entries for the tool call flowing through your production server: | | | | ---------------------- | -------------------------- | | `users:authenticate` | You logged in successfully | | `users:authorize` | Your access was authorized | | `credentials:issue` | Access token was issued | Same event types as local development. Authorization and audit don't change based on where the server runs. ## Patterns Your service is live with no stored secrets. Here's what Claude set up and how to apply it to other runtimes. ### Workload Identity Locally, `keycard run` injects credentials at startup. In production there's no human present, so the deployed service needs its own identity to authenticate with Keycard and request resources on behalf of callers. Claude registered a Fly.io OIDC provider in your Keycard instance. When your server starts on Fly.io, it gets an OIDC token from the runtime. Keycard verifies that token against the registered provider and issues credentials -- no stored secrets involved.
Fly.io Runtime
MCP Server
Resource
Keycard ephemeral credential
Fly.io Runtime
OIDC token
MCP Server
tokenexchangeKeycard
ephemeral credential
Resource
The application credential Claude configured is what ties this together. It specifies which Fly.io workloads can act as your application, scoped to your organization and app name. See [Applications](/platform/concepts/applications/) for the identity and credential model. ### Vaulted Static Credentials Some upstream resources only offer static API keys or long-lived tokens -- no ephemeral token exchange. For those, Keycard stores the credential as a [vaulted static credential](/platform/concepts/resources/#vaulted-static-credentials) and brokers access through token exchange. Only use vaulted credentials when native token exchange isn't available. They have two drawbacks: - You need to manually rotate the credential in [Keycard Console](https://console.keycard.ai) when it expires or gets compromised. - Every application that requests this resource receives the same static credential, so you can't scope access per-caller or per-request. If a resource supports OIDC or OAuth token exchange, use that instead. Ephemeral tokens expire on their own and don't need rotation. ### Production Resource Configuration Locally, your MCP server's resource identifier points to `localhost`. In production, Keycard needs the server's public URL so callers can find it. Claude registered a second resource (`https://.fly.dev/mcp`) alongside the existing `localhost` resource. Both belong to the same application. MCP clients use this identifier to locate your server, and callers authenticate through Keycard, which sends them to the right endpoint. Both resources are visible in [Keycard Console](https://console.keycard.ai) under your application's resource configuration. ## Troubleshooting
Claude is still calling `localhost` instead of the deployed URL After deployment, Claude's MCP client config may still point to `http://localhost:3000/mcp`. Ask Claude to update the MCP config to use the new production address (`https://.fly.dev/mcp`), then restart the MCP connection.
Token exchange fails with "provider not found" Keycard doesn't recognize the Fly.io OIDC issuer yet. Ask Claude to register the Fly.io OIDC provider, or check [Keycard Console](https://console.keycard.ai) > **Providers** to verify it's listed.
Deployment succeeds but the server returns 401 The application credential may not match the deployed workload. Verify in [Keycard Console](https://console.keycard.ai) > **Applications** that the credential's Fly.io app name and organization match your deployment. A mismatch means the runtime OIDC token won't pass validation.
## What's next Your service is running in production with no managed credentials. The next guide covers giving an agent its own access to third-party APIs, with no human in the loop. - **[Grant Agent Access to APIs](/guides/grant-agent-access-to-apis/)**: build an agent that authenticates to external services like Snowflake ## https://docs.keycard.ai/guides/grant-agent-access-to-apis # Grant Agent Access to APIs Ask Claude to build an autonomous agent for you. By the end of this guide you'll have a standalone agent that authenticates to Snowflake using Workload Identity Federation (WIF). No human approves each request, and no API keys live on disk. Other agents and clients talk to it over the [A2A protocol](https://google.github.io/A2A/). > **Note: Acting as a user instead of as itself** This guide builds an agent that acts as *itself*. If your agent needs to act *as a specific user* without that user being present, see [impersonation](/concepts/credentials/#impersonation).
Keycard
Agent
Snowflake
> **Tip:** Prefer to integrate Keycard directly in your code? See the [SDK guides](/guides/#use-the-sdk). ## Prerequisites - **Keycard**. You need your Keycard **Issuer URL** (found under **Settings** → **Connection**, e.g. ``). - **Snowflake account** with permission to run `CREATE USER ... TYPE = SERVICE`. The agent will walk you through creating a Workload Identity Federation service user that trusts tokens from your Keycard zone. - **Keycard session active**. Your `keycard run -- claude` session should still be running. - **Tailscale with Funnel enabled**. The agent serves its identity (public key and OAuth client metadata) and A2A agent card at `/.well-known/` endpoints. Keycard pulls these from the public internet to check the agent's signed assertions, so `localhost` won't work. > **Note:** Install [Tailscale](https://tailscale.com/download) and enable [Funnel](https://tailscale.com/kb/1223/funnel) on your tailnet (Admin Console → DNS → enable HTTPS, then DNS → Funnel). Funnel exposes a single local port on a stable `https://..ts.net` URL, with no reverse proxy or tunnels to set up. If you'd rather deploy the agent than run it locally, skip Funnel and follow [Run apps without static secrets](/guides/run-apps-without-static-secrets/) after this guide to host the agent on Fly.io. ## Walkthrough 1. **Ask Claude to create your agent** Inside your Keycard session, send a prompt like: ``` Create a Snowflake autonomous AI agent with its own identity so I can grant it access to tools ``` The Keycard skill drafts an action plan and shows it to you before doing anything. > **Tip:** While Claude scaffolds the agent and sets up Keycard resources, skim the [Patterns](#patterns) section below so you know what to expect once the agent boots. 2. **Expose the agent with Tailscale Funnel** Before scaffolding, start a Tailscale Funnel on the port the agent will use (default `9000`). Claude needs the public URL when it registers the agent with Keycard: ```bash tailscale funnel --bg 9000 ``` Tailscale prints a public URL like `https://..ts.net`. Copy it; Claude will ask for it as `AGENT_BASE_URL`. If you already have a funnel running, check with `tailscale funnel status`. > **Caution:** Funnel requires HTTPS and Funnel to be enabled in your tailnet's Admin Console. If `tailscale funnel` errors, follow Tailscale's [Funnel setup guide](https://tailscale.com/kb/1223/funnel) to enable it. 3. **Claude scaffolds the project and bootstraps the agent's identity** Claude generates the code, configures it for your Keycard zone, installs dependencies, and creates the agent's keypair (an RSA key stored in `agent_keys/`). The agent serves three documents at well-known URLs that Keycard and A2A clients consume: | Endpoint | Purpose | | --- | --- | | `/.well-known/jwks.json` | Public key Keycard uses to verify the agent's signed client assertions | | `/.well-known/oauth-client-metadata` | OAuth Client ID Metadata Document. This URL is the agent's `client_id` | | `/.well-known/agent-card.json` | A2A agent card advertising the agent's capabilities and auth requirements | These documents are fetched from the public internet, so the agent has to be reachable through your Tailscale Funnel URL. Claude uses the URL you copied in step 2 when it registers the agent with Keycard. Here's how the generated code handles identity, token exchange, and A2A: **Identity:** ```ts // src/identity.ts: bootstrap the keypair and publish the CIMD const identity = new WebIdentity({ storageDir: "./agent_keys", keyId: process.env.AGENT_NAME, }); await identity.bootstrap(); router.get("/.well-known/oauth-client-metadata", (_req, res) => { res.json({ client_id: getClientId(), token_endpoint_auth_method: "private_key_jwt", grant_types: ["urn:ietf:params:oauth:grant-type:token-exchange"], jwks: identity.getPublicJwks(), }); }); ``` **Token exchange:** ```ts // src/tokenProvider.ts: sign an assertion, exchange for a Snowflake-scoped token const request = await identity.prepareTokenExchangeRequest("", resource, { tokenEndpoint: `${keycardUrl}/oauth/2/token`, authInfo: { resource_client_id: clientId }, }); const body = new URLSearchParams({ grant_type: "client_credentials", client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", client_assertion: request.clientAssertion!, resource, // "snowflakecomputing.com" }); ``` **Snowflake WIF:** ```ts // src/snowflake.ts: hand the OIDC token to Snowflake's WORKLOAD_IDENTITY authenticator const connection = snowflakeSdk.createConnection({ account: config.account, authenticator: "WORKLOAD_IDENTITY", workloadIdentityProvider: "OIDC", token: accessToken, }); ``` **A2A:** ```ts // src/server.ts: agent card and JSON-RPC, gated by Keycard-verified caller identity app.use(identityRouter()); app.use( "/.well-known/agent-card.json", agentCardHandler({ agentCardProvider: requestHandler }), ); app.use( "/a2a/jsonrpc", jsonRpcHandler({ requestHandler, userBuilder }), ); ``` > **Tip:** You can see the Keycard resources Claude created in [Keycard Console](https://console.keycard.ai). The [Patterns](#patterns) section below explains what got created and why. 4. **Configure your `.env`** Claude generates a `.env` file with placeholders. Fill in the values for your environment. You can find your Snowflake account identifier, warehouse, and database from [Snowflake's connection settings](https://docs.snowflake.com/en/user-guide/gen-conn-config). `AGENT_BASE_URL` must be the Tailscale Funnel URL from step 2, and `PORT` must match the port you funneled. `SNOWFLAKE_USER` defaults to `autonomous_agent`, which is the service user you'll create in the next step. 5. **Create the Snowflake WIF service user** Claude prints a `CREATE USER` statement with your Keycard zone URL and the agent's application identifier filled in. Run it in a Snowflake worksheet (or via SnowSQL) using whichever role your Snowflake admin tells you to use: ```sql CREATE USER autonomous_agent WORKLOAD_IDENTITY = ( TYPE = OIDC ISSUER = '' SUBJECT = '' ) TYPE = SERVICE; ``` That creates a Snowflake service user that trusts OIDC tokens from your Keycard zone for this specific agent. Grant the user a role and warehouse access based on what your Snowflake admin tells you. 6. **Start the agent** With the funnel running and the WIF user created, start the agent through `keycard run` so the LLM key comes from your zone: ```bash cd keycard run -- npm start ``` At startup the agent: 1. Loads or generates its RSA keypair 2. Starts an Express server with the identity and A2A endpoints on `PORT` 3. Trades a signed JWT at Keycard's token endpoint for an access token scoped to `snowflakecomputing.com` 4. Connects to Snowflake with that token using the `WORKLOAD_IDENTITY` authenticator No secrets get passed on the command line or stored in env vars. The agent proves who it is with a signed JWT and gets a short-lived token back. > **Tip:** In a second terminal, sanity-check that the funnel is forwarding to the agent: ```bash curl -sf "$AGENT_BASE_URL/.well-known/jwks.json" | jq '.keys | length' curl -sf "$AGENT_BASE_URL/.well-known/oauth-client-metadata" | jq '.client_id' ``` Both should return data. If they hang or 404, run `tailscale funnel status` and confirm it points at the same `PORT` the agent is listening on. 7. **Send a request via A2A** The agent exposes an A2A JSON-RPC endpoint at `/a2a/jsonrpc` and an agent card at `/.well-known/agent-card.json`. Any A2A-compatible client can send tasks to it: ```bash curl -X POST http://localhost:9000/a2a/jsonrpc \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"tasks/send","id":"1","params":{"id":"task-1","message":{"role":"user","parts":[{"kind":"text","text":"Query Snowflake for the available sample datasets"}]}}}' ``` The agent checks the inbound request with Keycard, runs the query against Snowflake using its short-lived OIDC token, and streams the result back over A2A. 8. **Verify in Keycard Console** Open [Keycard Console](https://console.keycard.ai) → **Audit Log**. You should see entries for: | | | | ---------------------- | -------------------------------------------- | | `credentials:issue` | Access token issued for `snowflakecomputing.com` | That entry is the agent's token exchange. The agent presented a signed JWT, and Keycard returned a short-lived access token scoped to Snowflake. ## Patterns Your agent is running. Here's what Claude set up, and why it matters when you build your own agents or come back to debug this one. ### Agent Identity The agent owns its own keypair instead of using an injected secret. On first startup it generates an RSA keypair and saves it under `agent_keys/`. The private key never leaves the machine. The public key gets published at `/.well-known/jwks.json`, and an [OAuth Client ID Metadata Document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/) at `/.well-known/oauth-client-metadata` tells Keycard how to authenticate the agent. That metadata URL is the agent's `client_id`. When the agent needs an access token, it signs a JWT with its private key and sends it to Keycard's token endpoint. Keycard fetches the metadata document, finds the public key, checks the signature, and issues a scoped access token. That's the `private_key_jwt` method from [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523). No shared secret ever lives on disk or in env vars. To rotate the identity, delete `agent_keys/` and restart. The agent makes a new keypair, and Keycard picks up the new public key the next time it fetches the metadata. See [Applications](/platform/concepts/applications/) for the full identity and credential model. ### Workload Identity Federation Instead of storing Snowflake credentials, the agent uses Keycard as a token broker for [Workload Identity Federation](https://docs.snowflake.com/en/user-guide/workload-identity-federation). The flow: 1. The agent signs a JWT with its private key and posts it to Keycard's `/oauth/2/token` endpoint, asking for a token scoped to `snowflakecomputing.com`. 2. Keycard verifies the JWT against the agent's published public key. 3. Keycard returns an OIDC access token whose `iss` is your zone URL and whose `sub` is the agent's application identifier. Those are the same values you put into the Snowflake `CREATE USER ... WORKLOAD_IDENTITY` statement. 4. The agent connects to Snowflake with the `WORKLOAD_IDENTITY` authenticator, presenting the OIDC token. Tokens are cached per resource and refreshed before they expire. If a Snowflake connection drops mid-query, the client throws away its cached token, gets a fresh one, and retries with exponential backoff. You don't rotate anything by hand, and nothing gets persisted. For the broader authorization model, see [How Keycard Works](/guides/how-keycard-works/). ### A2A Interface The agent exposes the [Agent-to-Agent (A2A) protocol](https://google.github.io/A2A/) so other agents or clients can send it tasks. The `@keycardai/a2a` package provides the agent card handler, the JSON-RPC handler, and a `keycardUserBuilder` that checks inbound caller tokens against your Keycard zone before any task runs. The agent card at `/.well-known/agent-card.json` lists the agent's capabilities and how to authenticate to it, so A2A clients can discover and connect on their own. ## Troubleshooting
Keycard returns `invalid_client` when the agent requests a token Keycard couldn't fetch or verify the agent's metadata document. - Run `tailscale funnel status` and confirm it points at the same port the agent listens on. - Check `curl $AGENT_BASE_URL/.well-known/oauth-client-metadata` returns JSON. - Ask Claude to re-check the application credential's `identifier` and `jwks_uri` match the current Funnel URL.
Token exchange fails with `invalid_target` or "unknown resource" The `snowflakecomputing.com` resource isn't wired as a dependency of the agent application. - Open [Keycard Console](https://console.keycard.ai) → **Applications** → your agent → **Dependencies** and confirm Snowflake is listed. - If it's missing, ask Claude to re-run the dependency wiring step.
Snowflake rejects the OIDC token with an authentication error The Snowflake WIF user's `ISSUER` or `SUBJECT` don't match the agent's token. - `ISSUER` must equal your `KEYCARD_URL`; `SUBJECT` must equal the agent's application identifier (the token `sub` claim). - Fix with `ALTER USER autonomous_agent SET WORKLOAD_IDENTITY = (TYPE = OIDC ISSUER = '' SUBJECT = '');`.
## What's next Your agent is running locally with autonomous access to Snowflake. From here you can: - **[Run apps without static secrets](/guides/run-apps-without-static-secrets/)**: deploy the agent to Fly.io so it runs 24/7 without the Tailscale Funnel. - **[How Keycard Works](/guides/how-keycard-works/)**: the full authorization model and agentic access loop. - **[Applications](/platform/concepts/applications/)**: how Keycard manages application identity and credentials. ## https://docs.keycard.ai/guides/why-keycard # Why Keycard Agents don't work like apps. They reason, act, and adapt at runtime, calling tools, accessing APIs, reading data, writing code in loops that can't be predicted at deploy time. Traditional IAM was designed for a world where software follows deterministic paths and humans make the decisions. That world is ending. ## The trilemma Every team deploying agents today hits the same tradeoff: **autonomy**, **capability**, and **security** - pick two.
You can only pick 2 Autonomy Capability Security
- **Give agents full autonomy and capability**, and they run freely across systems, leaking secrets, invoking unintended tools, or taking actions that look innocuous until they aren't. - **Lock things down with human-in-the-loop approval**, and you get consent fatigue and constant oversight that erases the productivity gains agents were supposed to deliver. - **Narrow the scope with network-level controls**, and agents become autonomous in name only. Brittle, limited, unable to reach the tools they need. Most teams pick two and constrain the third. The productivity gains these agents can already deliver stay theoretical instead of deployed at scale. Keycard is built to break that tradeoff. ## Why it's broken The problem isn't the agents. It's the infrastructure underneath them. Agents inherit the user's **full token** or a shared service account. There's no way to scope, revoke, or trace what the agent did versus what the user did. Credentials are **long-lived and shared** across agents, repos, and environments with no per-task scoping, no attestation, no expiration. There's **no enforcement layer** between the model's decision and the action. Prompt injection redirects tool calls, and security teams have no runtime control. **No structured audit** exists across sessions or tool calls. Incident response ends with "we don't know." Without visibility, trust never builds. These gaps exist because existing IAM architectures - RBAC, conditional access, network-layer controls - were built for deterministic software and human decision-makers. Agents break every assumption they depend on. ## What Keycard does differently Keycard is identity and access infrastructure built from the ground up for agents. It resolves identity, enforces policy, issues scoped credentials, and logs everything - at runtime, on every tool call. Users, agents, and workloads each get a **verified identity**. Agents prove who they are through workload attestation - SPIFFE, cloud instance identity, mTLS - combined with the delegating user, the device, and the task for composite identity. [Policy](/concepts/policies/) evaluates at **credential issuance**, not at login or the network boundary. Every tool an agent touches - shell commands, MCP servers, APIs, agent-generated code - is governed at the point of execution. No credential is issued without policy approval. Static secrets are replaced with **[ephemeral tokens](/concepts/credentials/)** cryptographically bound to the agent, the user, the runtime environment, and the task. Credentials are injected in-memory, never touch disk or the agent's context window, and expire when the session ends. Every hop from user to agent to sub-agent to tool is traced. When something goes wrong, the **audit trail** shows exactly who authorized what, through which chain, at what time. Routine actions proceed without interruption. Sensitive operations - deploying to production, accessing restricted data - trigger **step-up approval** from the developer. No consent fatigue. No blanket `--dangerously-skip-permissions`. Keycard extends your existing [identity providers](/concepts/providers/) (Okta, Auth0, Google) to cover agents. It doesn't replace your IdP - it bridges the gap between human identity and agent access across trust domains. ## The result Teams stop choosing between autonomy, capability, and security. Agents get the access they need, scoped to the task, enforced by policy, and logged for everything. The organization gets agents that are actually deployed in production. > **Note: Example: coding agent deploys to staging** A coding agent needs to read a private repo, open a PR, and deploy to staging. Keycard issues a repo-scoped token and a staging-scoped deploy credential automatically. A production deploy triggers step-up approval. Every action traces back to the user, the agent, and the policy that authorized it. - [How Keycard works](/guides/how-keycard-works/) ## https://docs.keycard.ai/guides/slack-agent # Build a Slack agent (Experimental) > Experimental: generalized from a Slack agent we run internally. The Keycard primitives are stable; SDK method names and recommended wiring are still moving. Pin SDK versions. Build a Slack bot that answers questions like "what's on my calendar today?" from each user's own Google Calendar through a Keycard-protected MCP server. Each Slack user authorizes once; every downstream call runs as that user with a short-lived, scoped credential. No shared service-account token, no permission mirror in the application layer, and every authorization is logged. ## Architecture ``` Slack user ──▶ Slack agent (Bolt + LLM loop + MCP client) │ per-user MCP session (OAuth 2.0 + PKCE, Keycard as AS) ▼ Google MCP server (Keycard-protected) │ RFC 8693 token exchange (user token → Google token) ▼ Google Calendar API ``` Two authorization hand-offs, Keycard under both: - User to MCP server: the agent is an MCP client. Each Slack user authorizes via the standard MCP authorization flow (OAuth 2.0 with PKCE) against Keycard. The agent registers as a public client (Dynamic Client Registration) and needs no Keycard secret. - MCP server to Google: the MCP server authenticates with its own application credential (client ID + secret to start; workload identity later) and exchanges each incoming user token for a Google token scoped to that user. ## What you'll build - Google connected to Keycard as a credential provider - A Keycard-protected Google MCP server running locally with Calendar tools - A Slack bot that answers calendar questions as the requesting user - An audit trail in Console tracing every access to the specific Slack user ## Prerequisites - Keycard account (your Keycard URL and Redirect URL are under Configuration → Connection) - Slack workspace where you can create apps; Google Cloud account; Anthropic API key; uv or Docker - Clone the tutorial repo (contains the agent in two languages, `agent-python/` and `agent-typescript/`, plus `google-mcp-server/`): ```bash git clone https://github.com/keycardai/tutorial-slack-agent.git cd tutorial-slack-agent ``` ## Part 1: Connect Google to Keycard 1. Copy the Redirect URL from Keycard (Configuration → Connection tab → OAuth). 2. In Google Cloud Console → APIs & Services → Credentials (APIs & Services is under the ☰ menu, top-left), create an OAuth 2.0 Client ID (Web application) with that redirect URI. Enable the Google Calendar API (APIs & Services → Enabled APIs & services → + Enable APIs and services → search Google Calendar API → Enable). Note client ID + secret. Google Cloud Console's nav shifts often; the goal is one OAuth Web app client plus the Calendar API enabled on the same project. 3. In Keycard Console, open Resources in the sidebar, click the dropdown on Add Resource, choose Explore Resources, search Google Calendar, select it, and fill in the Google client ID and secret. This creates the Google OAuth provider and the Google Calendar API resource (identifier `https://www.googleapis.com/calendar/v3`). 4. Open the Google Calendar resource and confirm its Scopes: `https://www.googleapis.com/auth/calendar.readonly` for read, or `https://www.googleapis.com/auth/calendar` to also use the write tools. Without a scope Google rejects the exchanged token. ## Part 2: Register the MCP server in Keycard 1. Resources → dropdown on Add Resource → Add Manually: name `Google MCP Server`, identifier `http://localhost:8000/mcp` (include the `/mcp` path: it is the exact resource clients request a token for; a bare `http://localhost:8000/` makes authorization fail), credential provider `Zone Provider`. 2. Applications → Add Application (no dropdown, goes straight to the form): name `Google MCP Server`, identifier `http://localhost:8000/mcp`, then Create Application. On the details page: Dependencies tab → add the Google Calendar API resource; Provides tab → add the Google MCP Server resource. The MCP server shows up as two linked things: a resource (what users get a token for) and an application (the identity it uses to call Google). The application provides the resource and depends on Google, which is what lets it exchange one token for the other. 3. Application Credentials → Add Credential → Client ID & Secret. Copy both now. 4. Policies: make sure an active policy set includes `default-app-delegation` and permits your users to access the two resources. ## Part 3: Run the Google MCP server ```bash cd google-mcp-server cp .env.example .env # KEYCARD_ISSUER=https://.keycard.cloud # KEYCARD_CLIENT_ID= # KEYCARD_CLIENT_SECRET= # MCP_SERVER_URL=http://localhost:8000/ # GOOGLE_API_RESOURCE=https://www.googleapis.com/calendar/v3 uv sync uv run python -m google_mcp_server ``` (Or `docker compose --profile python up`, or `--profile typescript`, from the repo root runs the server plus your chosen agent once the `.env` files are filled in.) MCP endpoint: `http://localhost:8000/mcp`. Leave `MCP_SERVER_URL` as the base `http://localhost:8000/`; the server appends `mcp`, so it advertises the `http://localhost:8000/mcp` resource from Part 2. Trailing slashes are significant in these identifiers; copy them exactly. Caution: `GOOGLE_API_RESOURCE` must match the Google resource identifier in Keycard character for character. The catalog resource is `https://www.googleapis.com/calendar/v3`, not bare `https://www.googleapis.com`. A mismatch passes authorization, then the first tool call fails with `Token exchange failed` (400 from Keycard's token endpoint) in the server logs. ## Part 4: Create the Slack app and run the agent The agent ships in two languages: `agent-python/` (Keycard MCP client, keycardai-mcp ClientManager) and `agent-typescript/` (official MCP SDK + @keycardai/mcp BaseOAuthClientProvider). Pick one; run only one of the two variants at a time (they connect to the same Slack app, so running both answers every message twice). 1. api.slack.com/apps → Create New App → From a manifest; paste `manifest.json` from your chosen agent directory (`agent-python/manifest.json` or `agent-typescript/manifest.json`; the two are identical). Install to the workspace; copy the Bot User OAuth Token (`xoxb-...`) and generate an App-Level Token with `connections:write` (`xapp-...`). 2. Configure and run (a second terminal): ```bash # Python cd agent-python cp .env.example .env # SLACK_BOT_TOKEN= # SLACK_APP_TOKEN= # ANTHROPIC_API_KEY= # MCP_SERVERS=[{"key":"google","url":"http://localhost:8000/mcp"}] uv sync uv run slack-agent # or TypeScript (same .env contract) cd agent-typescript cp .env.example .env npm install npm run dev ``` The agent connects over Socket Mode and serves the OAuth callback on `http://localhost:3000`. It needs no Keycard credential (public MCP client via DCR; each user does their own PKCE authorization). ## Part 5: Test in Slack 1. DM the bot: "What's on my calendar today?" → it replies with an authorization link. 2. Click the link, sign in to Keycard, consent to Google Calendar access. The bot confirms. 3. Ask again → it lists your events. Another user gets their calendar after authorizing; an unauthorized user gets nothing. 4. Verify in Console Audit Logs: `users:authenticate`, `users:authorize`, `credentials:issue`. Every entry traces to the specific user, not the agent's service identity. ## Beyond the tutorial ### Calling provider APIs directly (RFC 8693 token exchange) Python (`pip install keycardai-oauth`): ```python import os from keycardai.oauth import AsyncClient, BasicAuth async def token_for(user_access_token: str, resource: str) -> str: async with AsyncClient( os.environ["KEYCARD_ISSUER"], auth=BasicAuth(os.environ["KEYCARD_CLIENT_ID"], os.environ["KEYCARD_CLIENT_SECRET"]), ) as client: resp = await client.exchange_token( subject_token=user_access_token, subject_token_type="urn:ietf:params:oauth:token-type:access_token", resource=resource, ) return resp.access_token ``` TypeScript (`npm install @keycardai/oauth`): ```typescript import { TokenExchangeClient, TokenType } from "@keycardai/oauth"; const keycard = new TokenExchangeClient(process.env.KEYCARD_ISSUER!, { clientId: process.env.KEYCARD_CLIENT_ID!, clientSecret: process.env.KEYCARD_CLIENT_SECRET!, }); async function tokenFor(userAccessToken: string, resource: string): Promise { const resp = await keycard.exchangeToken({ subjectToken: userAccessToken, subjectTokenType: TokenType.ACCESS_TOKEN, resource, }); return resp.accessToken; } ``` Go (`go get github.com/keycardai/go-sdk`): ```go import ( "context" "os" "github.com/keycardai/go-sdk/oauth" ) var keycard = oauth.NewTokenExchangeClient( os.Getenv("KEYCARD_ISSUER"), oauth.WithClientCredentials(os.Getenv("KEYCARD_CLIENT_ID"), os.Getenv("KEYCARD_CLIENT_SECRET")), ) func tokenFor(ctx context.Context, userAccessToken, resource string) (string, error) { resp, err := keycard.ExchangeToken(ctx, oauth.TokenExchangeRequest{ SubjectToken: userAccessToken, SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token", Resource: resource, }) if err != nil { return "", err } return resp.AccessToken, nil } ``` ### Acting as a user you resolved out of band (substitute-user) Privileged path: the agent acts as a user without that user presenting a token in the moment. Gate behind an access policy; only use with a verified Slack-user-to-Keycard-user mapping. Python: `resp = await client.impersonate(user_identifier=keycard_user_id, resource="https://www.googleapis.com/calendar/v3")` TypeScript: `const resp = await keycard.impersonate({ userIdentifier: keycardUserId, resource: "https://www.googleapis.com/calendar/v3" });` Go: `resp, err := keycard.Impersonate(ctx, oauth.ImpersonateRequest{UserIdentifier: keycardUserID, Resource: "https://www.googleapis.com/calendar/v3"})` ### Bring your own harness The tutorial repo uses Bolt (Socket Mode) with a small Claude tool loop, but the LLM loop (Claude Agent SDK, OpenAI Agents, raw), Slack ingress (Socket Mode vs HTTP events), and approval UX are yours. Keycard sits underneath, where each tool call needs a credential. ## Next steps - Switch the MCP server to a workload identity to eliminate its client secret - Add more Keycard-protected MCP servers to `MCP_SERVERS` - Register more Google API resources (Drive, Gmail, Docs, Sheets tools ship in the server) - Deploy both services as containers; update `MCP_SERVER_URL`, resource identifiers, and the agent's `BASE_URL` - Set up audit log export to stream the authorization trail into your SIEM ## https://docs.keycard.ai/guides/secure-agentic-coding # Run coding agents with Keycard Agentic coding tools like Claude Code and Cursor need access to production databases, third-party APIs, and internal services to be useful. But broad access creates risk: one misunderstood prompt can lead to a destructive query or a leaked credential. `keycard run` solves this by wrapping your coding agent in a secure session with automated credential management and enforced tool policies. ## Get started 1. Install the CLI: ```bash brew install keycardai/tap/keycard ``` 2. Install the plugin: ```bash claude plugin marketplace add keycardai/plugins claude plugin install keycard-cli@keycardai ``` 3. Sign in to your Keycard account: ```bash keycard auth signin --zone ``` 4. Add a `keycard.toml` with a Keycard managed resource credential: ```toml [zone] id = "your-zone-id" [[credentials.default]] env_var = "GH_TOKEN" resource = "https://api.github.com" ``` 5. Run your agent in a secure session: ```bash keycard run -- claude ``` ## How it works Keycard handles: - **Just-in-time credentials:** Each time the agent uses a tool or calls an MCP server, Keycard issues a fresh, scoped credential. No secrets stored on disk or in your shell history. - **Agent-native policy enforcement:** Every tool use is evaluated against your organization's policy before it executes. Dangerous operations are blocked automatically. - **Centralized visibility:** All agent activity is logged to your Keycard audit trail. Identify rogue agents, review usage patterns, and track credential access across your team. ## Credential Provisioning Inside a `keycard run` session, credentials are provisioned as your agent needs them. When an agent requests access to a resource (a database, an API, an MCP server), Keycard exchanges a scoped token just-in-time. This means agents only ever hold short-lived, narrowly-scoped credentials. Revoking centrally stops the next credential from being issued, and the one the agent already holds remains valid until it expires, so access ends within the lifetime of the current credential rather than the instant you revoke. [Revoke a Grant](/admin/revoke-a-grant/#what-happens-after-you-revoke) covers the full behavior. ## Policy Enforcement Policies control what your agents can and can't do. When an agent attempts a tool use, Keycard evaluates the action against your policy and either permits or denies it. For example, you can allow `SELECT` queries against a production database but deny `DELETE` or `DROP` operations, so a misunderstood instruction doesn't become an incident. Policies are managed centrally and enforced on all development devices. Adjust a policy once and it takes effect everywhere. ## https://docs.keycard.ai/guides/cloudflare-worker # Deploy an MCP server on Cloudflare Workers Cloudflare Workers give you edge-deployed MCP servers with zero infrastructure management. But Workers have a unique constraint: **isolates are reused across requests**, which means naive module-level caches leak tokens between users. The [`@keycardai/cloudflare`](/sdk/cloudflare/) package handles this. It adapts Keycard's auth primitives to Workers' `fetch(request, env)` model — real JWT verification, OAuth metadata endpoints, and per-user token caching that's isolate-safe by design. This guide walks through deploying a Worker with delegated API access. The full working example is in the [TypeScript SDK](https://github.com/keycardai/typescript-sdk/tree/main/examples/cloudflare-worker). > **Tip:** **Building with an AI agent?** Point it at [docs.keycard.ai/llms.txt](/llms.txt) for the full docs index, or [docs.keycard.ai/guides/cloudflare-worker.md](/guides/cloudflare-worker.md) for this guide. ## How it works ```mermaid flowchart LR A[AI Agent] -->|"Bearer JWT"| B[Your Worker] B -->|"Verify JWT"| C[Keycard JWKS] B -->|"Exchange token"| D[Keycard STS] D -->|"Upstream token"| B B -->|"Bearer upstream"| E[External API] ``` 1. AI agent sends a request with a Keycard JWT 2. Worker verifies the JWT against Keycard's JWKS endpoint 3. Worker exchanges the JWT for an upstream API token via Keycard STS 4. Worker calls the external API with the exchanged token The `createKeycardWorker()` wrapper handles steps 1-2 automatically. You handle 3-4 using `IsolateSafeTokenCache`. ## Prerequisites - **Cloudflare account** with [wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) installed - **Keycard account** with a configured zone and identity provider - Completed the [Access APIs on Behalf of Users](/guides/access-apis-on-behalf-of-users/) guide (for Keycard Console concepts) ## Keycard Console Setup 1. **Register the upstream API** If using the Resource Catalog (GitHub, Google, etc.), add it there. Otherwise create a provider and resource manually for your API. 2. **Register your Worker as a resource** Navigate to **Resources** → **Add Resource** → **Add Manually**: | Field | Value | | --- | --- | | **Resource Name** | My Worker MCP Server | | **Resource Identifier** | `https://your-worker.your-subdomain.workers.dev` | | **Credential Provider** | Zone Provider | After creating the resource, open its **Scopes** page, click **Add scope** (or choose from **Available scopes**), add `mcp:tools`, and click **Save Changes**. 3. **Create the application** Navigate to **Applications** → **Add Application**, give it a name and identifier, and click **Create Application**. Then, on the application details page: - On the **Provides** tab, click **Add provided resource** and select your Worker MCP Server. - On the **Dependencies** tab, click **Add dependency** and select your upstream API resource. Generate **client credentials** and save the Client ID and Client Secret. ## Implementation Install dependencies: ```bash npm install @keycardai/cloudflare @keycardai/oauth @modelcontextprotocol/sdk npm install -D @cloudflare/workers-types typescript wrangler ``` ### Worker Entry Point `createKeycardWorker()` handles OAuth metadata, CORS, and JWT verification. Your handler only runs for authenticated requests: ```typescript // src/index.ts interface Env { KEYCARD_ISSUER: string; KEYCARD_CLIENT_ID?: string; KEYCARD_CLIENT_SECRET?: string; KEYCARD_PRIVATE_KEY?: string; KEYCARD_RESOURCE_URL: string; } // Module-level cache is safe — keyed by user identity, not shared let tokenCache: IsolateSafeTokenCache; function getCache(env: Env) { if (!tokenCache) { const credential = resolveCredential(env); const client = new TokenExchangeClient(env.KEYCARD_ISSUER, credential.getAuth() ?? undefined); tokenCache = new IsolateSafeTokenCache(client, { credential }); } return tokenCache; } export default createKeycardWorker({ resourceName: "My Worker MCP Server", scopesSupported: ["mcp:tools"], requiredScopes: ["mcp:tools"], async fetch(request, env, ctx, auth) { // auth.subject is the verified user identity // auth.token is the raw JWT for token exchange const cache = getCache(env); const upstream = await cache.getToken(auth.subject!, auth.token, env.KEYCARD_RESOURCE_URL); // Use upstream.accessToken to call your API // Register MCP tools, handle routes, etc. // ... }, }); ``` > **Caution:** **Never cache tokens in a plain module-level variable.** Workers reuse isolates across users — a `let cachedToken = ...` leaks user A's token to user B. `IsolateSafeTokenCache` keys by `${subject}::${resource}` to prevent this. ### Wrangler Config Set `KEYCARD_ISSUER` to your **Issuer URL**, found in Keycard Console under **Settings** → **Connection**. ```jsonc // wrangler.jsonc { "name": "my-mcp-worker", "main": "src/index.ts", "compatibility_date": "2025-04-01", "compatibility_flags": ["nodejs_compat"], "vars": { "KEYCARD_ISSUER": "", "KEYCARD_RESOURCE_URL": "https://api.github.com" } } ``` ## Credential Modes You can authenticate your Worker with Keycard using either method: ### Option A: Client Credentials Store the client ID and secret from Keycard Console: ```bash wrangler secret put KEYCARD_CLIENT_ID wrangler secret put KEYCARD_CLIENT_SECRET ``` ### Option B: Web Identity (no client secret) Generate a private key — the Worker serves its public key automatically at `/.well-known/jwks.json`: ```bash openssl genrsa 2048 | wrangler secret put KEYCARD_PRIVATE_KEY ``` Register the Worker's JWKS URL (`https://your-worker.workers.dev/.well-known/jwks.json`) in Keycard Console as the application's public key endpoint. `createKeycardWorker` auto-detects which mode to use from env. ## Deploy and Test 1. **Deploy** ```bash wrangler deploy ``` 2. **Set secrets** (Option A or B from above) 3. **Connect from Cursor/Claude Desktop** Add to your MCP settings: ```json { "mcpServers": { "my-worker": { "url": "https://your-worker.your-subdomain.workers.dev/mcp" } } } ``` 4. **Verify in Audit Logs** Check Keycard Console **Audit Log** for `credentials:issue` events showing the identity chain (user + application). ## Full Example The complete working example — with MCP tool registration, token exchange, and both credential modes — is in the TypeScript SDK: **[examples/cloudflare-worker](https://github.com/keycardai/typescript-sdk/tree/main/examples/cloudflare-worker)** ## Troubleshooting ### 401 on every request - Verify `KEYCARD_ISSUER` matches your zone URL exactly (including `https://`) - Check that your Worker's URL matches the resource identifier in Keycard Console ### Token exchange fails - Verify the upstream API resource is added as a **dependency** on your Application - Check that client credentials (or private key) are set correctly as Worker secrets - Ensure the user completed the OAuth consent flow for the upstream API ### "Missing Keycard credentials in env" - Set either `KEYCARD_CLIENT_ID` + `KEYCARD_CLIENT_SECRET` or `KEYCARD_PRIVATE_KEY` via `wrangler secret put` ## https://docs.keycard.ai/guides/fastmcp-3-migration # Migrate to FastMCP 3.0 FastMCP 3.0 introduces async state management, which affects how your MCP tools interact with the Keycard SDK. This guide covers what changed and how to migrate your existing server. > **Tip:** **Not using FastMCP?** If your server uses the `keycardai-mcp` package (standard MCP SDK integration), this guide does not apply. Only servers using `keycardai-mcp-fastmcp` are affected. ## Who is affected Any MCP server built with: - `keycardai-mcp-fastmcp` (any version) - `fastmcp>=2.x,<3.0.0` ## Can I stay on FastMCP 2.x? Yes, if you pin both dependencies: ```toml # pyproject.toml dependencies = [ "keycardai-mcp-fastmcp==0.19.0", "fastmcp>=2.14.0,<3.0.0", ] ``` Your server keeps working, but `keycardai-mcp-fastmcp` is retired and receives no further releases, including security patches. Pinning buys time; it isn't a supported configuration. ## What changed ### `ctx.get_state()` and `ctx.set_state()` are now async This is the only breaking change that affects Keycard users. In FastMCP 3.0, state access requires `await`: ```python # Before (FastMCP 2.x) access_context = ctx.get_state("keycardai") # After (FastMCP 3.0) access_context = await ctx.get_state("keycardai") ``` This means any tool function that accesses `ctx.get_state("keycardai")` **must** be `async def`. ### The package is now `keycardai-fastmcp` The FastMCP 3.x integration ships as [`keycardai-fastmcp`](https://pypi.org/project/keycardai-fastmcp/). The old `keycardai-mcp-fastmcp` package is retired: 0.21.0 remains on PyPI so existing pins keep resolving, and its `keycardai.mcp.integrations.fastmcp` imports still forward to `keycardai.fastmcp` with a `DeprecationWarning`. Migrating to FastMCP 3.0 is the natural moment to switch. ### What stays the same - All Keycard SDK names (`AuthProvider`, `AccessContext`, `ClientSecret`, etc.); only the import path changes - The `@grant` decorator API - `AuthProvider` configuration (zone_id, mcp_server_name, etc.) - `AccessContext` methods (`.access()`, `.has_errors()`, `.get_errors()`) - `mcp.run(transport="streamable-http")` startup ## Migration steps 1. **Update dependencies** Replace `keycardai-mcp-fastmcp` with `keycardai-fastmcp`: ```toml # pyproject.toml dependencies = [ "fastmcp>=3.1.0", "keycardai-fastmcp", # replaces keycardai-mcp-fastmcp ] ``` Then re-install: ```bash uv sync # or pip install -U fastmcp keycardai-fastmcp ``` 2. **Update imports** ```python # Before from keycardai.mcp.integrations.fastmcp import AccessContext, AuthProvider # After from keycardai.fastmcp import AccessContext, AuthProvider ``` If you keep `keycardai-mcp-fastmcp` 0.21.0 installed, the old path still resolves and emits a `DeprecationWarning`, but 0.21.0 is the final release. 3. **Update tool functions that use `@grant`** Add `await` to every `ctx.get_state("keycardai")` call and ensure the function is `async def`: ```python # Before @mcp.tool() @auth_provider.grant("https://api.example.com") def my_tool(ctx: Context, query: str): access_context = ctx.get_state("keycardai") if access_context.has_errors(): return {"error": access_context.get_errors()} token = access_context.access("https://api.example.com").access_token return call_api(token, query) # After @mcp.tool() @auth_provider.grant("https://api.example.com") async def my_tool(ctx: Context, query: str): access_context = await ctx.get_state("keycardai") if access_context.has_errors(): return {"error": access_context.get_errors()} token = access_context.access("https://api.example.com").access_token return call_api(token, query) ``` 4. **Update chained expressions** If you chain `.access()` directly after `ctx.get_state()`, wrap with parentheses: ```python # Before token = ctx.get_state("keycardai").access("https://api.example.com").access_token # After token = (await ctx.get_state("keycardai")).access("https://api.example.com").access_token # Or assign first (cleaner) access_context = await ctx.get_state("keycardai") token = access_context.access("https://api.example.com").access_token ``` 5. **Update test mocks** If your tests mock `ctx.get_state`, make it async: ```python from unittest.mock import AsyncMock # Before mock_ctx = Mock(spec=Context) mock_ctx.get_state.return_value = mock_access_ctx # After mock_ctx = Mock(spec=Context) mock_ctx.get_state = AsyncMock(return_value=mock_access_ctx) ``` Or use the SDK's built-in test utility: ```python from keycardai.fastmcp.testing import mock_access_context with mock_access_context(resource_tokens={"https://api.example.com": "test_token"}): result = await my_tool(mock_ctx, "test query") ``` 6. **Run and verify** ```bash # Start the server uv run python -m your_server # Or run tests uv run pytest ``` ## Common gotchas **Sync functions that call `ctx.get_state()`** — You cannot `await` inside a `def`. Change it to `async def`. Since most tool functions already make async HTTP calls, this is usually a natural fit. **Forgetting `await`** — If you see `` instead of your `AccessContext`, you forgot the `await`. **`serializable=False`** — If you store custom objects in state with `ctx.set_state()`, FastMCP 3.0 requires JSON-serializable values by default. Pass `serializable=False` for non-serializable objects. The Keycard SDK handles this internally for `AccessContext`, but if you're setting your own state values, you may need to add this parameter. ## FAQ **Will my server break if I don't upgrade?** No, as long as you pin. `keycardai-mcp-fastmcp==0.19.0` with `fastmcp<3.0.0` keeps working. Without a pin, pip resolves 0.21.0, which forwards to `keycardai-fastmcp` and pulls FastMCP 3.1 or later. **Can I run 2.x and 3.x servers side by side?** Yes. Each server is an independent process with its own dependencies. One server can use FastMCP 2.x while another uses 3.x. **Do I need to change anything in Keycard Console?** No. The Keycard configuration (zones, resources, scopes) is unchanged. This migration is purely a code change in your server. ## https://docs.keycard.ai/guides/access-provider-apis/anthropic # Anthropic Your application authenticates to Keycard with [workload identity](/concepts/providers/#workload-identity) and exchanges its token for a Keycard-issued OIDC JWT scoped to Anthropic. The Anthropic SDK uses that JWT to perform [Workload Identity Federation (WIF)](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) and get a short-lived access token. No static API keys anywhere in the chain.
Your App Uses credential with Anthropic
Keycard-minted access token
Claude API Short-lived token, no API keys
Keycard Zone Issues OIDC JWT for Anthropic
Anthropic WIF Validates JWT, returns access token
> **Note:** This guide covers **credential brokering**. Your application calls the Anthropic API with dynamically issued tokens. For authenticating *users* into your zone, see [Identity Providers](/admin/identity-providers/). ## Prerequisites - A [Keycard zone](/concepts/zones/) - An admin role in your Anthropic organization with permission to manage [Workload Identity Federation](https://platform.claude.com/settings/workload-identity-federation) (Organization Owner or Organization Admin) - **Organization ID** (UUID) — found in small print on [Settings → Organization](https://platform.claude.com/settings/organization) ## Keycard setup 1. **Create the Anthropic resource** In [Keycard Console](https://console.keycard.ai), navigate to **Resources**. - Click **Add Resource**, then **Add Manually** - Set the **Resource Identifier** to `https://api.anthropic.com` - Select your **Zone Provider** as the credentials provider — this tells Keycard to issue OIDC tokens signed by the zone itself rather than brokering through an external OAuth flow - Under **Advanced Settings**, set the **Credential Lifetime** to `1h` 2. **Create an application and link the resource** Navigate to **Applications**. - Click **Add Application** - Give it a name (e.g. `anthropic-workload`) - Note the application's **identifier**, the `identifier` field set at creation time, which appears in the `sub` claim of the OIDC token (see [Token Claims](/reference/token-claims/)). You can copy it from the application details in the Console. Then open the application and go to **Dependencies**. - Click **Add Dependency** - Select the `https://api.anthropic.com` resource This authorizes the application to request tokens scoped to the Anthropic API. 3. **Create application credentials (local development)** For local development with `keycard run`, you need a client ID and secret. In production, applications authenticate with [workload identity](/concepts/providers/#workload-identity) instead. Open your application and go to **Application Credentials**. - Click **Add Credential** → **Client ID & Secret** - Note the **Client ID** and **Client Secret** — the secret is only shown once 4. **Note your OIDC Issuer URL** Copy your **Issuer URL** from [Keycard Console](https://console.keycard.ai) under **Settings** → **Connection**. Anthropic needs this as the issuer URL when you register the federation rule in the next section. Keycard serves standard OIDC discovery at your Issuer URL + `/.well-known/openid-configuration` — Anthropic fetches this automatically to discover the JWKS and verify token signatures. ## Anthropic setup 1. **Create a service account** In the [Anthropic Platform Console](https://platform.claude.com/settings/service-accounts), go to **Settings → Service accounts → Create service account**. Give it a name (e.g. `keycard-workload`). Note the service account ID (`svac_...`). 2. **Create a workspace and link the service account** The Default Workspace has no ID and can't be used with WIF. Create a dedicated workspace for your Keycard workloads. Go to **Settings → [Workspaces](https://platform.claude.com/settings/workspaces) → Create workspace**. Give it a name (e.g. `keycard-workloads`). Note the workspace ID (`wrkspc_...`) from the workspaces list. You'll need it in the code examples below. Once created, select the workspace from the dropdown in the top navigation, then go to **Manage → Service accounts → Add service account** and add the service account from step 1. This links it to the workspace and determines which models and rate limits it can use. 3. **Register Keycard as an issuer** In the org-level [Workload Identity Federation](https://platform.claude.com/settings/workload-identity-federation) settings, on the **Issuers** tab, click **Create issuer**. | Field | Value | |---|---| | Name | A label, e.g. `keycard-prod` | | Issuer URL | your Keycard Issuer URL, e.g. `` — must match the `iss` claim in the Keycard-issued JWT exactly | | JWKS source | `discovery` — Keycard serves `.well-known/openid-configuration` publicly | 4. **Create a federation rule** On the **Rules** tab, click **New Rule**. | Section | Value | |---|---| | Issuer | Select the `keycard-prod` issuer from step 3 | | Match → Subject prefix | The Keycard **application identifier** (the `sub` claim in the OIDC token) | | Target | The service account from step 1 | | Workspaces | Select the workspaces this rule can mint tokens for. The service account from step 1 must be a member of each selected workspace, otherwise token exchanges will fail | | Scope | `workspace:developer` (default — grants the same access as an API key) | | Token lifetime | `3600` seconds (default) — adjust based on your security requirements | Note the rule ID (`fdrl_...`). Your workload passes this in every token exchange request. > **Note:** The workspace you select here must match the workspace you linked the service account to in step 2. If the service account isn't a member of the workspace, the federation rule will be created but token exchanges will return an error. ## Use from code Your application does two things at runtime: 1. Gets a Keycard OIDC token scoped to `https://api.anthropic.com` via `client_credentials` with a `resource` parameter 2. Passes that token to the Anthropic SDK, which handles the WIF exchange and refresh automatically > **Note:** All four Keycard SDKs support `client_credentials` with a `resource` parameter natively. The TypeScript sample above calls the token endpoint directly instead of using `ClientCredentialsClient` from `@keycardai/oauth`. **Python:** ```python from keycardai.oauth import Client, BasicAuth from anthropic import Anthropic, WorkloadIdentityCredentials # 1. Get a Keycard OIDC token scoped to Anthropic. with Client( "", # from Settings → Connection auth=BasicAuth("", ""), ) as kc: token = kc.exchange_token( grant_type="client_credentials", resource="https://api.anthropic.com", ) # 2. Use it with the Anthropic SDK — WIF exchange happens automatically. client = Anthropic( credentials=WorkloadIdentityCredentials( identity_token_provider=lambda: token.access_token, federation_rule_id="", organization_id="", service_account_id="", workspace_id="", ), ) message = client.messages.create( model="claude-sonnet-4-6", max_tokens=1024, messages=[{"role": "user", "content": "Hello from a Keycard workload"}], ) print(message.content[0].text) ``` **TypeScript:** ```typescript /** Get a resource-scoped JWT via client_credentials grant. */ async function keycardClientCredentials( tokenUrl: string, clientId: string, clientSecret: string, resource: string, ): Promise { const resp = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`, }, body: new URLSearchParams({ grant_type: "client_credentials", resource }), }); if (!resp.ok) throw new Error(`client_credentials failed: ${await resp.text()}`); const data = (await resp.json()) as { access_token: string }; return data.access_token; } // 1. Get a Keycard OIDC token scoped to Anthropic. const tokenUrl = "/oauth/2/token"; // from Settings → Connection const keycardJwt = await keycardClientCredentials( tokenUrl, "", "", "https://api.anthropic.com", ); // 2. Use it with the Anthropic SDK — WIF exchange happens automatically. const client = new Anthropic({ credentials: oidcFederationProvider({ identityTokenProvider: () => keycardJwt, federationRuleId: "", organizationId: "", serviceAccountId: "", workspaceId: "", baseURL: "https://api.anthropic.com", fetch, }), }); const message = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, messages: [{ role: "user", content: "Hello from a Keycard workload" }], }); console.log(message.content[0].text); ``` **Go:** ```go package main import ( "context" "fmt" "log" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" "github.com/keycardai/go-sdk/oauth" ) func main() { ctx := context.Background() // 1. Get a Keycard OIDC token scoped to Anthropic. kc := oauth.NewClientCredentialsClient( "", // from Settings → Connection oauth.WithCCBasicAuth("", ""), ) keycardToken, err := kc.RequestToken(ctx, oauth.ClientCredentialsRequest{ Resource: "https://api.anthropic.com", }) if err != nil { log.Fatal(err) } // 2. Use it with the Anthropic SDK — WIF exchange happens automatically. client := anthropic.NewClient( option.WithFederationTokenProvider( func(_ context.Context) (string, error) { return keycardToken.AccessToken, nil }, option.FederationOptions{ FederationRuleID: "", OrganizationID: "", ServiceAccountID: "", WorkspaceID: "", }, ), ) message, err := client.Messages.New(ctx, anthropic.MessageNewParams{ Model: "claude-sonnet-4-6", MaxTokens: 1024, Messages: []anthropic.MessageParam{ anthropic.NewUserMessage(anthropic.NewTextBlock("Hello from a Keycard workload")), }, }) if err != nil { log.Fatal(err) } fmt.Println(message.Content[0].Text) } ``` **Ruby:** ```ruby require "anthropic" require "keycardai/oauth" # 1. Get a Keycard OIDC token scoped to Anthropic. kc = Keycardai::OAuth::ClientCredentialsClient.new( issuer: "", # from Settings → Connection client_id: "", client_secret: "", ) keycard_token = kc.request_token(resource: "https://api.anthropic.com") # 2. Use it with the Anthropic SDK. WIF exchange happens automatically. client = Anthropic::Client.new( credentials: Anthropic::Credentials::WorkloadIdentity.new( identity_token_provider: -> { keycard_token.access_token }, federation_rule_id: "", organization_id: "", service_account_id: "", workspace_id: "", ), ) message = client.messages.create( model: "claude-sonnet-4-6", max_tokens: 1024, messages: [{role: "user", content: "Hello from a Keycard workload"}], ) puts message.content[0].text ``` **cURL:** ```bash # 1. Get a Keycard OIDC token scoped to Anthropic. # Replace with your Issuer URL from Settings → Connection. KC_TOKEN=$(curl -sS "/oauth/2/token" \ -u ":" \ -d "grant_type=client_credentials" \ -d "resource=https://api.anthropic.com" \ | jq -r .access_token) # 2. Exchange the Keycard token at Anthropic's WIF endpoint. ANTHROPIC_TOKEN=$(curl -sS https://api.anthropic.com/v1/oauth/token \ -H "content-type: application/json" \ -d "{ \"grant_type\": \"urn:ietf:params:oauth:grant-type:jwt-bearer\", \"assertion\": \"${KC_TOKEN}\", \"federation_rule_id\": \"\", \"organization_id\": \"\", \"service_account_id\": \"\", \"workspace_id\": \"\" }" | jq -r .access_token) # 3. Call the API. curl -sS https://api.anthropic.com/v1/messages \ -H "authorization: Bearer ${ANTHROPIC_TOKEN}" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -d '{ "model": "claude-sonnet-4-6", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello from a Keycard workload"}] }' | jq -r '.content[0].text' ``` ## Verify **In Keycard Console** — open **Audit Log**. You should see: | | | | ---------------------- | -------------------------------------------- | | `credentials:issue` | OIDC token issued for `https://api.anthropic.com` | **In Anthropic Platform Console** — go to **Settings → Workload identity → Authentication events**. You should see the exchange attempt with your zone's issuer URL and the matched federation rule. ## Related - [Providers (concepts)](/concepts/providers/) — the trust model behind identity and access federation - [Access policies](/admin/access-policies/) — control which applications can access the Anthropic resource - [Run apps without static secrets](/guides/run-apps-without-static-secrets/) — deploy with workload identity on Fly.io - [Anthropic WIF documentation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) — Anthropic's setup reference ## https://docs.keycard.ai/guides/access-provider-apis/overview # Access Provider APIs Keycard issues short-lived, scoped credentials that external providers accept via Workload Identity Federation (WIF). Your application authenticates to Keycard, requests a token for a specific provider, and uses that token directly with the provider's API. No API keys stored anywhere.
Your App Uses credential with provider
Keycard-minted access token
Provider API Short-lived token, no secrets stored
Keycard Zone Issues scoped credential
Provider WIF Validates JWT, returns access token
## How it works 1. **Register the external API as a resource** and select your zone provider as the credentials issuer 2. **Configure the external provider** to trust your zone's OIDC issuer — each provider guide walks through this 3. **Your application authenticates** to Keycard and requests a token scoped to the external resource 4. **Keycard issues a short-lived OIDC JWT** signed by your zone, which the provider validates and exchanges for an access token For the conceptual model behind access federation and brokered credentials, see [Providers](/concepts/providers/#access-federation). ## Provider guides - [Anthropic](/guides/access-provider-apis/anthropic/) ## https://docs.keycard.ai/guides/access-snowflake # Access Snowflake In this tutorial, you'll set up end-to-end access control for Snowflake using Keycard. Your AI agents will query Snowflake through a service account, but Keycard governs _who_ can access the Snowflake MCP based on Okta group membership, even if those users don't have direct Snowflake accounts. ## Architecture
User / Agent
MCP token
MCP Server
Snowflake token
Snowflake
Keycard Auth via Okta
Keycard Token exchange
Snowflake has a single service user with multiple roles. Keycard acts as the policy layer: it grants different scopes based on Okta group membership, which determines what the user can do in Snowflake. Users don't need individual Snowflake accounts; Keycard decides who gets through and with what permissions. ## What you'll build By the end of this tutorial, you'll have: - Okta configured as an identity provider with a `data-analysts` group - Keycard policies that grant write access to analysts; everyone else gets read-only - Snowflake configured with read-only and read-write roles, accessed via a single service user - An MCP server that connects AI agents to Snowflake with permissions matching their Okta groups ## Prerequisites This tutorial uses a [custom zone](/concepts/zones/): a separate security domain with its own Okta identity provider and user pool, distinct from your organization. Create one in Keycard Console under **Zones** before you begin. Before starting, you'll need: - A [Keycard account](https://console.keycard.ai) with a custom zone. Open the zone from **Zones**, then go to **Settings** → **Connection** to find your zone ID (you'll need this throughout the tutorial) - An Okta account with admin access, or [sign up for the free Integrator plan](https://developer.okta.com/signup/) - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed - A [Snowflake account](https://signup.snowflake.com/) with `ACCOUNTADMIN` role (required for creating security integrations) - [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed - Clone the tutorial repository: ```bash git clone https://github.com/keycardai/tutorial-snowflake-mcp.git cd tutorial-snowflake-mcp cp .env.example .env ``` ### Find your Snowflake account URL To find your Snowflake account URL: 1. Sign in to [Snowflake](https://app.snowflake.com/) 2. Click your account name in the bottom-left corner 3. Hover over your account and click the **link icon** to copy the account URL The URL looks like `https://abc12345.us-east-1.snowflakecomputing.com`. ### Prepare your environment file Open `.env` in your editor. You'll fill in values as you progress through the tutorial, but start by adding these now: ```bash # Keycard zone (open the zone from Zones, then Settings → Connection) KEYCARD_ZONE_URL=https://.keycard.cloud # Snowflake connection SNOWFLAKE_ACCOUNT_URL= SNOWFLAKE_DATABASE=GREENDALE SNOWFLAKE_WAREHOUSE=COMPUTE_WH ``` The remaining values (`KEYCARD_CLIENT_ID` and `KEYCARD_CLIENT_SECRET`) will be added in Part 2 when you create the application. ## Part 1: Configure Okta as an Identity Provider First, we'll connect Okta to Keycard so users can authenticate with their existing corporate credentials. 1. **Create an OIDC application in Okta** In Okta Admin Console, navigate to **Applications > Applications** and click **Create App Integration**. Select **OIDC - OpenID Connect** and **Web Application**, then click **Next**. Configure the application: | Field | Value | | --- | --- | | **App integration name** | Keycard | | **Grant type** | Authorization Code, Refresh Token | | **Sign-in redirect URI** | `https://.keycard.cloud/oauth/2/redirect` | | **Sign-out redirect URI** | `https://.keycard.cloud/openid/connect/redirect/logout` | | **Controlled access** | Allow everyone in your organization to access | > **Tip:** Find your Redirect URL in Keycard Console: open the zone from **Zones**, then **Settings** → **Connection**. It is shown there as **Redirect URL**. Click **Save**. > **Caution:** **Copy the Client ID and Client Secret now.** You'll need these in step 4 when adding Okta as a provider in Keycard. 2. **Configure Okta to send group claims** While still on the **Sign On** tab, scroll to **Advanced Settings** and expand **advanced options**. Under **Group Claims**, click **Edit** and configure: | Field | Value | | --- | --- | | **Groups claim type** | Filter | | **Groups claim filter** | Name: `groups`, Filter: **Matches regex**, Value: `.*` | This sends all group memberships in the ID token. For production, narrow the regex to only the groups Keycard needs. 3. **Create an Okta group for analysts** In Okta Admin Console, navigate to **Directory > Groups** and create a group: | Group Name | Description | | --- | --- | | `data-analysts` | Full read-write access to Snowflake data | Add users who need write access to this group. Users _not_ in `data-analysts` automatically get read-only access (no separate group needed). 4. **Add Okta as a provider in Keycard** In [Keycard Console](https://console.keycard.ai), navigate to **Providers** and click **Add Provider**. | Field | Value | | --- | --- | | **Name** | Okta | | **Issuer URL** | `https://` (e.g., `https://.okta.com`) | | **Client ID** | From step 1 | | **Client Secret** | From step 1 | Click **Create Provider**. After creating the provider, click into its settings. Under **Advanced Settings**, add `groups` to **Additional Scopes**. This ensures Keycard requests group claims from Okta during authentication. 5. **Enable Okta for zone sign-in** In Keycard Console, go to **Zones**, open the **⋯** menu on your zone's card, and select **Settings**. On the **Settings** tab, find **Zone sign in configuration**, open the **Identity Provider** dropdown, and select **Okta**. Click **Save Changes**. Users can now sign in to your Keycard zone using their Okta credentials. ## Part 2: Configure Keycard Resources and Application Now we'll register everything in Keycard: the Snowflake API as a resource, the MCP server as a resource, and an application that ties them together. 1. **Create the Snowflake API resource** In [Keycard Console](https://console.keycard.ai), navigate to **Resources** and click **Add Resource > Add Manually**. | Field | Value | | --- | --- | | **Resource Name** | Snowflake API | | **Resource Identifier** | `` | | **Credential Provider** | Zone Provider | This resource represents the Snowflake API that the MCP server will call on behalf of users. 2. **Create the Snowflake MCP resource** Click **Add Resource > Add Manually** again, this time to register the MCP server that users will authenticate against. | Field | Value | | --- | --- | | **Resource Name** | Snowflake MCP | | **Resource Identifier** | `http://localhost:3100/` | | **Credential Provider** | Zone Provider | > **Tip:** These two resources serve different purposes: - **Snowflake API** is the downstream resource the MCP server calls on behalf of users - **Snowflake MCP** is the resource users authenticate against to use the MCP server Users request a token for the MCP resource, then the MCP server exchanges that for a Snowflake API token. > **Note:** The Resource Identifier must match where your MCP server runs. For local development, use `http://localhost:3100/`. Update this when deploying to production. 3. **Create the Snowflake MCP application** Navigate to **Applications** and click **Add Application > Add Manually**. | Field | Value | | --- | --- | | **Name** | Snowflake MCP | | **Identifier** | `http://localhost:3100/` | Click **Create Application**. 4. **Configure the application resources** On the application details page: Under **Provides**, click **Add provided resource** and select **Snowflake MCP** (the resource your application exposes). Under **Dependencies**, click **Add dependency** and select **Snowflake API** (the resource the application accesses on behalf of users). > **Tip:** **Provided vs Dependency**: A *provided resource* is what your application exposes to users (the MCP server's API). A *dependency* is what your application needs to call on behalf of users (Snowflake). Together, they enable token exchange: when users authenticate to your MCP server, it can exchange their token for a Snowflake token. 5. **Generate client credentials** On the application details page, go to the **Application Credentials** tab and click **Add Credential**. In the modal, select **Client ID & Secret**. > **Caution:** **Copy both values now and add them to your `.env` file.** This is the only time you'll see the Client Secret. If you lose it, you'll need to generate new credentials. - `KEYCARD_CLIENT_ID` - `KEYCARD_CLIENT_SECRET` > **Note:** Client credentials are for local development. For production deployments to cloud platforms, you'll use workload identity federation instead. 6. **Create an access policy** Navigate to **Policies > All Policies** and click **Create Policy**. Name it **Snowflake MCP**, then switch to the **Cedar** tab and add the following policy: ```cedar // Allow any user to access the MCP server permit ( principal is Keycard::User, action, resource ) when { (resource has identifier) && ((resource.identifier) == "http://localhost:3100/") }; // Allow any user to access Snowflake, unless requesting READWRITE_ROLE permit ( principal is Keycard::User, action, resource ) when { (((resource has identifier) && (context has scopes)) && ((resource.identifier) == "")) && (!((context.scopes).containsAny(["session:role:READWRITE_ROLE"]))) }; // Allow data-analysts group members to access Snowflake with READWRITE_ROLE permit ( principal is Keycard::User, action, resource ) when { ((((((resource has identifier) && (context has scopes)) && (context has subject_claims)) && ((context.subject_claims) has groups)) && ((resource.identifier) == "")) && ((context.scopes).containsAny(["session:role:READWRITE_ROLE"]))) && (((context.subject_claims).groups).contains("data-analysts")) }; ``` Click **Validate** to check the policy syntax, then click **Publish Policy** to save it. 7. **Create and activate a policy set** Go back to **Policies** (this lands you on **Policy Sets**) and click **New Policy Set**. Name it **Snowflake Access** and add two policies: - **default-app-delegation** (the preconfigured policy that enables token exchange) - **Snowflake MCP** (the policy you just created) Click **Publish as Candidate** to create a version. This lands you on the candidate page where you can click **Activate** to make it live. > **Note:** The `default-app-delegation` policy allows applications to request tokens on behalf of users. The `Snowflake MCP` policy controls which users can access which resources and roles. ## Part 3: Configure Snowflake for Keycard JWTs Snowflake needs to trust Keycard as an OAuth authorization server. We'll create a single service user for the MCP server. Keycard handles user identity and access control, so individual Snowflake accounts aren't needed. > **Note:** Snowflake users, roles, databases, and security integrations are account-level objects. If these have already been created (e.g., by another workshop participant), the `CREATE` statements will error. Skip steps that were already completed. 1. **Create the MCP service user** In [Snowflake](https://app.snowflake.com/), click **+ Create** in the top-left and select **SQL File**. In the top-right of the worksheet, select `ACCOUNTADMIN` as your role and `COMPUTE_WH` as your warehouse. Run the following SQL to create a service user and two roles for the MCP server. Replace `` with your application **identifier** (the `identifier` field set at creation time, which appears in the token `keycard_app_id` claim): ```sql BEGIN -- Create roles with different permission levels CREATE ROLE READONLY_ROLE; CREATE ROLE READWRITE_ROLE; -- Create service user and grant both roles CREATE USER MCP_SERVICE_USER TYPE = SERVICE LOGIN_NAME = '' DEFAULT_ROLE = READONLY_ROLE COMMENT = 'Service account for Keycard MCP server'; GRANT ROLE READONLY_ROLE TO USER MCP_SERVICE_USER; GRANT ROLE READWRITE_ROLE TO USER MCP_SERVICE_USER; END; ``` > **Tip:** The service user defaults to `READONLY_ROLE` for safety. When an analyst needs to write data, the MCP server specifies `READWRITE_ROLE` at connection time, but only if their token includes that scope. Users not in `data-analysts` can't escalate since their token only has `READONLY_ROLE`. 2. **Create an OAuth security integration** Configure Snowflake to accept Keycard-issued JWTs: ```sql CREATE SECURITY INTEGRATION keycard_oauth TYPE = EXTERNAL_OAUTH ENABLED = TRUE EXTERNAL_OAUTH_TYPE = CUSTOM EXTERNAL_OAUTH_ISSUER = 'https://.keycard.cloud' EXTERNAL_OAUTH_JWS_KEYS_URL = 'https://.keycard.cloud/openidconnect/jwks' EXTERNAL_OAUTH_AUDIENCE_LIST = ('') EXTERNAL_OAUTH_TOKEN_USER_MAPPING_CLAIM = 'keycard_app_id' EXTERNAL_OAUTH_SNOWFLAKE_USER_MAPPING_ATTRIBUTE = 'LOGIN_NAME' EXTERNAL_OAUTH_SCOPE_MAPPING_ATTRIBUTE = 'scope' EXTERNAL_OAUTH_SCOPE_DELIMITER = ' ' EXTERNAL_OAUTH_ANY_ROLE_MODE = 'ENABLE'; ``` > **Note:** The `keycard_app_id` claim is the MCP application's identifier and is present whether the MCP server calls on its own behalf or exchanges a user's token. Snowflake maps this to the service user's `LOGIN_NAME`, so `LOGIN_NAME` must equal the application identifier. The `scope` claim contains role authorizations like `session:role:READWRITE_ROLE` or `session:role:READONLY_ROLE`. See [Token Claims](/reference/token-claims/) for the full claim reference. 3. **Seed sample data** Create a database, schema, and sample data: ```sql BEGIN CREATE DATABASE GREENDALE; CREATE SCHEMA GREENDALE.ENROLLMENT; USE DATABASE GREENDALE; USE SCHEMA ENROLLMENT; CREATE TABLE STUDENTS ( ID INT AUTOINCREMENT PRIMARY KEY, NAME VARCHAR(100), EMAIL VARCHAR(100), MAJOR VARCHAR(50), GPA DECIMAL(3,2), ENROLLED_DATE DATE ); INSERT INTO STUDENTS (NAME, EMAIL, MAJOR, GPA, ENROLLED_DATE) VALUES ('Troy Barnes', 'troy.barnes@greendale.edu', 'Air Conditioning Repair', 3.2, '2009-09-01'), ('Abed Nadir', 'abed.nadir@greendale.edu', 'Film Studies', 3.8, '2009-09-01'), ('Jeff Winger', 'jeff.winger@greendale.edu', 'Undeclared', 2.9, '2009-09-01'), ('Britta Perry', 'britta.perry@greendale.edu', 'Psychology', 3.1, '2009-09-01'), ('Annie Edison', 'annie.edison@greendale.edu', 'Healthcare Administration', 4.0, '2009-09-01'), ('Shirley Bennett', 'shirley.bennett@greendale.edu', 'Business', 3.5, '2009-09-01'), ('Pierce Hawthorne', 'pierce.hawthorne@greendale.edu', 'Undeclared', 2.1, '2009-09-01'); -- READONLY_ROLE: read-only access GRANT USAGE ON WAREHOUSE COMPUTE_WH TO ROLE READONLY_ROLE; GRANT USAGE ON DATABASE GREENDALE TO ROLE READONLY_ROLE; GRANT USAGE ON SCHEMA GREENDALE.ENROLLMENT TO ROLE READONLY_ROLE; GRANT SELECT ON ALL TABLES IN SCHEMA GREENDALE.ENROLLMENT TO ROLE READONLY_ROLE; -- READWRITE_ROLE: read-write access GRANT USAGE ON WAREHOUSE COMPUTE_WH TO ROLE READWRITE_ROLE; GRANT USAGE ON DATABASE GREENDALE TO ROLE READWRITE_ROLE; GRANT USAGE ON SCHEMA GREENDALE.ENROLLMENT TO ROLE READWRITE_ROLE; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA GREENDALE.ENROLLMENT TO ROLE READWRITE_ROLE; END; ``` ## Part 4: Run the Snowflake MCP Server In the `tutorial-snowflake-mcp` directory you cloned during prerequisites, run the pre-built MCP server. The server uses the MCP service account you created, but Keycard validates each user's identity and group membership before allowing access. 1. **Run with Docker** ```bash docker compose up ``` The MCP server is now running at `http://localhost:3100/mcp`. 2. **Add the MCP server to Claude Code** In a new terminal, add the Snowflake MCP server to Claude Code: ```bash claude mcp add --transport http snowflake-mcp http://localhost:3100/mcp ``` 3. **Authenticate with the MCP server** Start Claude Code: ```bash claude ``` Type `/mcp` and select the **snowflake-mcp** server. This will open your browser and take you through the Keycard authentication flow (backed by Okta). 4. **Test read-only access** Sign in as a user who is _not_ in the `data-analysts` group. Ask Claude to read data: ``` List all students at Greendale ``` This should succeed: the user has `READONLY_ROLE` access. Now ask Claude to write data: ``` Add a new student named Ben Chang with email ben.chang@greendale.edu, major Spanish, GPA 2.5 ``` This should fail: `READONLY_ROLE` doesn't have INSERT permissions. 5. **Test read-write access** Add yourself to the `data-analysts` group in Okta, then sign out of Keycard to refresh your session: ``` https://.keycard.cloud/logout ``` In Claude Code, re-authenticate by typing `/mcp`, selecting **snowflake-mcp**, and completing the login flow again. Ask Claude to write data: ``` Add a new student named Ben Chang with email ben.chang@greendale.edu, major Spanish, GPA 2.5 ``` This should succeed: the user has `READWRITE_ROLE` access with INSERT permissions. Verify the insert worked: ``` Show me all students including Ben Chang ``` ## Next steps You now have Snowflake connected through Keycard with Okta-based identity and group-based policies. From here, you can: - Deploy to higher environments with [workload identity federation](/concepts/credentials/#workload-identity) to eliminate client secrets - Create per-operation roles (e.g., `INSERT_ROLE`, `UPDATE_ROLE`, `DELETE_ROLE`) for even more granular access control - Add more granular policies (e.g., restrict specific warehouses or databases) - Expand the MCP server with additional tools (schema inspection, write operations) - Set up [audit log export](/admin/audit-log-export/) to track all Snowflake access - Configure additional identity providers alongside Okta ## https://docs.keycard.ai/guides/act-on-behalf-of-absent-users # Act on Behalf of Absent Users Build a background agent that acts as a specific user *without that user being present*. The user grants access once through a landing page. After that, the agent mints a short-lived token scoped to that user on every request, with no browser, no session, and nothing stored. This is [impersonation](/concepts/credentials/#impersonation): the agent authenticates as *itself*, names the *user* it acts for, and exchanges the two for a credential. > **Note: Is the user present?** This guide is for the case where the user is **not** present — scheduled jobs, queue workers, long-running agents. If the user is in the loop and you have their token on the request, use [Access APIs on Behalf of Users](/guides/access-apis-on-behalf-of-users/) (CLI) or [Call External APIs from MCP](/guides/delegated-access/) (SDK) instead, which forward the live user token.
One-Time Consent User authorizes via a landing page, once
Background Agent Runs non-interactively, no user present
Per-Request Tokens Scoped, short-lived, never stored
Audit Trail Actor and delegator both logged
## Prerequisites - **Keycard**. You need your Keycard **Issuer URL** (found under **Settings** → **Connection**, e.g. ``). - **A Provider and Resource**. The Resource you want the agent to reach (e.g. a GitHub-backed Resource at `https://api.github.com`), linked to its OAuth Provider in [Keycard Console](https://console.keycard.ai). See [Call External APIs from MCP](/guides/delegated-access/#keycard-setup) for adding an API from the catalog. - **Two Applications** (created in the walkthrough): a public **landing page** for the one-time consent, and a confidential **background agent** that does the impersonation. - **Python 3.10+**, **Node.js 18+**, **Go 1.22+**, or **Ruby 3.2+**, depending on which SDK you use. ## Walkthrough 1. **Install the SDK** **Python:** ```bash pip install keycardai-oauth ``` **TypeScript:** ```bash npm install @keycardai/oauth ``` **Go:** ```bash go get github.com/keycardai/go-sdk/oauth ``` **Ruby:** ```bash bundle add keycardai-oauth ``` 2. **Provision the two Applications in Keycard Console** You set up two Applications. With the default policies, the agent can impersonate a user for any Resource it declares as a **dependency** once that user has delegated it — no policy is needed to *enable* impersonation. Add a policy only to *restrict* it. | Step | What to create | | --- | --- | | **Landing page app** | A **public** credential (identifier e.g. `landing-page`), redirect URI `http://localhost:3000/callback`. Add the Resource as a **dependency**. | | **Background agent app** | A **confidential** credential (identifier e.g. `background-agent`) with a client secret. Add the same Resource as a **dependency**, and set its [consent](/concepts/applications/#configuration) to `implicit` — no user is present to approve a consent screen during the exchange. | | **Restriction policy** *(optional)* | A [forbid policy](/admin/access-policies/) if you want to limit impersonation — e.g. allow it only for specific users. Without one, the agent can impersonate any user who has delegated a Resource it depends on. | To restrict `background-agent` so it may impersonate only one user (Cedar `forbid` overrides the default permit): ```text forbid ( principal is Keycard::Application, action, resource ) when { principal.identifier == "background-agent" && context has impersonate && context.impersonate == true } unless { context has subject && context.subject.identifier == "troy.barnes@greendale.edu" }; ``` Put the agent's client ID and secret in the environment as `KEYCARD_CLIENT_ID` and `KEYCARD_CLIENT_SECRET`. Never commit them. 3. **Build the landing page for one-time consent** The landing page runs the standard authorization-code-with-PKCE flow. The user signs in once and authorizes the Resources configured as the app's dependencies. That establishes the consent grant the agent relies on later — there is no token to keep here. **Python:** ```python # landing_page.py — the two calls that matter from keycardai.oauth import Client, build_authorize_url from keycardai.oauth.http.auth import NoneAuth from keycardai.oauth.types.models import ClientConfig from keycardai.oauth.utils.pkce import PKCEGenerator client = Client( base_url="", # from Settings → Connection auth=NoneAuth(), # public client, PKCE only config=ClientConfig(enable_metadata_discovery=True, auto_register_client=False), ) # On GET /authorize: redirect the user to Keycard pkce = PKCEGenerator().generate_pkce_pair() url = build_authorize_url( client.endpoints.authorize, client_id="", redirect_uri="http://localhost:3000/callback", pkce=pkce, resources=[], # dependencies determine what gets authorized scope="openid email", state=state, # store state -> pkce.code_verifier ) # On GET /callback: trade the code for tokens (this records the grant) token_response = client.exchange_authorization_code( code=code, redirect_uri="http://localhost:3000/callback", code_verifier=code_verifier, client_id="", ) ``` **TypeScript:** ```typescript // landing-page.ts — the two calls that matter import { fetchAuthorizationServerMetadata, generatePkcePair, buildAuthorizeUrl, exchangeAuthorizationCode, } from "@keycardai/oauth"; const issuer = ""; // from Settings → Connection const metadata = await fetchAuthorizationServerMetadata(issuer); // On GET /authorize: redirect the user to Keycard const pkce = await generatePkcePair(); const url = buildAuthorizeUrl(metadata.authorization_endpoint!, { clientId: "", redirectUri: "http://localhost:3000/callback", codeChallenge: pkce.codeChallenge, scope: "openid email", state, // store state -> pkce.codeVerifier }); // On GET /callback: trade the code for tokens (this records the grant) const tokenResponse = await exchangeAuthorizationCode(issuer, code, { codeVerifier, redirectUri: "http://localhost:3000/callback", clientId: "", }); ``` **Go:** ```go // landing_page.go — the two calls that matter import "github.com/keycardai/go-sdk/oauth" issuer := "" // from Settings → Connection metadata, err := oauth.FetchAuthorizationServerMetadata(ctx, issuer) // On GET /authorize: redirect the user to Keycard pkce, err := oauth.GeneratePKCEPair() url, err := oauth.BuildAuthorizeURL(metadata.AuthorizationEndpoint, oauth.AuthorizeURLParams{ ClientID: "", RedirectURI: "http://localhost:3000/callback", CodeChallenge: pkce.CodeChallenge, Scopes: []string{"openid", "email"}, State: state, // store state -> pkce.CodeVerifier }) // On GET /callback: trade the code for tokens (this records the grant) tokenResponse, err := oauth.ExchangeAuthorizationCode(ctx, issuer, oauth.AuthorizationCodeExchangeRequest{ Code: code, CodeVerifier: codeVerifier, RedirectURI: "http://localhost:3000/callback", ClientID: "", }) ``` **Ruby:** ```ruby # landing_page.rb: the two calls that matter require "keycardai/oauth" issuer = "" # from Settings → Connection metadata = Keycardai::OAuth.fetch_authorization_server_metadata(issuer) # On GET /authorize: redirect the user to Keycard pkce = Keycardai::OAuth::PKCE.generate_pair url = Keycardai::OAuth.build_authorize_url( metadata.authorization_endpoint, client_id: "", redirect_uri: "http://localhost:3000/callback", code_challenge: pkce.code_challenge, scope: "openid email", state: state, # store state -> pkce.code_verifier ) # On GET /callback: trade the code for tokens (this records the grant) token_response = Keycardai::OAuth.exchange_authorization_code( issuer, code: code, code_verifier: code_verifier, redirect_uri: "http://localhost:3000/callback", client_id: "", ) ``` > **Tip:** A complete, runnable landing page (HTML, PKCE store, callback handling) ships as the [`impersonation_token_exchange` example](https://github.com/keycardai/python-sdk/tree/main/packages/oauth/examples/impersonation_token_exchange) in the Python SDK. Any standard OAuth 2.1 PKCE web flow works. 4. **Have the user authorize, once** Open the landing page, sign in, and approve access. You may see a consent screen from the Provider that establishes trust between Keycard and the upstream API. After this, the user can close the tab — the agent never needs them again unless a *new* Resource has to be authorized. 5. **Get the user's identifier from Keycard Console** In Keycard Console, find the user under **Users** and copy their [identifier](/concepts/users/#identifier) (often an email or `sub`, depending on how the Provider maps claims). This is the value the agent names when it impersonates. 6. **Impersonate the user from the background agent** The agent authenticates with its own confidential credential and calls `impersonate()` with the user identifier and target Resource. The SDK builds the substitute-user token and runs the [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange for you. **Python:** ```python import os from keycardai.oauth import Client from keycardai.oauth.http.auth import BasicAuth from keycardai.oauth.types.models import ClientConfig with Client( base_url="", # from Settings → Connection auth=BasicAuth( os.environ["KEYCARD_CLIENT_ID"], os.environ["KEYCARD_CLIENT_SECRET"], ), config=ClientConfig(enable_metadata_discovery=True, auto_register_client=False), ) as client: response = client.impersonate( user_identifier="troy.barnes@greendale.edu", resource="https://api.github.com", ) token = response.access_token # short-lived, scoped to the user ``` **TypeScript:** ```typescript import { TokenExchangeClient } from "@keycardai/oauth"; const client = new TokenExchangeClient("", { // from Settings → Connection clientId: process.env.KEYCARD_CLIENT_ID!, clientSecret: process.env.KEYCARD_CLIENT_SECRET!, }); const response = await client.impersonate({ userIdentifier: "troy.barnes@greendale.edu", resource: "https://api.github.com", }); const token = response.accessToken; // short-lived, scoped to the user ``` **Go:** ```go import ( "context" "log" "os" "github.com/keycardai/go-sdk/oauth" ) client := oauth.NewTokenExchangeClient( "", // from Settings → Connection oauth.WithClientCredentials( os.Getenv("KEYCARD_CLIENT_ID"), os.Getenv("KEYCARD_CLIENT_SECRET"), ), ) response, err := client.Impersonate(context.Background(), oauth.ImpersonateRequest{ UserIdentifier: "troy.barnes@greendale.edu", Resource: "https://api.github.com", }) if err != nil { log.Fatal(err) } token := response.AccessToken // short-lived, scoped to the user ``` **Ruby:** ```ruby require "keycardai/oauth" client = Keycardai::OAuth::TokenExchangeClient.new( issuer: "", # from Settings → Connection client_id: ENV.fetch("KEYCARD_CLIENT_ID"), client_secret: ENV.fetch("KEYCARD_CLIENT_SECRET"), ) response = client.impersonate( user_identifier: "troy.barnes@greendale.edu", resource: "https://api.github.com", ) token = response.access_token # short-lived, scoped to the user ``` 7. **Call the upstream API with the token** The credential acts as the user against the Resource — for the brokered GitHub Resource here, that's GitHub's own token for that user. Use it like any bearer token: ```bash curl -H "Authorization: Bearer $TOKEN" https://api.github.com/user ``` Mint a fresh token per request — they are short-lived and meant to be thrown away, not cached across jobs. 8. **Verify in Keycard Console** Open [Keycard Console](https://console.keycard.ai) → **Audit Log**. The impersonated issuance is recorded with the **Application as the actor** and the **impersonated user as its delegator**, so access traces back to both: | | | | ---------------------- | ------------------------------------------------- | | `users:authorize` | The one-time grant from the landing page | | `credentials:issue` | Each token the agent minted on the user's behalf | ## Errors | Error | Cause | | --- | --- | | `invalid_client` | The caller is a public client or failed client authentication. | | `invalid_request` | The substitute-user token is malformed — it is signed (it must be unsigned), has the wrong `typ` or `alg`, or is missing `sub`. The SDK builds it correctly, so this usually means a hand-rolled request. | | `invalid_grant` | The `sub` identifier does not resolve to a known user. | | `invalid_target` | The `resource` is not a known, configured Resource. | | `interaction_required` | No prior delegation exists for the Resource; the user must authorize through the landing page first. | | `access_denied` | Access policy denied the exchange — the Resource is not one of the agent's dependencies, or a policy forbids impersonation. | ## Authenticate the agent without a static secret This guide uses a client secret to keep the walkthrough short, but a shared secret is the weakest of the [Application credentials](/concepts/applications/#credentials) — and impersonation only requires a *confidential* client, not a secret specifically. Swap it for a credential that nothing has to store; the `impersonate()` call stays the same. - **Workload Identity (WI)** — if the agent runs on AWS, Google Cloud, Vercel, or similar, it authenticates with the platform's [workload identity](/concepts/applications/#workload-identity) token instead of a secret. [Run Apps Without Static Secrets](/guides/run-apps-without-static-secrets/) walks through deploying an app this way. - **URL credential (CIMD)** — the agent proves control of a URL with a signed, key-based assertion, and Keycard fetches its public key from the agent's [OAuth Client ID Metadata Document](/concepts/applications/#url). No shared secret lives anywhere. [Grant Agent Access to APIs](/guides/grant-agent-access-to-apis/) shows an agent bootstrapping this identity with the SDK's `WebIdentity` helper. ## What's next - **[Credential Issuance](/concepts/credentials/#impersonation)**: the full impersonation model and how it relates to delegation and autonomous access. - **[Grant Agent Access to APIs](/guides/grant-agent-access-to-apis/)**: build an agent that acts as *itself* rather than as a user. ## Troubleshooting
The exchange returns `access_denied` Access policy denied the exchange. With the default policies this happens when the Resource is **not** one of the agent Application's dependencies, or when a policy explicitly forbids impersonation. - Confirm the Resource is listed as a dependency of the agent Application. - If you've added a restriction policy, confirm it isn't forbidding this Application or subject — and that its `principal.identifier` matches the agent's identifier exactly.
The exchange returns `interaction_required` No prior grant exists for the user and Resource. - The user must complete the landing-page flow for that Resource at least once. - Adding a *new* Resource dependency requires the user to authorize again — the existing grant doesn't cover it.
The exchange returns `invalid_client` The agent failed client authentication, or you're using a public client. - Impersonation requires a **confidential** client. Verify `KEYCARD_CLIENT_ID` / `KEYCARD_CLIENT_SECRET` are set and correct. - Confirm the agent Application has a client secret (or workload identity / URL credential), not a public credential.
The exchange returns `invalid_grant` The user identifier doesn't resolve to a known user. - Copy the exact [identifier](/concepts/users/#identifier) from Keycard Console → **Users**. - The identifier can be changed in Console; make sure your code uses the current value.
## https://docs.keycard.ai/guides/connect-claude-to-resources # Connect Claude to Resources Give a coding agent access to the services it needs without pasting secrets into config files. This guide covers the two ways Keycard distributes credentials: **static** secrets (using Datadog as the example) and **OIDC / OAuth** credentials (using Linear). Both flow into a Claude Code session through the same mechanism. By the end you'll have a project that: - Hydrates environment variables from Keycard-managed Resources - Runs Claude Code inside a Keycard session - Boots MCP servers already authenticated with Keycard-issued credentials ## Prerequisites - macOS or Linux - [Claude Code](https://claude.com/claude-code) installed - The Keycard CLI installed: ```bash brew install keycardai/tap/keycard ``` - Access to a Keycard Org and a Zone that already has the Resources you want to use (this guide assumes Datadog and Linear Resources have been configured by an admin; see [Configure Resources](#configure-resources)). > **Note:** This guide picks up after account setup. If you haven't authenticated yet, run `keycard init` first to sign in and generate your `policy.cedar`. ## Set up the project directory Create (or open) a directory that holds two files: a `keycard.toml` and an `mcp.json`. ``` my-project/ ├── keycard.toml └── mcp.json ``` ### keycard.toml `keycard.toml` tells Keycard which **Org** and **Zone** to use, and maps local environment variables to the Keycard **Resources** that hydrate them. - **Org**: your Keycard Organization. - **[Keycard Zone](/concepts/zones/)**: a boundary around a group of Resources and configuration for a particular use case. All the Resources in this guide live in one Zone. - **Credentials**: a map between local environment variables and the Resources that get exchanged to fill them in. ```toml [org] id = "" [zone] id = "" # Datadog: distributed as static secrets from a Keycard Vault. [[credentials.default]] env_var = "DD_API_KEY" resource = "" [[credentials.default]] env_var = "DD_APP_KEY" resource = "" [[credentials.default]] env_var = "DD_SITE" resource = "" # Linear: an OIDC credential minted from an external OAuth Provider. [[credentials.default]] env_var = "LINEAR_API_KEY" resource = "" ``` Each `[[credentials.default]]` entry needs an `env_var` and a `resource`. The `default` set is the one `keycard run` hydrates. > **Note: Static vs. OIDC: why it doesn't change the config** The Datadog API key, app key, and site are stored as **static** values in a Keycard Vault (an admin pasted them in once). The Linear API key is **OIDC**: Keycard mints a fresh, scoped token from an external OAuth Application each time. From `keycard.toml`'s point of view they look identical: every value flows through the same credential mechanism, which keeps environment management uniform. (The app key and site are effectively static and could be provided some other way, but routing them through Keycard keeps everything in one place.) ### mcp.json `mcp.json` declares the MCP servers Claude Code should connect to. Reference the hydrated environment variables using `${VAR}` interpolation. This is a Claude Code feature: it expands the variables from the environment when it boots, so the MCP servers come up already authenticated with the Keycard-issued credentials. ```json { "mcpServers": { "datadog": { "url": "https://mcp.datadoghq.com/", "headers": { "DD-API-KEY": "${DD_API_KEY}", "DD-APPLICATION-KEY": "${DD_APP_KEY}" } }, "linear": { "url": "https://mcp.linear.app/", "headers": { "Authorization": "Bearer ${LINEAR_API_KEY}" } } } } ``` ## Start a Keycard session Launch Claude Code inside a Keycard session: ```bash keycard run claude ``` The first time (roughly once per sign-in), you'll be asked to grant two consents: 1. **Management API access**: lets the CLI talk to the Keycard Management API. 2. **Token exchange**: lets the CLI exchange and provide the tokens for your configured Resources. These consents are remembered, so you won't be prompted on every run. Claude Code then detects the MCP servers from `mcp.json` and asks you to confirm connecting to them. Approve, and Keycard hydrates the credentials and hands them to the MCP servers. **Verify the connection.** Run `/mcp` inside Claude Code. If the Datadog and Linear servers show as connected, the exchanged credentials were valid; an invalid credential would fail the connection. ## Use the credentials The hydrated credentials work anywhere in the session, both through MCP servers and through CLI tools that read the environment variables. **Via MCP.** Ask the agent to work with Datadog monitors: > List all the monitors for the `svc-console` service. The Datadog MCP server is already authenticated with the Keycard-issued credentials, so the agent can query it and return results. **Via CLI.** The same data can come from a CLI tool instead of MCP. Ask: > Use the Datadog `pup` CLI to list the monitors for `svc-console`. The `pup` CLI picks up `DD_API_KEY`, `DD_APP_KEY`, and `DD_SITE` from the hydrated environment, with no keys on disk and nothing exported in your shell profile. ## Configure Resources The Resources used above were set up ahead of time in the Keycard Console. Pre-configured MCP and API servers can be installed straight from the [Catalog](/admin/catalog/); the two in this guide were configured by hand: - **Linear (OIDC).** An external OAuth Provider. An admin created a Linear OAuth Application, added its credentials to Keycard, and set the active scopes. Tokens Keycard mints for Linear carry exactly those scopes. See [brokered access](/concepts/resources/#brokered-access) for how this mechanism works. - **Datadog (static Vault).** Not an external OAuth Provider but a Keycard Vault. An admin opened the Resource, clicked **Add credential**, and pasted the static values from Datadog. See [vaulted static credentials](/concepts/resources/#vaulted-static-credentials). ## What's next Credentials are only half the story; the session also decides what the agent is allowed to *do* with them. Continue with [Control Tool Calls](/guides/control-tool-calls/) to enforce a default-deny Cedar policy on every tool call, add in-the-loop approvals, and audit the session in the Keycard Console. ## https://docs.keycard.ai/guides/control-tool-calls # Control Tool Calls Coding agents are only as safe as the actions they're allowed to take. This guide shows how Keycard enforces **Cedar policy** on every tool call Claude Code makes, CLI commands and MCP tools alike, so an agent can only do what you've explicitly allowed. By the end you'll have a session that: - Enforces a default-deny policy on every tool call (CLI and MCP) - Blocks a destructive action, then re-allows it with a human checkpoint - Prompts you in-the-loop for sensitive actions - Produces a full audit trail in the Keycard Console ## Prerequisites - The Keycard CLI and [Claude Code](https://claude.com/claude-code) installed - A project with a `keycard.toml` and connected MCP servers, running inside `keycard run claude`. Set this up in [Connect Claude to Resources](/guides/connect-claude-to-resources/) ## Install the Keycard plugin The plugin ties Keycard into Claude Code's hook system. The important one for this guide is the **pre-tool-use** hook: every time Claude Code is about to use a tool, whether an MCP tool call **or** a CLI command, the request is routed through this hook, compared against your policy, and allowed or denied. ```bash claude plugin marketplace add keycardai/plugins claude plugin install keycard-cli@keycardai ``` You can also ask Claude Code to install the plugin into the project for you. > **Caution: This supersedes Claude's own permissioning** When a pre-tool-use hook returns a decision (permit / deny / prompt), that decision is final. Claude's internal permission system is never consulted, so flags like "bypass permissions" become irrelevant. Nothing stops someone from passing them, but they have no effect, because policy makes the decision and Claude simply accepts it. It does not fall back to its own checks. ### How policy decisions work Policies are written in [Cedar](https://www.cedarpolicy.com/) and are **default-deny**: anything not explicitly permitted is denied. Each rule produces one of three outcomes: | Outcome | What happens | |---|---| | **Permit** | Allowed silently, with no prompt. | | **In-the-loop (itl)** | Allowed, but a prompt pops up; you must approve or deny. | | **Deny / no match** | Blocked. The agent gets a message saying so. | `forbid` always beats `permit`: if a tool matches both, it stays forbidden. This lets you keep a "defense in depth" hard-block even while a broad permit exists. ## Watch policy filter tool calls With the plugin installed and policy in place, the agent can only reach what you've permitted. **Via MCP.** Ask the agent to work with Datadog monitors: > List all the monitors for the `svc-console` service. Because the policy permits the monitor-related MCP tools (e.g. the search-monitor and list-monitor tools), the agent can query them and return results. **Via CLI.** The same data can come from a CLI tool instead of MCP. Ask: > Use the Datadog `pup` CLI to list the monitors for `svc-console`. Here you'll typically see a mix of results: the agent may try several commands, some of which aren't in the allow-list and get **blocked** (e.g. a `which` probe), while the specifically permitted command (`pup monitors`) succeeds. That mix is expected: default-deny blocks everything you didn't explicitly allow, and you can widen the policy later if needed. ## See a block in action Now try something the policy forbids. Suppose there's a throwaway "spurious demo dashboard" in Datadog: > Delete the spurious demo dashboard from Datadog. You'll see it blocked, for two independent reasons: 1. **Default-deny**: dashboard deletion was never permitted, so it's denied. 2. **Explicit forbid**: the policy also `forbid`s it outright as defense in depth. The agent reports back that it can't delete the dashboard. ## Change the policy to allow with a prompt > **Note:** Organization policy enforcement is coming soon. Orgs will be able to enforce a default policy that users can only **narrow**, not expand, so an individual or a specific agent can be made *more* restricted, never less. Ask the agent to update the policy so that deletes are allowed **in-the-loop**: > Add dashboard delete to the policy with **itl** (in-the-loop), so it prompts > me before running. The agent proposes a change that does two things: 1. **Removes** dashboard-delete from the explicit `forbid` block, which is necessary because `forbid` always wins over `permit`. 2. **Adds** a new clause permitting the delete but requiring an in-the-loop prompt (an `@itl("prompt")` annotation). Review the diff and approve it. ## Watch the in-the-loop approval Policy takes effect immediately: the policy file is re-read on every tool call, so your edit applies to the very next one with no restart. Ask again: > Delete the spurious demo dashboard. This time the tool is permitted, but because it's in-the-loop, an **approval prompt** pops up. It shows: - The exact command that would run. - A description drawn from the Cedar policy (the `@description` annotation), giving you context on why this action matters and what to check before approving. Approve it, and the delete proceeds. You've now gone from hard-blocked to allowed with a human checkpoint, without ever disabling the safety net. ## Review the session in the Console Every `keycard run` session has a session ID (visible in the CLI as a short identifier). 1. Open the [Keycard Console](https://console.keycard.ai/). 2. Go to **Sessions** and find your session by its ID. The session view is a full rundown of what happened: each token exchange, each credential issuance, and each tool call. Entries that succeeded show **green**; entries that were blocked by policy show **red**. You get complete insight and explainability into what the agent did and what it was stopped from doing. ## Recap - **The plugin's pre-tool-use hook** evaluates every tool call against Cedar policy and supersedes Claude's own permissioning. - **Policy is default-deny** with permit / in-the-loop / forbid outcomes; `forbid` beats `permit`. - **The policy file is re-read on every tool call**, so edits apply immediately, and the **Console** gives you a green/red audit trail per session. ## https://docs.keycard.ai/guides/delegated-access # Call External APIs from MCP Your MCP tools often need to call external APIs as the authenticated user: GitHub for issues and pull requests, Google for calendar or Drive, Slack for messages, or your own internal APIs. Delegated access is the pattern that lets the tool do that without shared service accounts, copied API keys, or blanket OAuth grants. Keycard handles the token exchange: you write tools, Keycard manages OAuth flows, credential exchange, policy checks, audit, and per-user refresh. > **Tip:** Complete the [Add Auth to Custom MCP](/guides/mcp-server/) guide first. This guide builds on that pattern. ## How delegated access works Token exchange lets your MCP server act as both a **protected resource** (receiving authenticated requests from AI agents) and an **API client** (calling upstream APIs on behalf of the authenticated user).
User authenticated via Keycard
AI Agent routes tool calls
Your MCP Server exchanges token
Keycard scopes & issues token
External API GitHub, Google, etc.
1. User authenticates to your MCP server with a Keycard token 2. Your MCP server exchanges that token for an external API token scoped to the user 3. Your MCP server calls the external API with the exchanged token 4. The user's data is accessed with their own permissions, not a shared service account ## Keycard setup These steps are the same regardless of which external API you're integrating. Complete them before following a provider-specific guide. 1. **Copy your Keycard redirect URL** In [Keycard Console](https://console.keycard.ai), open **Settings** → **Connection** and copy the **Redirect URL**. You will need this when creating the OAuth App at your provider. 2. **Create an OAuth App at your provider** In your provider's developer console (e.g., GitHub Developer Settings, Google Cloud Console), create an OAuth App. Set the authorization callback / redirect URI to the URL you copied in step 1. Note the **Client ID** and **Client Secret**. You will need them in the next step. 3. **Add the API from the Catalog** In Keycard Console, navigate to **Resources** -> **Add Resource** -> **Explore Resources** and add the API you want to integrate. Enter the Client ID and Client Secret from step 2. > **Note:** For APIs not in the catalog, you can [manually create providers and resources](/concepts/resources/) in Keycard Console - any OAuth 2.0 provider works. 4. **Register your MCP server Resource** Navigate to **Resources** → **Add Resource** → **Add Manually**: | Field | Value | | --- | --- | | **Resource Name** | Your MCP Server | | **Resource Identifier** | `http://localhost:8000/mcp` | | **Credential Provider** | Zone Provider | > **Note:** The Resource Identifier must match the URL where your MCP server is reachable. 5. **Create an Application** Navigate to **Applications** → **Add Application**, give it a name, and click **Create Application**. Then, on the application details page: - On the **Provides** tab, click **Add provided resource** and select your MCP Server. - On the **Dependencies** tab, click **Add dependency** and select the external API Resource(s). After creating the Application, generate **client credentials** (Client ID + Client Secret) and save them. These go into your environment as `KEYCARD_CLIENT_ID` and `KEYCARD_CLIENT_SECRET`. Your MCP server passes them to `AuthProvider` so it can exchange tokens on behalf of users. ## The Grant Pattern Every third-party integration follows the same steps: 1. **Add from Resource Catalog** (or [manually create](/concepts/resources/) the provider and resource for any OAuth 2.0 API) 2. **Register your MCP server Resource**: set Credential Provider to **Zone Provider** 3. **Create an Application**: name it, click **Create Application**, then on the **Provides** tab add your MCP server and on the **Dependencies** tab add the external APIs 4. **Generate client credentials**: Client ID + Client Secret for your Application 5. **Use the grant pattern** in code: **Python:** ```python @auth_provider.grant("https://api.example.com") async def my_tool(ctx: Context): access_context: AccessContext = await ctx.get_state("keycardai") token = access_context.access("https://api.example.com").access_token # Call API with token ``` **TypeScript:** ```typescript app.get("/api/endpoint", authProvider.grant("https://api.example.com"), async (req, res) => { const { accessContext } = req as DelegatedRequest; const token = accessContext.access("https://api.example.com").accessToken; // Call API with token }); ``` **Go:** ```go // Wrap your MCP handler with the grant middleware handler := authProvider.Grant([]string{"https://api.example.com"})(mcpHandler) // Inside a tool handler: ac := mcp.AccessContextFromContext(ctx) token, _ := ac.Access("https://api.example.com") // Call API with token.AccessToken ``` **Ruby:** ```ruby # Wrap your MCP endpoint with the grant middleware use auth_provider.grant("https://api.example.com") # Inside the handler: access_context = Keycardai::MCP.access_context(env) token = access_context.access("https://api.example.com").access_token # Call API with token ``` This works for any OAuth 2.0 provider: Slack, Linear, Notion, and more. ## Provider Guides Follow a provider-specific guide to see the full implementation: - [GitHub](/guides/delegated-access/github/) - [Google Workspace](/guides/delegated-access/google/) ## Production Notes When deploying to production: - **Update Resource Identifiers** in Keycard Console to your production URLs (must use HTTPS) - **Update OAuth redirect URIs** in GitHub/Google to match your production Keycard zone - **Set environment variables** securely: never commit client secrets to source control - **Token caching and refresh** is handled automatically by Keycard > **Tip:** For infrastructure-as-code deployments, use the Keycard Terraform Provider to programmatically configure Providers, Resources, and Applications. ## Troubleshooting ### Token Exchange Fails **Symptom**: `access_context.has_errors()` returns `True` (Python) / `accessContext.hasErrors()` returns `true` (TypeScript) / `ac.HasErrors()` returns `true` (Go) / `context.errors?` returns `true` (Ruby) - Verify the external API Resource is added as a **Dependency** on your Application - Check that the OAuth provider credentials (in Resource Catalog) are correct - Ensure the user has completed the OAuth consent flow for the external API - Confirm your MCP server is reachable at the registered Resource Identifier URL ### Invalid or Expired Token **Symptom**: External API returns 401 Unauthorized - Token refresh is automatic. If this persists, check provider configuration - Verify the scopes in Keycard Console match what the API requires - The user may need to re-authorize if they revoked access ### Scope Mismatch **Symptom**: External API returns 403 Forbidden or missing data - Ensure scopes in Keycard Console match what the API requires - For Google: verify the required APIs are enabled in Google Cloud Console - User may need to disconnect and reconnect to pick up new scopes ## https://docs.keycard.ai/guides/delegated-access/github # GitHub Build an MCP server with repository-focused tools that let AI agents interact with GitHub on behalf of users. > **Note:** This guide assumes you've completed the [delegated access setup](/guides/delegated-access/). If you haven't, start there. ## GitHub Setup 1. **Copy your Keycard redirect URL** In [Keycard Console](https://console.keycard.ai), open **Settings** → **Connection** and copy the **Redirect URL**. You will need this when creating the GitHub OAuth App. 2. **Create a GitHub OAuth App** In [GitHub Developer Settings](https://github.com/settings/developers), create a new **OAuth App** and set the **Authorization callback URL** to the redirect URL you copied from Keycard. Once created, note the **Client ID** and generate a **Client Secret**. 3. **Add GitHub from Resource Catalog** In Keycard Console, navigate to **Resource Catalog** and add **GitHub**. Enter the Client ID and Client Secret from step 2. This creates the GitHub OAuth provider and API resource. > **Note:** The Resource Catalog provides one-click setup for popular APIs. For APIs not in the catalog, you can [manually create providers and resources](/concepts/resources/) in Keycard Console. Any OAuth 2.0 provider works. 4. **Register your MCP server Resource** Navigate to **Resources** → **Add Resource** → **Add Manually**: | Field | Value | | --- | --- | | **Resource Name** | GitHub MCP Server | | **Resource Identifier** | `http://localhost:8000/mcp` | | **Credential Provider** | Zone Provider | > **Note:** The Resource Identifier must match the URL where your MCP server is reachable. 5. **Create an Application** Navigate to **Applications** → **Add Application**, give it a name, and click **Create Application**. Then, on the application details page: - On the **Provides** tab, click **Add provided resource** and select your GitHub MCP Server. - On the **Dependencies** tab, click **Add dependency** and select GitHub API. After creating the Application, generate **client credentials** (Client ID + Client Secret) and save them. ## Implementation The server is split into two files: setup and tools. The setup file configures Keycard authentication; the tools file contains your API wrappers. **Python:** Install dependencies: ```bash pip install keycardai-fastmcp fastmcp httpx ``` Create a `.env` file: ```bash KEYCARD_ZONE_ID= MCP_SERVER_URL=http://localhost:8000 KEYCARD_CLIENT_ID= KEYCARD_CLIENT_SECRET= ``` **`server.py`**: Server setup and entry point: ```python """GitHub MCP Server with Keycard Delegated Access.""" import os from fastmcp import FastMCP from keycardai.fastmcp import AuthProvider, ClientSecret auth_provider = AuthProvider( zone_id=os.getenv("KEYCARD_ZONE_ID"), mcp_server_name="GitHub MCP Server", mcp_server_url=os.getenv("MCP_SERVER_URL", "http://localhost:8000"), application_credential=ClientSecret(( os.getenv("KEYCARD_CLIENT_ID"), os.getenv("KEYCARD_CLIENT_SECRET"), )), ) auth = auth_provider.get_remote_auth_provider() mcp = FastMCP("GitHub MCP Server", auth=auth) from tools import register_tools # noqa: E402 register_tools(mcp, auth_provider) def main(): mcp.run(transport="streamable-http") if __name__ == "__main__": main() ``` **`tools.py`**: Each tool uses the grant decorator and extracts the exchanged token: ```python """GitHub tools: thin wrappers around the GitHub REST API.""" import httpx from fastmcp import Context, FastMCP from keycardai.fastmcp import AccessContext, AuthProvider GITHUB_API = "https://api.github.com" async def github_request(access_context: AccessContext, method: str, path: str, **kwargs) -> dict: """Make an authenticated request to the GitHub API.""" token = access_context.access(GITHUB_API).access_token async with httpx.AsyncClient() as client: response = await client.request( method, f"{GITHUB_API}{path}", headers={ "Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", }, **kwargs, ) response.raise_for_status() return response.json() def register_tools(mcp: FastMCP, auth_provider: AuthProvider): @mcp.tool() @auth_provider.grant(GITHUB_API) async def list_repos(ctx: Context, per_page: int = 30, sort: str = "updated") -> dict: """List the authenticated user's repositories.""" access_context: AccessContext = await ctx.get_state("keycardai") if access_context.has_errors(): return {"error": "Token exchange failed", "details": access_context.get_errors()} repos = await github_request( access_context, "GET", "/user/repos", params={"per_page": per_page, "sort": sort} ) return { "count": len(repos), "repositories": [ {"name": r["name"], "full_name": r["full_name"], "html_url": r["html_url"]} for r in repos ], } # Additional tools follow the same pattern: grant → get_state → check errors → call API # See full examples: https://github.com/keycardai/python-sdk/tree/main/packages/mcp-fastmcp/examples ``` **TypeScript:** Install dependencies: ```bash npm install @keycardai/mcp @modelcontextprotocol/sdk express npm install -D typescript @types/express ``` Create a `.env` file: ```bash KEYCARD_ZONE_URL= KEYCARD_CLIENT_ID= KEYCARD_CLIENT_SECRET= PORT=8080 ``` **`server.ts`**: Server setup and entry point: ```typescript const ZONE_URL = process.env.KEYCARD_ZONE_URL ?? ""; // from Settings → Connection const CLIENT_ID = process.env.KEYCARD_CLIENT_ID ?? ""; const CLIENT_SECRET = process.env.KEYCARD_CLIENT_SECRET ?? ""; const PORT = Number(process.env.PORT ?? 8080); const authProvider = new AuthProvider({ zoneUrl: ZONE_URL, applicationCredential: new ClientSecret(CLIENT_ID, CLIENT_SECRET), }); const app = express(); app.use(express.json()); app.use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: ZONE_URL }, resourceName: "GitHub MCP Server", }), ); registerGitHubRoutes(app, authProvider); app.listen(PORT, () => { console.log(`GitHub MCP Server running on http://localhost:${PORT}`); }); ``` **`tools.ts`**: Each route uses the grant middleware and extracts the exchanged token: ```typescript const GITHUB_API = "https://api.github.com"; async function githubRequest(token: string, path: string, options?: RequestInit) { const response = await fetch(`${GITHUB_API}${path}`, { ...options, headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", ...options?.headers, }, }); if (!response.ok) throw new Error(`GitHub API error: ${response.status}`); return response.json(); } export function registerGitHubRoutes(app: Express, authProvider: AuthProvider) { app.get("/api/repos", authProvider.grant(GITHUB_API), async (req, res) => { const { accessContext } = req as DelegatedRequest; if (accessContext.hasErrors()) { res.status(502).json({ error: "Token exchange failed", details: accessContext.getErrors() }); return; } const token = accessContext.access(GITHUB_API).accessToken; const repos = await githubRequest(token, "/user/repos?per_page=30&sort=updated"); res.json({ count: repos.length, repositories: repos.map((r: any) => ({ name: r.name, full_name: r.full_name, html_url: r.html_url, })), }); }); // Additional routes follow the same pattern: grant → accessContext → check errors → call API // See full examples: https://github.com/keycardai/typescript-sdk/tree/main/examples } ``` **Go:** Install dependencies: ```bash go get github.com/keycardai/go-sdk/mcp go get github.com/mark3labs/mcp-go ``` Set the environment variables: ```bash export KEYCARD_ISSUER= export KEYCARD_CLIENT_ID= export KEYCARD_CLIENT_SECRET= ``` **`main.go`**: Server setup and entry point: ```go // GitHub MCP Server with Keycard delegated access. package main import ( "log" "net/http" "os" "github.com/keycardai/go-sdk/mcp" "github.com/mark3labs/mcp-go/server" ) func main() { issuer := os.Getenv("KEYCARD_ISSUER") credential, err := mcp.NewClientSecret( os.Getenv("KEYCARD_CLIENT_ID"), os.Getenv("KEYCARD_CLIENT_SECRET"), ) if err != nil { log.Fatal(err) } // Auth provider for token exchange authProvider, err := mcp.NewAuthProvider( mcp.WithZoneURL(issuer), mcp.WithApplicationCredential(credential), ) if err != nil { log.Fatal(err) } // Verifier for inbound bearer tokens verifier, err := mcp.NewZoneTokenVerifier(issuer) if err != nil { log.Fatal(err) } s := server.NewMCPServer("GitHub MCP Server", "1.0.0") registerTools(s) httpMux := http.NewServeMux() // Serve OAuth metadata endpoints httpMux.Handle("/.well-known/", mcp.AuthMetadataHandler( mcp.WithIssuer(issuer), mcp.WithScopesSupported([]string{"mcp:tools"}), mcp.WithResourceName("GitHub MCP Server"), )) // Chain: bearer auth → grant → MCP handler httpMux.Handle("/mcp", mcp.RequireBearerAuth(verifier, mcp.WithRequiredScopes("mcp:tools"), )(authProvider.Grant([]string{githubAPI})( server.NewStreamableHTTPServer(s), ))) log.Println("GitHub MCP Server running on http://localhost:8000") log.Fatal(http.ListenAndServe(":8000", httpMux)) } ``` **`tools.go`**: Each tool reads the exchanged token from the access context: ```go package main import ( "context" "encoding/json" "fmt" "io" "net/http" "github.com/keycardai/go-sdk/mcp" mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) const githubAPI = "https://api.github.com" // githubRequest makes an authenticated request to the GitHub API. func githubRequest(ctx context.Context, token, method, path string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, method, githubAPI+path, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode) } return io.ReadAll(resp.Body) } func registerTools(s *server.MCPServer) { s.AddTool( mcpgo.NewTool("list_repos", mcpgo.WithDescription("List the authenticated user's repositories."), ), func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { ac := mcp.AccessContextFromContext(ctx) if ac.HasErrors() { return mcpgo.NewToolResultError("Token exchange failed"), nil } token, err := ac.Access(githubAPI) if err != nil { return mcpgo.NewToolResultError(err.Error()), nil } body, err := githubRequest(ctx, token.AccessToken, "GET", "/user/repos?per_page=30&sort=updated") if err != nil { return mcpgo.NewToolResultError(err.Error()), nil } var repos []struct { Name string `json:"name"` FullName string `json:"full_name"` HTMLURL string `json:"html_url"` } if err := json.Unmarshal(body, &repos); err != nil { return mcpgo.NewToolResultError(err.Error()), nil } result, _ := json.Marshal(map[string]any{"count": len(repos), "repositories": repos}) return mcpgo.NewToolResultText(string(result)), nil }, ) // Additional tools follow the same pattern: grant → access context → check errors → call API // See full examples: https://github.com/keycardai/go-sdk/tree/main/examples } ``` **Ruby:** Install dependencies: ```bash bundle add keycardai-mcp mcp rackup webrick ``` Set the environment variables: ```bash export KEYCARD_ISSUER= export KEYCARD_CLIENT_ID= export KEYCARD_CLIENT_SECRET= ``` **`config.ru`**: Server setup and entry point: ```ruby # GitHub MCP Server with Keycard delegated access. require "json" require "keycardai/mcp" require "mcp" require_relative "tools" issuer = ENV.fetch("KEYCARD_ISSUER") auth_provider = Keycardai::MCP::AuthProvider.new( zone_url: issuer, client_id: ENV.fetch("KEYCARD_CLIENT_ID"), client_secret: ENV.fetch("KEYCARD_CLIENT_SECRET"), ) metadata = Keycardai::MCP::MetadataApp.new( issuer: issuer, resource_name: "GitHub MCP Server", scopes_supported: ["mcp:tools"], ) # The MCP endpoint reads the exchanged tokens from the Rack env and hands # them to the tools via server_context mcp_endpoint = lambda do |env| access_context = Keycardai::MCP.access_context(env) mcp_server = build_mcp_server(access_context) body = env["rack.input"].read [200, { "content-type" => "application/json" }, [mcp_server.handle_json(body)]] end # Chain: bearer auth → grant → MCP handler protected_mcp = Keycardai::MCP::RequireBearerAuth.new( auth_provider.grant(GITHUB_API).new(mcp_endpoint), verifier: auth_provider.token_verifier, required_scopes: ["mcp:tools"], ) run lambda { |env| case env["PATH_INFO"] when %r{\A/\.well-known/} then metadata.call(env) when "/mcp" then protected_mcp.call(env) else [404, { "content-type" => "application/json" }, ['{"error":"not_found"}']] end } ``` **`tools.rb`**: Each tool reads the exchanged token from the access context: ```ruby require "json" require "net/http" GITHUB_API = "https://api.github.com" # Make an authenticated request to the GitHub API def github_request(token, path) uri = URI("#{GITHUB_API}#{path}") request = Net::HTTP::Get.new(uri) request["Authorization"] = "Bearer #{token}" request["Accept"] = "application/vnd.github+json" response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) } raise "GitHub API error: #{response.code}" unless response.is_a?(Net::HTTPSuccess) JSON.parse(response.body) end def build_mcp_server(access_context) mcp_server = MCP::Server.new( name: "GitHub MCP Server", version: "1.0.0", server_context: { access_context: access_context }, ) mcp_server.define_tool( name: "list_repos", description: "List the authenticated user's repositories", input_schema: { properties: { per_page: { type: "integer" } }, required: [] }, ) do |per_page: 30, server_context:| context = server_context[:access_context] # access raises if the exchange for this resource failed token = context.access(GITHUB_API).access_token repos = github_request(token, "/user/repos?per_page=#{per_page}&sort=updated") MCP::Tool::Response.new([{ type: "text", text: JSON.dump({ "count" => repos.length, "repositories" => repos.map { |r| r.slice("name", "full_name", "html_url") }, }), }]) end # Additional tools follow the same pattern: grant → access_context → call API # See full examples: https://github.com/keycardai/ruby-sdk/tree/main/examples mcp_server end ``` ## Test It 1. **Start your server** **Python:** ```bash python server.py ``` **TypeScript:** ```bash npx tsx server.ts ``` **Go:** ```bash go run . ``` **Ruby:** ```bash bundle exec rackup -p 8000 ``` 2. **Configure your agent** Add the MCP server to your agent configuration: **Cursor**: add to `.cursor/mcp.json`: ```json { "mcpServers": { "github-mcp": { "url": "http://localhost:8000/mcp" } } } ``` **Claude Code**: run in your terminal: ```bash claude mcp add --transport http github-mcp http://localhost:8000/mcp ``` 3. **Authenticate** Restart your coding agent to detect the server. When prompted, complete the OAuth flow. Keycard will prompt you to sign in to your zone. This is a separate account from your Keycard Console login. If it's your first time, click **Sign up** to create one. You will then be prompted to authorize GitHub access. 4. **Try these prompts:** - "List my GitHub repos" - "Show open PRs on `owner/repo`" - "Create an issue on `owner/repo` titled 'Bug: login broken'" 5. **Verify in Audit Logs** In Keycard Console, navigate to **Audit Log** to see the full flow: | Event | Description | | --- | --- | | `users:authenticate` | User logged in via Keycard | | `users:authorize` | User authorized access to the MCP server | | `credentials:issue` | Access token issued, showing the identity chain (user + application) | Each `credentials:issue` event includes an **identity chain** showing both the user identity (e.g., `alice@acme.com`) and the application identity (e.g., `GitHub`), so you can trace exactly which user triggered which API call through which application. ## https://docs.keycard.ai/guides/delegated-access/google # Google Workspace Build an MCP server with Calendar and Drive tools that let AI agents manage a user's Google Workspace on their behalf. > **Note:** This guide assumes you've completed the [delegated access setup](/guides/delegated-access/). If you haven't, start there. ## Google Setup 1. **Copy your Keycard redirect URL** In [Keycard Console](https://console.keycard.ai), open **Settings** → **Connection** and copy the **Redirect URL**. You will need this when creating the Google OAuth App. 2. **Create a Google OAuth App** In [Google Cloud Console](https://console.cloud.google.com/) → **APIs & Services** → **Credentials**, create an **OAuth 2.0 Client ID** (application type: **Web application**). Add the redirect URL you copied from Keycard to **Authorized redirect URIs**. Note the **Client ID** and **Client Secret**. Also enable the **Calendar API** and **Drive API** under **APIs & Services** → **Enabled APIs**. 3. **Add Google from Resource Catalog** In Keycard Console, navigate to **Resource Catalog** and add **Google Calendar** and **Google Drive**. Enter the Client ID and Client Secret from step 2. This creates the Google OAuth provider and API resources. > **Note:** The Resource Catalog provides one-click setup for popular APIs. For APIs not in the catalog, you can [manually create providers and resources](/concepts/resources/) in Keycard Console. Any OAuth 2.0 provider works. 4. **Register your MCP server Resource** Navigate to **Resources** → **Add Resource** → **Add Manually**: | Field | Value | | --- | --- | | **Resource Name** | Google Workspace MCP Server | | **Resource Identifier** | `http://localhost:8000/mcp` | | **Credential Provider** | Zone Provider | 5. **Create an Application** Navigate to **Applications** → **Add Application**, give it a name, and click **Create Application**. Then, on the application details page: - On the **Provides** tab, click **Add provided resource** and select your Google Workspace MCP Server. - On the **Dependencies** tab, click **Add dependency** and select Google Calendar API and Google Drive API. After creating the Application, generate **client credentials** and save them. ## Implementation The implementation follows the same pattern as the GitHub server. The only difference is the API URL you pass to `grant()` and the API calls you make with the token. **Python:** ```bash pip install keycardai-fastmcp fastmcp httpx ``` Create a `.env` file: ```bash KEYCARD_ZONE_ID= MCP_SERVER_URL=http://localhost:8000 KEYCARD_CLIENT_ID= KEYCARD_CLIENT_SECRET= ``` The server setup is identical to GitHub: just change the server name. The tools file uses the same grant pattern with `https://www.googleapis.com`: ```python from keycardai.fastmcp import AccessContext, AuthProvider GOOGLE_API = "https://www.googleapis.com" def register_tools(mcp: FastMCP, auth_provider: AuthProvider): @mcp.tool() @auth_provider.grant(GOOGLE_API) async def list_calendar_events(ctx: Context, calendar_id: str = "primary") -> dict: """List events from a Google Calendar.""" access_context: AccessContext = await ctx.get_state("keycardai") token = access_context.access(GOOGLE_API).access_token # Use token to call Google Calendar API async with httpx.AsyncClient() as client: response = await client.get( f"{GOOGLE_API}/calendar/v3/calendars/{calendar_id}/events", headers={"Authorization": f"Bearer {token}"}, params={"singleEvents": "true", "orderBy": "startTime"}, ) # ... process response @mcp.tool() @auth_provider.grant(GOOGLE_API) async def list_drive_files(ctx: Context, q: str | None = None) -> dict: """List or search files in Google Drive.""" access_context: AccessContext = await ctx.get_state("keycardai") token = access_context.access(GOOGLE_API).access_token # Use token to call Google Drive API ``` See the full example with all tools, error handling, and Google Workspace file export logic: [Python SDK examples](https://github.com/keycardai/python-sdk/tree/main/packages/mcp-fastmcp/examples) **TypeScript:** ```bash npm install @keycardai/mcp @modelcontextprotocol/sdk express npm install -D typescript @types/express ``` Create a `.env` file: ```bash KEYCARD_ZONE_URL= KEYCARD_CLIENT_ID= KEYCARD_CLIENT_SECRET= PORT=8080 ``` The server setup is identical to GitHub: just change the server name. The tools file uses the same grant pattern with `https://www.googleapis.com`: ```typescript const GOOGLE_API = "https://www.googleapis.com"; export function registerGoogleRoutes(app: Express, authProvider: AuthProvider) { app.get("/api/calendar/events", authProvider.grant(GOOGLE_API), async (req, res) => { const { accessContext } = req as DelegatedRequest; if (accessContext.hasErrors()) { res.status(502).json({ error: "Token exchange failed" }); return; } const token = accessContext.access(GOOGLE_API).accessToken; // Use token to call Google Calendar API const response = await fetch( `${GOOGLE_API}/calendar/v3/calendars/primary/events?singleEvents=true&orderBy=startTime`, { headers: { Authorization: `Bearer ${token}` } }, ); // ... process response }); app.get("/api/drive/files", authProvider.grant(GOOGLE_API), async (req, res) => { // Same pattern: grant → accessContext → token → API call }); } ``` See the full example with all tools, error handling, and Google Workspace file export logic: [TypeScript SDK examples](https://github.com/keycardai/typescript-sdk/tree/main/examples) **Go:** ```bash go get github.com/keycardai/go-sdk/mcp go get github.com/mark3labs/mcp-go ``` Set the environment variables: ```bash export KEYCARD_ISSUER= export KEYCARD_CLIENT_ID= export KEYCARD_CLIENT_SECRET= ``` The server setup is identical to GitHub: change the server name and pass `https://www.googleapis.com` to `Grant`. The tools file uses the same pattern: ```go package main import ( "context" "io" "net/http" "github.com/keycardai/go-sdk/mcp" mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) const googleAPI = "https://www.googleapis.com" func registerTools(s *server.MCPServer) { s.AddTool( mcpgo.NewTool("list_calendar_events", mcpgo.WithDescription("List events from a Google Calendar."), ), func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { ac := mcp.AccessContextFromContext(ctx) token, err := ac.Access(googleAPI) if err != nil { return mcpgo.NewToolResultError("Token exchange failed"), nil } // Use token to call Google Calendar API req, _ := http.NewRequestWithContext(ctx, "GET", googleAPI+"/calendar/v3/calendars/primary/events?singleEvents=true&orderBy=startTime", nil) req.Header.Set("Authorization", "Bearer "+token.AccessToken) resp, err := http.DefaultClient.Do(req) if err != nil { return mcpgo.NewToolResultError(err.Error()), nil } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return mcpgo.NewToolResultText(string(body)), nil }, ) s.AddTool( mcpgo.NewTool("list_drive_files", mcpgo.WithDescription("List or search files in Google Drive."), ), func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { ac := mcp.AccessContextFromContext(ctx) token, err := ac.Access(googleAPI) if err != nil { return mcpgo.NewToolResultError("Token exchange failed"), nil } // Use token to call Google Drive API req, _ := http.NewRequestWithContext(ctx, "GET", googleAPI+"/drive/v3/files", nil) req.Header.Set("Authorization", "Bearer "+token.AccessToken) resp, err := http.DefaultClient.Do(req) if err != nil { return mcpgo.NewToolResultError(err.Error()), nil } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return mcpgo.NewToolResultText(string(body)), nil }, ) } ``` See the full delegated access example: [Go SDK examples](https://github.com/keycardai/go-sdk/tree/main/examples/delegated-access) **Ruby:** ```bash bundle add keycardai-mcp mcp rackup webrick ``` Set the environment variables: ```bash export KEYCARD_ISSUER= export KEYCARD_CLIENT_ID= export KEYCARD_CLIENT_SECRET= ``` The server setup is identical to GitHub: change the server name and pass `https://www.googleapis.com` to `auth_provider.grant`. The tools file uses the same pattern: ```ruby require "net/http" GOOGLE_API = "https://www.googleapis.com" def build_mcp_server(access_context) mcp_server = MCP::Server.new( name: "Google Workspace MCP Server", version: "1.0.0", server_context: { access_context: access_context }, ) mcp_server.define_tool( name: "list_calendar_events", description: "List events from a Google Calendar", input_schema: { properties: { calendar_id: { type: "string" } }, required: [] }, ) do |calendar_id: "primary", server_context:| token = server_context[:access_context].access(GOOGLE_API).access_token # Use token to call Google Calendar API uri = URI("#{GOOGLE_API}/calendar/v3/calendars/#{calendar_id}/events?singleEvents=true&orderBy=startTime") request = Net::HTTP::Get.new(uri) request["Authorization"] = "Bearer #{token}" response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) } MCP::Tool::Response.new([{ type: "text", text: response.body }]) end mcp_server.define_tool( name: "list_drive_files", description: "List or search files in Google Drive", input_schema: { properties: { q: { type: "string" } }, required: [] }, ) do |q: nil, server_context:| token = server_context[:access_context].access(GOOGLE_API).access_token # Use token to call Google Drive API, same pattern as above end mcp_server end ``` See the full example MCP server: [Ruby SDK examples](https://github.com/keycardai/ruby-sdk/tree/main/examples/mcp-server) ## Test It 1. **Start your server** **Python:** ```bash python server.py ``` **TypeScript:** ```bash npx tsx server.ts ``` **Go:** ```bash go run . ``` **Ruby:** ```bash bundle exec rackup -p 8000 ``` 2. **Configure your agent** Add the MCP server to your agent configuration: **Cursor**: add to `.cursor/mcp.json`: ```json { "mcpServers": { "google-mcp": { "url": "http://localhost:8000/mcp" } } } ``` **Claude Code**: run in your terminal: ```bash claude mcp add --transport http google-mcp http://localhost:8000/mcp ``` 3. **Authenticate** Restart your coding agent to detect the server. When prompted, complete the OAuth flow. Keycard will prompt you to sign in to your zone. This is a separate account from your Keycard Console login. If it's your first time, click **Sign up** to create one. You will then be prompted to authorize Google Calendar and Drive access. 4. **Try these prompts:** - "Show my calendar events for this week" - "List my recent Drive files" - "Get the content of the file named 'Meeting Notes'" 5. **Verify in Audit Logs** Check Keycard Console **Audit Log**. You should see the same `users:authenticate`, `users:authorize`, and `credentials:issue` events, with the identity chain showing your user and the Google Application. ## https://docs.keycard.ai/guides/deploy-to-render-without-secrets # Deploy to Render without Secrets You built an MCP server that calls external APIs on behalf of users. Now you want to run it in production without copying a `KEYCARD_CLIENT_SECRET` into your hosting platform: a long-lived secret that can leak, needs rotation, and grants whoever holds it your Application's full identity. Render's [managed OIDC](https://render.com/docs/oidc) removes that secret entirely. Render mints a short-lived, automatically rotated identity token for each service, and Keycard accepts that token as your Application's credential through **workload identity federation**. Your service proves who it is with its runtime identity. There is no secret to provision, store, or rotate. > **Tip:** Complete the [Call External APIs from MCP](/guides/delegated-access/) guide first. This guide deploys the server you built there. ## How it works ```mermaid flowchart LR A[Render] -->|"Mounts rotating OIDC token"| B[Your MCP Server] B -->|"Token exchange + client assertion"| C[Keycard] C -->|"Verify issuer + subject"| C C -->|"Delegated API token"| B B -->|"Bearer token"| D[External API] ``` 1. Render issues a short-lived OIDC token for your service and rotates it automatically. The token's issuer is `https://oidc.render.com/` and its subject identifies your exact service: `workspace::environment::service:` (the environment segment is `default` when the service is not in a project environment) 2. The Keycard SDK reads the token on every request and presents it as an authentication credential to Keycard 3. Keycard verifies the token's signature against Render's published keys, then matches the issuer and subject against the workload identity credential registered on your Application 4. Token exchange proceeds exactly as before: your tools receive delegated, per-user API tokens Because Keycard pins the credential to your service's exact subject, no other service, even in the same Render workspace, can authenticate as your Application. ## Prerequisites - **Render workspace** on a *Pro* plan or higher (required for managed OIDC), with the [Render CLI](https://render.com/docs/cli) installed and logged in - Completed the [Call External APIs from MCP](/guides/delegated-access/) guide: a working MCP server with delegated access and an Application in Keycard Console - Your MCP server code in a Git repository that Render can access ## Create the Render service 1. **Deploy the service** ```bash render services create \ --name my-mcp-server \ --type web_service \ --repo \ --branch main \ --runtime python \ --build-command "uv sync --frozen" \ --start-command "uv run my-mcp-server" \ --env-var PORT=10000 \ --env-var KEYCARD_ISSUER= \ --env-var AWS_ROLE_ARN=keycard-oidc-trigger \ -o json --confirm ``` Find your **Issuer URL** in Keycard Console under **Settings** → **Connection**. > **Note:** The `AWS_ROLE_ARN` value is never used and your server never talks to AWS. Its *presence* is what tells Render to mount the OIDC token and set `AWS_WEB_IDENTITY_TOKEN_FILE` to its file path. This guide reuses the AWS trigger because the Keycard SDK discovers `AWS_WEB_IDENTITY_TOKEN_FILE` automatically. 2. **Note the service ID and URL** The create command returns the service ID (starts with `srv-`) and the public URL (`https://.onrender.com`). You need both in the next sections. 3. **Set your server's public URL** In the Render dashboard, add an environment variable to the service. This step uses the dashboard because the public URL only exists after creation, and the CLI's update command does not manage environment variables: ```bash MCP_SERVER_URL=https://.onrender.com/mcp ``` 4. **Register the production Resource** Keep the localhost Resource from the previous guide for local development. In Keycard Console, navigate to **Resources** → **Add Resource** → **Add Manually** and register the deployed server as a separate Resource: | Field | Value | | --- | --- | | **Resource Name** | My MCP Server (Production) | | **Resource Identifier** | `https://.onrender.com/mcp` | | **Credential Provider** | Zone Provider | ## Keep your server code unchanged No code change is required. One codebase serves both environments because the SDK selects the Application credential from the environment. Locally, `KEYCARD_CLIENT_ID` and `KEYCARD_CLIENT_SECRET` select your local Application's client secret. On Render, those variables are absent, so the SDK discovers `AWS_WEB_IDENTITY_TOKEN_FILE` and authenticates as the production Application with the token it points to. Your `AuthProvider` from the previous guide works unchanged: ```python import os from keycardai.fastmcp import AuthProvider auth_provider = AuthProvider( zone_url=os.environ["KEYCARD_ISSUER"], mcp_server_url=os.environ["MCP_SERVER_URL"], ) ``` The token is re-read on every request, so Render's automatic rotation requires no handling in your code. ## Register the Render identity in Keycard Rather than reusing the Application from the previous guide, create a second Application dedicated to the deployment. Each environment gets its own identity, its own dependencies, and its own audit attribution, and revoking one never touches the other. 1. **Create a Provider for Render's OIDC issuer** In Keycard Console, navigate to **Providers** → **Add Provider**: | Field | Value | | --- | --- | | **Name** | Render OIDC | | **Identifier** | `https://oidc.render.com/` | Your Render workspace ID starts with `tea-` and is shown at the top of the workspace's **Settings** page in the Render dashboard. Keycard discovers Render's signing keys from the issuer automatically. 2. **Create the production Application** Navigate to **Applications** → **Add Application**, name it (for example, My MCP Server Production), and click **Create Application**. Then, on the Application details page: - On the **Provides** tab, click **Add provided resource** and select the production Resource you registered earlier. - On the **Dependencies** tab, click **Add dependency** and select the same external API Resource(s) your local Application depends on. Do **not** generate client credentials for this Application. The next step gives it a credential that never needs storing. 3. **Add a workload identity credential** On the production Application's details page, open **Credentials** and add a **Workload Identity** credential: | Field | Value | | --- | --- | | **Provider** | Render OIDC | | **Subject** | `workspace::environment:default:service:` | The subject must match the `sub` claim of your service's token exactly. The environment segment is `default` unless the service belongs to a Render project environment, in which case it is the environment's ID (starts with `evm-`). A trailing `*` wildcard is supported if you want to trust every service in the workspace, at the cost of looser pinning. 4. **Redeploy the service** Trigger a deploy so the service starts with the OIDC token mounted. Because the production Application authenticates only through workload identity, there are no `KEYCARD_CLIENT_ID` or `KEYCARD_CLIENT_SECRET` variables to set on the service. ## Verify 1. **Call a tool end to end** Connect an MCP client to `https://.onrender.com/mcp`, complete the sign-in flow, and invoke a tool that calls the external API. 2. **Check the Audit Log** In Keycard Console, open the **Audit Log** and confirm `credentials:issue` events show the production Application authenticating with no client secret involved. ## Troubleshooting ### `invalid_client`: No token application credential configured The error message includes the exact Provider issuer and subject Keycard extracted from your service's token. Compare them against the Provider identifier and credential subject in Keycard Console. The subject must match character for character, including the service ID. To inspect the token Render actually issued, SSH into the service and decode its claims: ```bash render ssh cut -d. -f2 "$AWS_WEB_IDENTITY_TOKEN_FILE" | base64 -d ``` Confirm the `iss` and `sub` values match the Provider identifier and credential subject you registered. ### `AWS_WEB_IDENTITY_TOKEN_FILE` is not set - Confirm `AWS_ROLE_ARN` is set on the service and redeploy, since Render mounts the token during deploys - Managed OIDC requires a *Pro* workspace or higher ### Token exchange fails on Render but works locally - Confirm `KEYCARD_CLIENT_ID` and `KEYCARD_CLIENT_SECRET` are not set on the service: a client secret takes priority over workload identity, so the service would authenticate as your local Application - Verify the external API Resource is a **Dependency** on the production Application, not only on the local one - Verify the production Resource's **Resource Identifier** matches `https://.onrender.com/mcp` exactly ### Dockerfile-based builds Render does not provide the OIDC token at build time for services built from a Dockerfile. The token is available at runtime either way; if you need OIDC during builds, use a native runtime. ## What's next Your MCP server is running in production with no stored Keycard credential. From here you can extend what the deployed server does and watch how it is used. - **[Act on Behalf of Absent Users](/guides/act-on-behalf-of-absent-users/)**: run background work from the deployed server after a one-time user consent - **[Audit Log & Sessions](/admin/audit-log-and-sessions/)**: monitor every credential the production Application is issued - **[Deploy an MCP server on Cloudflare Workers](/guides/cloudflare-worker/)**: an alternative deployment target with its own credential options ## https://docs.keycard.ai/guides/mcp-server # Add Auth to Custom MCP MCP servers expose tools to AI agents, but without authentication any agent can call any tool with no accountability. Keycard adds OAuth-based authentication to your MCP server so that every tool call is tied to a verified user, scoped to explicit permissions, and logged for audit. This guide walks through building a protected MCP server from scratch: first a hello world, then adding delegated access to external APIs, with examples in Python, TypeScript, Go, and Ruby.
Your MCP Server Python, TypeScript, Go, or Ruby
OAuth Discovery Clients find how to auth
Bearer Auth Every tool call verified
Audit Trail Every access logged
> **Tip:** For complete, runnable code see the full examples: [Python SDK](https://github.com/keycardai/python-sdk/tree/main/packages/fastmcp/examples) | [TypeScript SDK](https://github.com/keycardai/typescript-sdk/tree/main/examples) Using Claude Code or Cursor with the Keycard CLI? The [Quickstart](/guides/quickstart) covers the automated setup path. See the [coding agent guides](/guides/#use-coding-agents) for more agent-driven flows. ## Prerequisites - **Python 3.10+** or **Node.js 18+** - **Keycard account** with access to [Console](https://console.keycard.ai) - **Cursor IDE** or another MCP-compatible client for testing > **Tip:** Looking for a stock MCP server (GitHub, Notion, Stripe, Linear, etc.) rather than building one from scratch? The [MCP server catalog](/admin/catalog/) has one-click installs that route through the Keycard Gateway with auth, policy, and audit applied for free. ## Hello world MCP server ### Install dependencies **Python:** ```bash pip install keycardai-fastmcp fastmcp ``` **TypeScript:** ```bash npm install @keycardai/mcp @modelcontextprotocol/sdk express zod npm install -D typescript @types/express tsx ``` **Go:** ```bash go get github.com/keycardai/go-sdk/mcp go get github.com/mark3labs/mcp-go ``` **Ruby:** ```bash bundle add keycardai-mcp mcp rackup webrick ``` ### Register your MCP server in Keycard Your MCP server needs to be registered as a protected **Resource** in Keycard so that AI agents can authenticate against it. 1. In Keycard Console, navigate to **Resources** → **Add Resource** → **Add Manually** 2. Configure the resource: **Python:** | | | | ------------------- | --------------------------- | | Resource Name | `My MCP Server (Local Dev)` | | Resource Identifier | `http://localhost:8000/mcp` | | Credential Provider | `Zone Provider` | **TypeScript:** | | | | ------------------- | --------------------------- | | Resource Name | `My MCP Server (Local Dev)` | | Resource Identifier | `http://localhost:8080/mcp` | | Credential Provider | `Zone Provider` | **Go:** | | | | ------------------- | --------------------------- | | Resource Name | `My MCP Server (Local Dev)` | | Resource Identifier | `http://localhost:8080/mcp` | | Credential Provider | `Zone Provider` | **Ruby:** | | | | ------------------- | --------------------------- | | Resource Name | `My MCP Server (Local Dev)` | | Resource Identifier | `http://localhost:8000/mcp` | | Credential Provider | `Zone Provider` | 3. Click **Create** > **Note:** The resource identifier must match your server's MCP endpoint URL exactly. ### Configure Environment Create a `.env` file in your project root: **Python:** ```bash KEYCARD_ZONE_ID= MCP_SERVER_URL=http://localhost:8000 ``` **TypeScript:** ```bash KEYCARD_ZONE_URL= PORT=8080 ``` **Go:** ```bash KEYCARD_ISSUER= MCP_SERVER_URL=http://localhost:8080 ``` **Ruby:** ```bash KEYCARD_ISSUER= ``` > **Tip:** Find these values in Keycard Console under **Settings** → **Connection**. The Python SDK accepts the zone ID directly; TypeScript uses your Issuer URL. ### Write the Server **Python:** ```python import os from fastmcp import FastMCP from keycardai.fastmcp import AuthProvider auth_provider = AuthProvider( zone_id=os.getenv("KEYCARD_ZONE_ID", ""), mcp_server_name="Hello World Server", mcp_server_url=os.getenv("MCP_SERVER_URL", "http://localhost:8000"), ) auth = auth_provider.get_remote_auth_provider() mcp = FastMCP("Hello World Server", auth=auth) @mcp.tool() def hello_world(name: str) -> str: """Say hello to an authenticated user.""" return f"Hello, {name}! You are authenticated." def main(): mcp.run(transport="streamable-http") if __name__ == "__main__": main() ``` `AuthProvider` handles all OAuth metadata and token verification. It connects your server to your Keycard zone so that MCP clients can discover how to authenticate. `get_remote_auth_provider()` returns the auth configuration that FastMCP needs, and `mcp.run()` starts the server. **TypeScript:** ```typescript const ZONE_URL = process.env.KEYCARD_ZONE_URL ?? ""; // from Settings → Connection const PORT = Number(process.env.PORT ?? 8080); const app = express(); app.use(express.json()); // OAuth metadata endpoints (unauthenticated) app.use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: ZONE_URL }, resourceName: "Hello World MCP Server", }), ); // MCP transport (protected by bearer auth) app.post( "/mcp", requireBearerAuth({ issuers: ZONE_URL }), async (req, res) => { const server = new McpServer({ name: "Hello World Server", version: "1.0.0" }); server.tool( "hello_world", "Say hello to an authenticated user.", { name: z.string().describe("Name to greet") }, async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}! You are authenticated.` }], }), ); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res, req.body); }, ); app.listen(PORT, () => { console.log(`Hello World MCP Server running on http://localhost:${PORT}`); }); ``` `mcpAuthMetadataRouter` serves the `.well-known` OAuth endpoints that MCP clients use to discover how to authenticate. `requireBearerAuth` verifies the JWT and rejects tokens from any issuer other than your zone. The MCP transport is mounted at `/mcp` in stateless mode. Each request is handled independently with no session state. **Go:** ```go package main import ( "context" "fmt" "log" "net/http" "os" "github.com/keycardai/go-sdk/mcp" "github.com/keycardai/go-sdk/oauth" mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) func main() { issuer := os.Getenv("KEYCARD_ISSUER") serverURL := os.Getenv("MCP_SERVER_URL") if serverURL == "" { serverURL = "http://localhost:8080" } // Build the MCP server with your preferred library (mcp-go here) s := server.NewMCPServer("Hello World Server", "1.0.0") s.AddTool( mcpgo.NewTool("hello_world", mcpgo.WithDescription("Say hello to an authenticated user."), mcpgo.WithString("name", mcpgo.Required()), ), func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { name, _ := req.GetArguments()["name"].(string) return mcpgo.NewToolResultText( fmt.Sprintf("Hello, %s! You are authenticated.", name), ), nil }, ) // The verifier trusts only tokens issued by your Zone, bound to this Resource verifier, err := mcp.NewZoneTokenVerifier(issuer, oauth.WithAudiences(serverURL+"/mcp")) if err != nil { log.Fatal(err) } mux := http.NewServeMux() // OAuth metadata endpoints (unauthenticated) mux.Handle("/.well-known/", mcp.AuthMetadataHandler( mcp.WithIssuer(issuer), mcp.WithResourceName("Hello World MCP Server"), )) // MCP transport (protected by bearer auth) mux.Handle("/mcp", mcp.RequireBearerAuth(verifier)( server.NewStreamableHTTPServer(s), )) log.Printf("Hello World MCP Server running on %s", serverURL) log.Fatal(http.ListenAndServe(":8080", mux)) } ``` The Go SDK is composable `http.Handler` middleware, so it works with any MCP library that produces an HTTP handler (mcp-go here). `AuthMetadataHandler` serves the `.well-known` OAuth endpoints that MCP clients use to discover how to authenticate. `NewZoneTokenVerifier` pins verification to your Zone's issuer and binds accepted tokens to this Resource's identifier, and `RequireBearerAuth` wraps the MCP transport so every request to `/mcp` carries a verified token. **Ruby:** ```ruby # config.ru require "json" require "keycardai/mcp" require "mcp" ISSUER = ENV.fetch("KEYCARD_ISSUER", "") mcp_server = MCP::Server.new(name: "Hello World Server", version: "1.0.0") mcp_server.define_tool( name: "hello_world", description: "Say hello to an authenticated user.", input_schema: { properties: { name: { type: "string" } }, required: ["name"] }, ) do |name:, server_context: nil| MCP::Tool::Response.new([{ type: "text", text: "Hello, #{name}! You are authenticated." }]) end # OAuth metadata endpoints (unauthenticated) metadata = Keycardai::MCP::MetadataApp.new( issuer: ISSUER, resource_name: "Hello World MCP Server", ) # MCP endpoint (protected by bearer auth) verifier = Keycardai::OAuth::TokenVerifier.new(issuers: ISSUER) mcp_endpoint = lambda do |env| body = env["rack.input"].read [200, { "content-type" => "application/json" }, [mcp_server.handle_json(body)]] end protected_mcp = Keycardai::MCP::RequireBearerAuth.new(mcp_endpoint, verifier: verifier) run lambda { |env| case env["PATH_INFO"] when %r{\A/\.well-known/} then metadata.call(env) when "/mcp" then protected_mcp.call(env) else [404, { "content-type" => "application/json" }, [JSON.dump({ "error" => "not_found" })]] end } ``` The `keycardai-mcp` gem attaches at the Rack seam and wraps no MCP SDK, so any Rack-compatible server works (the official `mcp` gem here). `MetadataApp` serves the `.well-known` OAuth endpoints that MCP clients use to discover how to authenticate. `RequireBearerAuth` verifies the bearer token and rejects tokens from any issuer other than your Zone before the MCP endpoint runs. ### Run and Test 1. **Start the server** **Python:** ```bash python server.py ``` **TypeScript:** ```bash npx tsx server.ts ``` **Go:** ```bash go run server.go ``` **Ruby:** ```bash bundle exec rackup -p 8000 ``` 2. **Configure your coding agent** Add the MCP server to your agent's configuration. Use the URL your server is running on (Python defaults to port `8000`, TypeScript to `8080`). **Cursor**: add to `.cursor/mcp.json`: ```json { "mcpServers": { "my-mcp-server": { "url": "http://localhost:8000/mcp" } } } ``` **Claude Code**: run in your terminal: ```bash claude mcp add --transport http my-mcp-server http://localhost:8000/mcp ``` 3. **Authenticate and test** 1. Restart your coding agent to detect the new MCP server 2. Connect to the MCP server when prompted 3. Complete the OAuth flow. Keycard will prompt you to sign in to your zone. This is a separate account from your Keycard Console login. If it's your first time, click **Sign up** to create one. 4. Test the tool. Ask your agent: `"Run the hello_world tool with my name"` 4. **Verify in Keycard Console** Check **Audit Log** for: | | | | ---------------------- | -------------------------- | | `users:authenticate` | You logged in successfully | | `users:authorize` | Your access was authorized | | `credentials:issue` | Access token was issued | > **Tip:** See the full working examples: [Python](https://github.com/keycardai/python-sdk/tree/main/packages/mcp-fastmcp/examples/hello_world_server) | [TypeScript](https://github.com/keycardai/typescript-sdk/tree/main/examples/hello-world-server) ## Next Steps Your MCP server is now protected. Every tool call is tied to a verified user, scoped by policy, and logged for audit. > **Tip:** If your tools need to call external APIs (GitHub, Google, etc.) on behalf of authenticated users, see [Add delegated access](/guides/delegated-access/). ## Deploy to Production When deploying your MCP server to production: - **Update the resource identifier** in Keycard Console to your production URL (e.g., `https://my-mcp-server.example.com/mcp`). It must use HTTPS. - **Set environment variables** securely in your hosting platform (`KEYCARD_ZONE_ID` or `KEYCARD_ZONE_URL`, `KEYCARD_CLIENT_ID`, `KEYCARD_CLIENT_SECRET`, `PORT`). Never commit secrets to source control. > **Tip:** For infrastructure-as-code deployments, use the Keycard Terraform Provider to programmatically configure resources and applications. ## https://docs.keycard.ai/guides/protect-any-api # Protect an API 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 API Any backend service
Bearer Auth Requests verified by Keycard
Per-Request Tokens Short-lived, never stored
Agent Identity Know which agent called what
> **Tip:** Using Claude Code or Cursor with the Keycard CLI? The [Quickstart](/guides/quickstart) covers the automated setup path. See the [coding agent guides](/guides/#use-coding-agents) for more agent-driven flows. | | Shared API keys | Keycard | | --- | --- | --- | | **Identity** | The user | User + agent + session | | **Scope** | Whatever the OAuth app grants | Constrained by [access policy](/admin/access-policies) | | **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). If not, start with the [Quickstart](/guides/quickstart). --- ## 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. **Python:** ```bash 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. ```python import os from fastapi import FastAPI, Request from 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. **TypeScript:** ```bash npm install @keycardai/express express ``` ```typescript 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:** ```bash go get github.com/keycardai/go-sdk/mcp ``` The `mcp` package is composable `net/http` middleware. Despite the name it has no MCP dependency and protects any `http.Handler`, plain APIs included. ```go 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)) } ``` **Ruby:** ```bash bundle add keycardai-mcp rackup ``` The `keycardai-mcp` gem attaches at the Rack seam. It has no MCP dependency and protects any Rack app, plain APIs included. ```ruby # config.ru 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 Set `KEYCARD_ISSUER` to your zone URL from [Keycard Console](https://console.keycard.ai) (**Settings** → **Connection**) and start the server: **Python:** ```bash export KEYCARD_ISSUER= # from Settings → Connection uvicorn main:app --port 8080 ``` **TypeScript:** ```bash export KEYCARD_ISSUER= # from Settings → Connection npx tsx src/server.ts ``` **Go:** ```bash export KEYCARD_ISSUER= # from Settings → Connection go run . ``` **Ruby:** ```bash export KEYCARD_ISSUER= # from Settings → Connection bundle exec rackup -p 8080 ``` Check that discovery responds: ```bash $ curl -s http://localhost:8080/.well-known/oauth-protected-resource | jq ``` ```json { "resource": "http://localhost:8080", "scopes_supported": ["read", "write", "admin"], "resource_name": "My API" } ``` Check that unauthenticated requests are rejected: ```bash $ 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" ``` ### What your middleware validates When a valid token arrives, your middleware unpacks these claims: ```json { "iss": "", "sub": "", "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" } ``` > **Note:** See [Token Claims](/reference/token-claims/) for the full reference, including how to choose and customize claims. ### 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](/admin/access-policies), or both. ```typescript 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. **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. **Set the user identifier.** On your Zone's identity Provider, set the [user identifier claim](/concepts/providers/#user-identifier-claim) to the claim you want in `sub`, such as the provider's user id. New users then get their `sub` from that claim instead of the default Keycard ID. 5. **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.) 6. **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. > **Note:** The identifiers you set here become claims in the tokens Keycard issues: the Resource identifier as `aud`, the Application identifier as `keycard_app_id`, and the subject (`sub`) as the user or Application identifier. See [Token Claims](/reference/token-claims/). --- ## 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: **Python:** ```python 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_secret="", resource_url="http://localhost:8080", www_authenticate_header=www_auth, scopes=["read"], ) return token.access_token ``` **TypeScript:** ```typescript async function getSubjectToken() { return authenticate("", { // from Settings → Connection clientId: "", clientSecret: "", resource: "http://localhost:8080", port: 8765, scopes: ["read"], }); } ``` **Go:** ```go import ( "context" "github.com/keycardai/go-sdk/oauth" ) func getSubjectToken(ctx context.Context) (string, error) { token, err := oauth.Authenticate(ctx, "", oauth.AuthenticateRequest{ // from Settings → Connection ClientID: "", ClientSecret: "", Resource: "http://localhost:8080", CallbackPort: 8765, Scopes: []string{"read"}, }) if err != nil { return "", err } return token.AccessToken, nil } ``` **Ruby:** ```ruby require "keycardai/oauth" def get_subject_token token = Keycardai::OAuth.authenticate( issuer: "", # from Settings → Connection client_id: "", client_secret: "", resource: "http://localhost:8080", port: 8765, scope: "read", ) token.access_token end ``` 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. **Python:** ```python 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}"}, ) ``` **TypeScript:** ```typescript 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}` }, }); ``` **Go:** ```go 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) ``` **Ruby:** ```ruby 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`. > **Note:** The oauth package in every SDK (`keycardai-oauth` on PyPI, `@keycardai/oauth`, `go-sdk/oauth`, and the `keycardai-oauth` gem) has zero MCP dependencies. Pure OAuth 2.1 / RFC 8693. --- ## What's Next - Setup: [Quickstart](/guides/quickstart) · [Resource Catalog](/admin/catalog) · [Access Policies](/admin/access-policies) - Guides: [Add delegated access](/guides/delegated-access) · [Run coding agents with Keycard](/guides/secure-agentic-coding) - SDK Reference: [OAuth SDK](/sdk/oauth) · [MCP SDK](/sdk/mcp) · [CLI](/cli) --- # Admin ## https://docs.keycard.ai/platform/operate # Operate Operate covers the controls teams need once Keycard is part of a real deployment: who can administer it, how sign-in is gated, what gets audited, and how the platform is reviewed for security and compliance. ## Admin Controls - [Roles & Permissions](/admin/roles-and-permissions/) define who can manage organizations and zones. - [Single Sign-On](/admin/single-sign-on/) gates Console access behind your corporate identity provider. - [Usage & Billing](/admin/usage/) shows billable activity and transaction history. ## Audit And Compliance - [Audit Log Export](/admin/audit-log-export/) sends activity records to your security data plane. - [Security Architecture](/reference/security-architecture/) explains the platform controls behind Keycard. - [Standards & Protocols](/reference/standards/) documents the protocols Keycard builds on. ## Deployment [Deployment](/admin/deployment/) covers environment and operational considerations for running Keycard with your infrastructure. ## https://docs.keycard.ai/admin/single-sign-on # Single Sign-On Keycard supports any OIDC identity provider, including Okta and Azure Entra ID. Users are automatically provisioned Just-in-Time (JIT) on first login. ## Prerequisites - Admin access to your Keycard organization - Admin access to your identity provider ## Setup ### Step 1: Configure your identity provider Use your organization's **Redirect URL**, found in Keycard Console under **Settings** → **Connection**. **Okta:** 1. In your Okta Admin Console, go to **Applications** > **Create App Integration** 2. Select **OIDC - OpenID Connect** and **Web Application** 3. Configure the application: - **Sign-in redirect URIs**: the Redirect URL you copied above 4. Save the **Client ID**, **Client Secret**, and **Issuer** (found in your Okta domain, e.g., `https://.okta.com`) **Azure Entra ID:** 1. In the Azure Portal, go to **Microsoft Entra ID** > **App registrations** > **New registration** 2. Configure the application: - **Name**: Choose a name for your app - **Supported account types**: **Accounts in this organizational directory only (Single tenant)** - **Redirect URI**: Select **Web** and enter the Redirect URL you copied above 3. After creation, go to **Certificates & secrets** and create a new client secret 4. Save the **Application (client) ID**, **Client Secret**, and **Issuer** (`https://login.microsoftonline.com//v2.0`) **Generic OIDC:** Create an OAuth 2.0 / OIDC application in your identity provider with these settings: - **Application type**: Web application - **Redirect URI**: the Redirect URL you copied above - **Grant types**: Authorization Code Save the **Client ID**, **Client Secret**, and **Issuer** URL. ### Step 2: Link identity provider to Keycard Configure an SSO connection from **Settings** → **SSO** in the Keycard Console, or via the Keycard Terraform provider. Turn on the **Enable SSO** toggle, then enter your identity provider's **Identifier** (its OAuth issuer URL), **Client ID**, and **Client Secret**. ### Step 3: Test authentication 1. Test login through your identity provider dashboard (see [Login from Your Identity Provider](#login-from-your-identity-provider) for setup) 2. Confirm successful login and user creation Your SSO is now configured. Users can login from your identity provider dashboard. ## User Roles and Access When users log in via SSO for the first time, they join your organization as a **Viewer**, which is read-only. To let someone manage Keycard, an **Admin** updates their role on the **People** page. See [Roles & Permissions](/admin/roles-and-permissions/) for details. To grant access to a team rather than one person at a time, put people in a [Group](/admin/groups/) and assign the Role to the Group. > **Tip: Coming soon** Provisioning Users and Groups from your identity provider over SCIM 2.0 is coming soon. ## Domain Verification (Optional) Domain verification enables a seamless login experience at [console.keycard.ai](https://console.keycard.ai). **Without verification**: Users access Keycard through your identity provider dashboard (Okta tiles, Azure My Apps) **With verification**: Users enter their email at console.keycard.ai and are automatically redirected to your identity provider To request verification, email help@keycard.ai with your organization name and the domain to verify. Keycard will verify you control the domain before enabling this feature. **After verification**: - Users with your verified email address domain must authenticate through your identity provider - Entering an email with a verified domain automatically redirects to your identity provider *Note: The same domain cannot be reused across multiple organizations.* ### Emergency Access For emergency access when your identity provider is unavailable, administrators can use this URL: ``` https://id.keycard.ai/openid/connect/login?tenant=personal&iss=https://id.keycard.ai&target_link_uri=https://console.keycard.ai ``` This enables non-SSO email/password accounts to authenticate. > **Note:** Keep at least one non-SSO Admin account so you can update roles even if your identity provider becomes unavailable. ## Login from Your Identity Provider To enable users to login from your identity provider dashboard (Okta tiles, Azure My Apps), configure your identity provider with the **Initiate Login URI**, found in **Settings** → **SSO** under **Provider Configuration**. **URL format**: ``` https://id.keycard.ai/openid/connect/login?iss=&target_link_uri=https://console.keycard.ai&tenant= ``` **Parameters**: - `iss`: Your identity provider's issuer URL - `target_link_uri`: Where to redirect after login (e.g., `https://console.keycard.ai` or a specific page) - `tenant`: Your Keycard organization ID (found in the Console under your organization settings page) **Okta:** In your Okta application settings, set the **Initiate login URI** to: ``` https://id.keycard.ai/openid/connect/login?target_link_uri=https://console.keycard.ai&tenant= ``` Okta automatically includes the `iss` parameter, so you only need to specify `target_link_uri` and `tenant`. Replace `` with your Keycard organization ID. **Azure Entra ID:** In your Azure application settings, set the **Home page URL** to: ``` https://id.keycard.ai/openid/connect/login?iss=https://login.microsoftonline.com//v2.0&target_link_uri=https://console.keycard.ai&tenant= ``` Replace `` with your Azure tenant ID and `` with your Keycard organization ID. **Generic OIDC:** Configure your identity provider's application launch URL or home page URL to: ``` https://id.keycard.ai/openid/connect/login?iss=&target_link_uri=https://console.keycard.ai&tenant= ``` Replace `` with your identity provider's issuer URL and `` with your Keycard organization ID. ## https://docs.keycard.ai/admin/roles-and-permissions # Roles & Permissions Keycard uses **role-based access control (RBAC)** to manage who can administer Keycard. Roles apply at two levels: 1. **Organization roles** control who can manage your organization. 2. **Custom zone roles** control who can manage a specific [custom zone](/concepts/zones/). Only members of your organization hold these roles. People who sign in to a custom zone to use its applications are not organization members and have no management access. > **Note: Roles in access policies** Beyond controlling who can administer Keycard, these Roles can also be referenced in access policies as `Keycard::Role` entities to grant Users and Applications access to Resources. See [Role-based policies](/admin/access-policies/#role-based-policies). Policies can also grant access by [Group](/concepts/groups/) membership; see [Group-based policies](/admin/access-policies/#group-based-policies). --- ## Organization Roles Every member of your organization has one organization role. | | | | ----------- | -------------------------------------------------------- | | **Admin** | Full access to the organization and everything in it | | **Viewer** | Read-only access to the organization | ### Admin Admins have full control of the organization: - Manage organization settings - Configure SSO - Invite and remove members - Change member roles - Create, update, and delete zones - Create and manage service accounts - Configure applications, resources, and providers - View audit logs > **Caution:** Every organization must have at least one Admin. You cannot remove or demote the last Admin. ### Viewer Viewers have read-only access to the organization: - View settings, members, and service accounts - View applications, resources, and providers - View sessions, users, and audit logs - Cannot create, modify, or delete anything --- ## Custom Zone Roles [Custom zones](/concepts/zones/) have their own users and identity provider. To let an organization member manage a custom zone, an Admin assigns them a zone role. | | | | ------------ | -------------------------------------------------------- | | **Manager** | Full access to the custom zone and everything in it | | **Viewer** | Read-only access to the custom zone | > **Note:** Organization Admins are not automatically Managers of custom zones. Zone access is assigned per zone. --- ## Managing Members > **Note:** Only Admins can invite members and change roles. ### Inviting Members 1. **Open the People page** Click **People** in the sidebar. 2. **Create an invitation** Click **Invite**. 3. **Enter email and role** Enter the email address(es) and choose a role (**Viewer** by default). 4. **Send the invitation** Click **Add people**. They receive an email invitation. > **Note:** Organizations with SSO enabled have invitations disabled, so the **Invite** button won't appear. ### Changing a Member's Role Open a member's access drawer to change their organization role or their access to a specific Zone. 1. Open the **People** page. 2. Find the member and click the **⋯** button on their row to open their access drawer. 3. Under **Organization role**, choose **Admin** or **Viewer**. 4. Under **Zone access**, choose **No access**, **Manager**, or **Viewer** for each zone. > **Caution:** Changing an Admin to Viewer removes their ability to manage the organization. --- ## Assigning Roles to Groups Roles can be assigned to a [Group](/concepts/groups/) as well as to an individual. Every member of the Group inherits the Role, and a person's effective Roles are the union of the Roles assigned to them directly and the Roles assigned to every Group they belong to. Use Groups when a Role should follow a team rather than a person: put someone in the right Groups and their access follows, instead of assigning each Role by hand. See [Groups](/admin/groups/#assign-roles-to-a-group). --- ## Best Practices
Use least privilege Give people the Viewer role unless they need to manage Keycard. Reserve Admin for the people who configure SSO, members, and policy.
Use service accounts for automation For CI/CD pipelines and automated workflows, use [service accounts](/concepts/) instead of personal credentials.
Regularly audit access Review your members and their roles periodically. Remove people who no longer need access.
## https://docs.keycard.ai/admin/audit-log-export # Audit Log Export Audit Log Export allows you to automatically export Keycard audit logs to your own AWS S3 bucket in OCSF (Open Cybersecurity Schema Framework) format. This enables integration with security information and event management (SIEM) tools, data warehouses, and custom analytics platforms. ## Data Format ### OCSF Schema Audit logs are exported in [OCSF v1.7.0](https://schema.ocsf.io/1.7.0/) format, an industry-standard schema for security events. Events include authentication, authorization, API operations, and secret management activities. ### File Format Files are delivered as **Parquet** files with the following characteristics: - **Compression**: Snappy compression - **Partitioning**: Files are organized by date - **Naming**: `YYYY-MM-DD-HH-MM-SS-{uuid}.parquet` - **Delivery SLA**: Events are delivered within one hour of occurrence ### S3 Key Structure Files will be written to your bucket with this structure: ``` s3://your-bucket/[prefix/]YYYY-MM-DD-HH-MM-SS-{uuid}.parquet ``` Example: ``` s3://acme-keycard-audit-logs/keycard/audit_logs/2026-02-05-14-30-00-1b2e4543-cf21-49c2-9459-bdada803c24b.parquet ``` ## Prerequisites Before configuring audit log export, you'll need: - An AWS S3 bucket in your AWS account - An IAM role with permissions for Keycard to write to your bucket ## Setup > **Note:** **First step:** Contact Keycard support to request audit log export and provide them with your configuration details (see final step). The **Keycard AWS Organization Path** for production is: `o-c7hznpvsin/r-6n6x/ou-6n6x-bcvsfrlg/ou-6n6x-oox2udyt/*` You'll need to generate an **External ID** - a secret credential for the IAM trust policy. Generate a random UUID or use: ```bash uuidgen ``` Keep this External ID secure - you'll use it in your IAM trust policy and provide it to Keycard support. 1. **Create an S3 bucket** Create an S3 bucket in your AWS account where audit logs will be delivered: ```bash aws s3api create-bucket \ --bucket $BUCKET_NAME \ --region us-east-1 ``` > **Note:** Choose a bucket name that follows your organization's naming conventions. We recommend including "keycard" and "audit" in the name for clarity. 2. **Enable bucket encryption** Enable default encryption at rest for all objects: ```bash aws s3api put-bucket-encryption \ --bucket $BUCKET_NAME \ --server-side-encryption-configuration '{ "Rules": [{ "ApplyServerSideEncryptionByDefault": { "SSEAlgorithm": "AES256" }, "BucketKeyEnabled": true }] }' ``` > **Note:** For enhanced security, you can use AWS KMS encryption instead of AES256. See the KMS encryption step below for details. 3. **Block public access** Enable S3 Block Public Access to prevent accidental public exposure: ```bash aws s3api put-public-access-block \ --bucket $BUCKET_NAME \ --public-access-block-configuration \ "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" ``` 4. **Enable bucket versioning** Enable versioning to protect audit logs from accidental deletion or modification: ```bash aws s3api put-bucket-versioning \ --bucket $BUCKET_NAME \ --versioning-configuration Status=Enabled ``` 5. **Create IAM trust policy** Create a file named `trust-policy.json`: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "AWS": "*" }, "Action": "sts:AssumeRole", "Condition": { "StringEquals": { "sts:ExternalId": "$EXTERNAL_ID" }, "ForAnyValue:StringLike": { "aws:PrincipalOrgPaths": "o-c7hznpvsin/r-6n6x/ou-6n6x-bcvsfrlg/ou-6n6x-oox2udyt/*" } } } ] } ``` > **Caution:** Replace `$EXTERNAL_ID` with the External ID you generated. The External ID is a secret credential that prevents the confused deputy problem. Keep it secure. 6. **Create the IAM role** ```bash aws iam create-role \ --role-name KeycardAuditLogWriter \ --assume-role-policy-document file://trust-policy.json ``` 7. **Attach S3 permissions** Create a file named `permissions-policy.json`: ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "ListBucketForPrefix", "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::$BUCKET_NAME" }, { "Sid": "ReadWriteWithTLS", "Effect": "Allow", "Action": ["s3:PutObject", "s3:AbortMultipartUpload"], "Resource": "arn:aws:s3:::$BUCKET_NAME/*", "Condition": { "Bool": { "aws:SecureTransport": "true" } } } ] } ``` Replace `$BUCKET_NAME` with your actual bucket name. Attach the policy: ```bash aws iam put-role-policy \ --role-name KeycardAuditLogWriter \ --policy-name S3WriteAccess \ --policy-document file://permissions-policy.json ``` Save the role ARN - you'll need it for configuration: ```bash aws iam get-role \ --role-name KeycardAuditLogWriter \ --query 'Role.Arn' \ --output text ``` 8. **(Optional) Upgrade to KMS encryption** For enhanced security with customer-managed keys, you can upgrade from AES256 to KMS encryption. > **Caution:** If you enable KMS encryption, you must update both the IAM role permissions and the KMS key policy. Missing IAM role KMS permissions will prevent Keycard from writing audit logs and you from reading exported files. First, update your bucket encryption configuration to use KMS: ```bash aws s3api put-bucket-encryption \ --bucket $BUCKET_NAME \ --server-side-encryption-configuration '{ "Rules": [{ "ApplyServerSideEncryptionByDefault": { "SSEAlgorithm": "aws:kms", "KMSMasterKeyID": "arn:aws:kms:us-east-1:$AWS_ACCOUNT_ID:key/$KMS_ID" }, "BucketKeyEnabled": true }] }' ``` Next, update the IAM role policy to grant KMS permissions. Create a file named `kms-permissions-policy.json`: ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "ListBucketForPrefix", "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::$BUCKET_NAME" }, { "Sid": "ReadWriteWithTLS", "Effect": "Allow", "Action": ["s3:PutObject", "s3:AbortMultipartUpload"], "Resource": "arn:aws:s3:::$BUCKET_NAME/*", "Condition": { "Bool": { "aws:SecureTransport": "true" } } }, { "Sid": "AllowKMSEncryption", "Effect": "Allow", "Action": ["kms:Decrypt", "kms:GenerateDataKey"], "Resource": "arn:aws:kms:us-east-1:$AWS_ACCOUNT_ID:key/$KMS_ID" } ] } ``` Replace: - `$BUCKET_NAME` with your bucket name (appears in 2 places) - `$AWS_ACCOUNT_ID` with your AWS account ID - `$KMS_ID` with your KMS key ID Update the IAM role policy: ```bash aws iam put-role-policy \ --role-name KeycardAuditLogWriter \ --policy-name S3WriteAccess \ --policy-document file://kms-permissions-policy.json ``` Then grant the IAM role access to your KMS key by adding this statement to your KMS key policy: ```json { "Sid": "AllowKeycardToEncrypt", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::$AWS_ACCOUNT_ID:role/KeycardAuditLogWriter" }, "Action": ["kms:Decrypt", "kms:GenerateDataKey"], "Resource": "*" } ``` Update your KMS key policy: ```bash # Get current policy aws kms get-key-policy \ --key-id $KMS_ID \ --policy-name default \ --output text > current-key-policy.json # Edit current-key-policy.json to add the AllowKeycardToEncrypt statement above to the "Statement" array # Update the key policy aws kms put-key-policy \ --key-id $KMS_ID \ --policy-name default \ --policy file://current-key-policy.json ``` Replace `$AWS_ACCOUNT_ID` and `$KMS_ID` with your actual AWS account ID and KMS key ID. 9. **Provide configuration to Keycard support** Provide Keycard support with your configuration details: - **S3 Bucket Name** - **S3 Bucket Region** - **IAM Role ARN** - **External ID** - The External ID you generated and used in your IAM trust policy - **S3 Key Prefix** (optional): A prefix for organizing files, e.g., `keycard/audit_logs/` - **KMS Key ARN** (optional): Only if you upgraded to KMS encryption Keycard support will configure the export and notify you when it's active. ## Verifying Export 1. **Check S3 bucket** Confirm that Parquet files are being created in your bucket: ```bash aws s3 ls s3://$BUCKET_NAME/keycard/audit_logs/ ``` > **Tip:** Files should appear within one hour of activity in Keycard. File names follow the pattern: `YYYY-MM-DD-HH-MM-SS-{uuid}.parquet` 2. **Validate file contents** Download and inspect a sample file: ```bash # Install parquet-tools pip install parquet-tools # View file schema and sample data parquet-tools show s3://your-bucket/prefix/file.parquet ``` ## Troubleshooting
No files appearing in S3 bucket - Verify the IAM role trust policy includes the correct External ID provided by Keycard support - Confirm the IAM role has `s3:PutObject` permission on your bucket - Check with Keycard support that export is enabled for your organization
Access denied errors - Verify the IAM role has `s3:PutObject` and `s3:ListBucket` permissions - Ensure the External ID in the trust policy matches the value provided by Keycard support - If you upgraded to KMS encryption, confirm the KMS key policy grants access to the IAM role - Check that Block Public Access settings aren't preventing the write operation
Files not readable by downstream systems - Confirm your system supports Parquet file format - Check that your system has appropriate AWS credentials to read from your S3 bucket - Verify file permissions and encryption settings are compatible with your system
--- ## Security Responsibility Keycard delivers your audit logs to your S3 bucket using encrypted connections and authenticated access. It is your sole responsibility to ensure your S3 bucket is secure, this includes but is not limited to: - Maintaining appropriate S3 bucket policies and access controls - Managing encryption keys (if using KMS) - Controlling who can read audit logs from your bucket - Implementing lifecycle policies and retention requirements - Meeting your organization's security and compliance standards Keycard's obligations are set out in your Master Services Agreement and Service Level Agreement. Once successful delivery of logs to your S3 bucket has been completed Keycard responsibility for the safekeeping of the data ends. All subsequent storage, access control, and data handling is your responsibility. --- > **Note:** Need help? Contact Keycard support. ## https://docs.keycard.ai/admin/catalog # Catalog export const mcpItems = [ ...featured.mcp, { cta: true, name: 'More MCP servers in the catalog', description: 'Browse and install the full set from the Keycard Console, under Resources.', }, ]; export const apiItems = [ ...featured.api, { cta: true, name: 'More API servers in the catalog', description: 'Browse and install the full set from the Keycard Console, under Resources.', }, ];

Pre-configured integrations for the tools your agents and apps already use - GitHub, Slack, Notion, Stripe, and dozens more. Install one from the Keycard Console and Keycard handles the OAuth setup, scopes, and per-user audit so you don't have to wire it up yourself.

> **Note:** You need a Keycard **zone** with an [identity provider](/admin/identity-providers/) before installing - catalog entries authenticate users against it. Keycard provisions a default identity provider with every zone, so you can start right away, or [connect your own](/admin/identity-providers/) instead. ## Featured MCP servers

Official MCP servers from each vendor, run by Keycard. You install the URL into Cursor or Claude Code; every tool call is authenticated against your zone's identity provider, evaluated against your access policies, and recorded in the audit log.

## Featured API servers

Pre-configured third-party APIs (Gmail, Slack, GitHub, ...) your backend can call on behalf of authenticated users. Each install creates the OAuth provider and resource your app needs. Default scopes are pre-set; you can override them at install time.

> **Tip:** **Not in the catalog?** You can register any OAuth 2.0 provider as a custom resource - the same token exchange and audit guarantees apply. See [Call External APIs from MCP](/guides/access-apis-on-behalf-of-users/) for a worked example. > **Note:** **Audit by default.** Every catalog install records the user's OAuth consent, each token exchange, and every API or gateway call - viewable in **Console -> Audit Log** or exported to your SIEM via [Audit Log Export](/admin/audit-log-export/). ## https://docs.keycard.ai/admin/access-policies # Access Policies Access policies control what users and applications can access in Keycard. The policy system has two levels: - **Policies** are individual authorization rules (for example, "permit user Alice access to Google Calendar") - **Policy sets** bundle policies together and deploy them across the entire zone Keycard uses **default-deny**: every authorization request needs an explicit `permit` for both the user and the application acting on their behalf. Without a matching permit, Keycard denies access. A `forbid` policy always overrides a `permit`, so you can layer restrictive rules on top of permissive baselines. > **Tip: Postman collection** Download the Postman collection to import all requests with pre-configured scripts that automatically chain variables (token, zone ID, policy IDs, etc.) between steps. Set `client_id` and `client_secret` in your Postman environment before running. ## Policy Language Keycard uses [Cedar](https://www.cedarpolicy.com/) by AWS as its policy language. Cedar is an open-source authorization language built for fine-grained access control. It is formally verified and statically analyzable. You author policies in Cedar and submit them through the API in one of two formats: - **`cedar_raw`** is human readable Cedar syntax, concise and easy to write - **`cedar_json`** is Cedar's JSON AST representation, verbose but machine-friendly Keycard validates both formats against the schema before storing them. On retrieval, use the `?format=cedar` query parameter to get human readable output, or `?format=json` (default) for the JSON representation. Cedar supports two effects (`permit` and `forbid`), uses deny-wins conflict resolution, and provides `when`/`unless` conditions for attribute based rules. ### Why Cedar **Safety guarantees.** Keycard validates policies against a typed schema before storing them. The system rejects invalid policies at authoring time, not at runtime. The Cedar engine can prove properties about policy sets without executing them. **Constrained language.** Cedar is intentionally not a general purpose language. Its small, well defined grammar keeps policy authoring straightforward. Cedar has no loops and no side effects, only declarative rules. **Model friendly.** The constrained semantics and well defined schema make Cedar excellent for AI assisted policy authoring. LLMs can generate correct policies reliably when given the schema as context. ## Keycard Schema > **Note:** This section refers to schema version `2026-06-18`. Your zone may have a different schema version available. Check your zone's policy schemas via the API to confirm the current version. The `Role` and `Group` entities require schema version `2026-03-16` or later; `2026-02-24` and earlier versions ignore role and group data. The Cedar schema maps entity types to the Keycard data model: | Entity Type | Maps To | |---|---| | Keycard::User | Zone users are the people who authenticate into a zone | | Keycard::Application | Registered applications and OAuth clients that act on behalf of users, or on its own | | Keycard::Resource | Third party API resources configured in the zone (for example, Google Calendar, GitHub) | | Keycard::Role | Platform-defined Roles assigned to Users and Applications through role assignments. Used for [role-based policies](#role-based-policies) | | Keycard::CustomRole | Customer-defined Roles assigned to Users and Applications through role assignments. Separate entity type from `Keycard::Role` so identifiers can't collide with platform-defined Roles | | Keycard::Group | [Groups](/concepts/groups/) of Users, managed in the zone. Used for [group-based policies](#group-based-policies) | | Keycard::RegistrationMethod | Enum entity for application registration methods | | Keycard::CredentialType | Enum entity for application credential types | ### Entity properties Each entity type has attributes you can reference in Cedar `when` and `unless` conditions. #### Keycard::Application | Property | Type | Description | |---|---|---| | identifier | String | The application's unique identifier | | name | String | The application's display name | | registration_method | RegistrationMethod | How the application was registered. See [Keycard::RegistrationMethod](#keycardregistrationmethod) for values | | credential_type | CredentialType? | The credential type used for authentication. Optional, absent when not yet determined. See [Keycard::CredentialType](#keycardcredentialtype) for values | | traits | Set\ | Behavioral traits that activate trait-specific experiences and workflows. Possible values: "gateway" (application acts as an API gateway), "mcp-provider" (application provides MCP tools) | | dependencies | Set\ | Resources the application is configured to access directly (service-to-service) | Applications can be members of [Keycard::Role](#keycardrole) entities. Match membership with `principal in Keycard::Role::""` (platform-defined roles) or `principal in Keycard::CustomRole::""` (customer-defined roles). #### Keycard::User | Property | Type | Description | |---|---|---| | identifier | String | The user's unique identifier | | email | String | The user's email address from their identity provider | Users can be members of [Keycard::Role](#keycardrole) entities. Match membership with `principal in Keycard::Role::""` (platform-defined roles) or `principal in Keycard::CustomRole::""` (customer-defined roles). Users can also be members of [Keycard::Group](#keycardgroup) entities. Match membership with `principal in Keycard::Group::""`. #### Keycard::Resource | Property | Type | Description | |---|---|---| | identifier | String | The resource's unique identifier | | name | String | Human-readable resource name | | scopes | Set\ | OAuth scopes associated with the resource | #### Keycard::Role Roles have no attributes to reference in conditions. They serve as membership targets: a policy matches when the principal holds the role, using Cedar's `in` operator. The role's owner type selects the entity type: - `Keycard::Role::"admin"` — a platform-defined role - `Keycard::CustomRole::"deployer"` — a customer-defined role (creating custom Roles is coming soon; see [Role-based policies](#role-based-policies)) Because platform-defined and customer-defined roles use separate entity types, their identifiers can't collide. Only Users and Applications can hold Roles; Resources do not support role membership. See [Role-based policies](#role-based-policies) for usage. #### Keycard::Group Groups have no attributes to reference in conditions. Like Roles, they serve as membership targets: a policy matches when the principal belongs to the Group, using Cedar's `in` operator. The entity ID is the Group's identifier, which is unique within the Zone: ```cedar principal in Keycard::Group::"data-analysts" ``` Only Users can be Group members. Applications and Resources do not support Group membership, so a rule that matches a Group only ever matches a person. See [Group-based policies](#group-based-policies) for usage. #### Keycard::RegistrationMethod Enum entity for application registration methods. Reference in Cedar as Keycard::RegistrationMethod::"value". | Value | Description | |---|---| | Keycard::RegistrationMethod::"managed" | Created via the management API | | Keycard::RegistrationMethod::"dcr" | Registered dynamically via OAuth 2.0 Dynamic Client Registration | #### Keycard::CredentialType Enum entity for application credential types. Reference in Cedar as Keycard::CredentialType::"value". | Value | Description | |---|---| | Keycard::CredentialType::"token" | Workload identity (short-lived tokens) | | Keycard::CredentialType::"password" | Client ID and secret | | Keycard::CredentialType::"public-key" | Key-based assertion | | Keycard::CredentialType::"url" | URL-based identity | | Keycard::CredentialType::"public" | Public client (no secret) | #### Claims The schema defines a Claims type used for JWT claim-based policy conditions. | Property | Type | Description | |---|---|---| | email | String? | Email claim from the JWT, if present | | groups | Set\? | Group memberships as asserted by the upstream IdP in the JWT (for example, Okta or Entra ID groups). This is the raw claim from a single sign-in, not Keycard Group membership — for directory-backed membership use [Keycard::Group](#keycardgroup) | #### Context Policies have access to a **context** object carrying runtime authorization state: | Attribute | Type | Description | |---|---|---| | on_behalf | Bool | Whether this is a delegated request (application acting for a user) | | impersonate | Bool | Whether this is an impersonation request (application acting as the subject rather than on its behalf) | | subject | User? | The end user on whose behalf an application acts. Optional, absent for direct application access | | resource | Resource? | The request's target Resource, mirrored into context so conditions can reference it (for example, `context.resource.identifier`) | | scopes | Set\? | OAuth scopes in the current request. Optional, absent when no scopes are requested | | actor_claims | Claims? | JWT claims for the actor making the request. Optional, absent when claims are not available | | subject_claims | Claims? | JWT claims for the subject that an application acts on behalf of. Optional, absent when there is no subject | Keycard manages schema versions using a date based format (for example, `2026-06-18`). Policies you write against a schema version are guaranteed to continue working. Keycard only makes additive changes to a published version, such as new entity types or attributes, and never removes or alters existing definitions. ## Who can manage policies Administrative roles control who can manage policies through the management API: - **Organization Admins and Zone Managers** can create, modify, and activate policies (full CRUD on policies, policy versions, policy sets, and policy set versions) - **Viewers** can view policies and policy sets but can't create, modify, or activate them - [Roles & Permissions](/admin/roles-and-permissions) ## Managed policies The Keycard platform creates and manages these policies. You can identify them by `owner_type: "platform"` in the API. You can't modify or delete managed policies. Keycard groups them into a managed policy set called **default-zone-policies**. Expand each entry to view the full Cedar content.
default-user-grants : Permits all authenticated users access to all resources The permissive baseline. This policy grants every authenticated user access to every resource in the zone, regardless of the specific resource or action. ```cedar @id("default-user-grants") permit ( principal is Keycard::User, action, resource ); ```
default-app-delegation : Permits applications to act on behalf of users Allows applications to perform actions on behalf of a user when the request is a delegated request (`context.on_behalf == true`). This is the foundation for OAuth based delegation flows where an application acts for a user. ```cedar @id("default-app-delegation") permit ( principal is Keycard::Application, action, resource ) when { context.on_behalf == true }; ```
default-app-direct-access : Permits applications to access resources directly Allows [applications as consumers](/concepts/applications/#applications-as-consumers) to access resources directly (without acting on behalf of a user), scoped to the application's configured resource dependencies. The application can only access resources that appear in its `dependencies` set. ```cedar @id("default-app-direct-access") permit ( principal is Keycard::Application, action, resource ) when { principal.dependencies.contains(resource) }; ```
## Customer policies Customer policies (`owner_type: "customer"`) let you define your own authorization rules and assemble them into policy sets that supersede the default managed policy set. When you activate a customer policy set, it replaces **default-zone-policies** as the active set for your zone. You can include managed policies alongside your custom policies in the same policy set. This is safe because all policy versions, including managed ones, are immutable. When Keycard updates a managed policy, it issues a new version rather than modifying an existing one. Because your policy set pins exact version IDs, there's no risk of breaking changes from upstream updates. You control when to adopt a newer managed policy version by creating a new policy set version with an updated manifest. ## Role-based policies Role-based policies grant access by Role membership instead of enumerating individual principals. Membership comes from role assignments and is resolved at evaluation time, so membership changes take effect immediately without touching your policies. The default managed policies don't reference Roles; role-based rules are opt-in for customer policies. Platform-owned roles are the administrative roles documented in [Roles & Permissions](/admin/roles-and-permissions/) (`admin` and `viewer`) — the same underlying roles serve both Console administration and policy evaluation. > **Tip: Coming soon** The ability to create your own custom Roles (`Keycard::CustomRole`) and assign them to Users and Applications is coming soon. The `Keycard::CustomRole` entity type is already declared in the schema, so policies referencing custom Roles validate today and take effect as soon as role assignments exist. > **Note:** Role-based policies require schema version `2026-03-16` or later. Policy versions authored against `2026-02-24` and earlier schema versions ignore role data entirely. ### Grant by role Permit every member of a Role: ```cedar @id("permit-platform-admin-role") permit ( principal in Keycard::Role::"admin", action, resource ); ``` ### Combine roles with delegation Roles resolve for both the actor and the subject in delegated requests. This policy permits members of the built-in `viewer` Role only when acting on behalf of a user: ```cedar @id("permit-viewer-on-behalf") permit ( principal in Keycard::Role::"viewer", action, resource ) when { context.on_behalf == true }; ``` ### Restrict a resource to a role Cedar's `in` operator matches exact entity IDs; there are no role wildcards or negation. To exclude non-members, write a `forbid` policy or rely on default deny. This example restricts a Resource to members of the built-in `admin` Role: ```cedar @id("restrict-resource-to-admins") forbid ( principal, action, resource == Keycard::Resource::"" ) unless { principal in Keycard::Role::"admin" }; ``` ### How role membership is resolved - Role assignments are the source of truth. Keycard resolves them at evaluation time, so changing an assignment changes decisions without a policy update. - Zone-scoped assignments apply only within their Zone. Unscoped assignments apply in every Zone. - Assignments with malformed scopes are dropped fail-closed: the principal is not treated as a member of that Role. - Only Users and Applications carry role membership; Resources do not. ## Group-based policies Group-based policies grant access by [Group](/concepts/groups/) membership. They work the same way role-based policies do — `in` against a membership target — but a Group is a collection of people you manage for its own sake, so the same Group can drive both administrative Roles and resource access. Reach for a Group when the rule is about *who someone is* on your team (the data analysts, the on-call rotation). Reach for a Role when the rule is about *what someone may administer*. > **Note:** Group-based policies require schema version `2026-03-16` or later. Policy versions authored against `2026-02-24` and earlier schema versions ignore group data entirely. ### Grant by group Permit every member of a Group. The entity ID is the Group's identifier: ```cedar @id("permit-data-analysts") permit ( principal in Keycard::Group::"data-analysts", action, resource ); ``` ### Restrict a resource to a group Combine a Group with a Resource and a condition. This example permits the data analysts to reach a Snowflake Resource, and only with a read-write session role: ```cedar @id("permit-data-analysts-snowflake") permit ( principal in Keycard::Group::"data-analysts", action, resource ) when { resource.identifier == "" && context has scopes && context.scopes.containsAny(["session:role:READWRITE_ROLE"]) }; ``` ### Combine groups with delegation In a delegated request an Application acts for a User. Group membership attaches to that subject, not to the calling Application, so this rule permits any Application acting on behalf of an on-call engineer: ```cedar @id("permit-on-call-on-behalf") permit ( principal, action, resource ) when { context.on_behalf == true && context has subject && context.subject in Keycard::Group::"on-call" }; ``` ### Groups versus the IdP groups claim Both of these match on "groups", and they are not the same thing: - `principal in Keycard::Group::""` reads membership from the Zone's directory at evaluation time. - `context.subject_claims.groups.contains("")` reads the `groups` claim the identity provider put on the token for this sign-in. Claim-based rules keep working unchanged and remain the right tool when the authoritative list lives only in your IdP and is never mirrored into Keycard. Prefer `Keycard::Group` otherwise: the claim is a snapshot taken at sign-in, so it goes stale until the user gets a new token and it depends on the provider being configured to send it. ### How group membership is resolved - Membership is resolved when the request is evaluated, so adding or removing a member changes access without a policy update and without the User signing in again. - Groups are zone-scoped. A Group identifier only matches within the Zone that owns it. - Only Users carry Group membership; Applications and Resources do not. - Groups do not nest. `in` matches direct membership only. - In an on-behalf request, Group membership attaches to the subject — the User being acted for — not to the acting Application. ## Policy lifecycle The governance system has four resource types: policies, policy versions, policy sets, and policy set versions. ### Policies A *policy* is a named container for authorization rules. It has metadata (name, description) but no Cedar content. The actual rules live in policy versions. **Invariants:** - Policy names are unique within a zone - Metadata (name, description) can be updated at any time without affecting active authorization - Policies are soft deleted (archived), never hard deleted - Platform owned policies can't be updated or archived by customers ### Policy versions A *policy version* is an immutable snapshot of Cedar content, validated against a specific schema version. Once created, the Cedar content and schema reference never change. **Invariants:** - Policy versions are **immutable**. You can't modify the Cedar content or schema version after creation - Each version is validated against the Cedar schema before Keycard stores it - Keycard computes and stores a SHA-256 hash of the canonicalized policy content for integrity verification - You can archive a policy version only if it isn't referenced by an active policy set - Version numbers auto-increment within each policy ### Policy sets A *policy set* is a named deployment unit that bundles policies together. Like policies, it has metadata but no policy content. The actual bundle is defined in policy set versions. **Invariants:** - Policy set names are unique within a zone - Platform owned policy sets can't be updated or archived by customers - A policy set can't be archived while it has an active binding ### Policy set versions A *policy set version* is an immutable manifest that pins exact policy version IDs. When you activate a policy set version, Keycard evaluates exactly the policies listed in that manifest. **Invariants:** - Policy set versions are **immutable**. The manifest can't change after creation - The manifest must contain at least one entry - Each manifest entry references a specific policy ID and policy version ID, both of which must exist and not be archived - Keycard computes a SHA-256 hash of the canonicalized manifest for integrity verification and audit - You can archive a policy set version only if it isn't the currently active version - Activating a version atomically replaces the previously active version - Version numbers auto increment within each policy set ### How the pieces fit together The lifecycle for creating and deploying a custom policy is: 1. **Create a policy** : a named container for your rule 2. **Author a policy version** : the immutable Cedar content, validated against a schema 3. **Assemble into a policy set** : bundle policies into a deployment unit, optionally including managed policy versions 4. **Activate the policy set** : bind and activate it for your zone, replacing the default set Because both policy versions and policy set versions are immutable, you can always trace exactly which Cedar content was active at any point in time. Rolling back means activating a previous policy set version. ## Security guarantees ### No silent policy changes Policy versions are immutable. Once you create a version, nobody can alter its Cedar content. To change a rule, create a new version. The content hash stored at creation time remains valid forever, and any tampering is detectable. ### Atomic deployment A policy set version pins exact policy version IDs in an immutable manifest. When Keycard evaluates an authorization request, it uses exactly the policies in the active manifest. There is no window where a partially updated set of policies could be evaluated. ### Safe rollback Because every policy set version is immutable and preserved, you can revert to any previous configuration by activating an earlier policy set version. The previous manifest still references the same immutable policy versions it always did. ### Full auditability Every version of every policy and every manifest is preserved with SHA-256 content hashes. You can reconstruct exactly which Cedar content was active at any point in time, and verify that the content hasn't been modified since creation. ### Separation of authoring and activation Creating a policy version doesn't affect live authorization. You can author, validate, and review policies without risk. Changes only take effect when you explicitly activate a policy set version. ## Setup This walkthrough shows you how to create and activate a custom policy. You'll authenticate with the API, write a Cedar policy, bundle it into a policy set, and deploy it to your zone. 1.
**Authenticate** **Python:** All management API requests require a Bearer token. Obtain one by exchanging your service account's client ID and client secret: ```python import requests token_response = requests.post( "https://api.keycard.ai/service-account-token", data={ "grant_type": "client_credentials", "client_id": KEYCARD_CLIENT_ID, "client_secret": KEYCARD_CLIENT_SECRET, }, ).json() base = "https://api.keycard.ai" headers = {"Authorization": f"Bearer {token_response['access_token']}"} ```
Example response ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600 } ```
**HTTP:** All management API requests require a Bearer token. Obtain one by exchanging your service account's client ID and client secret: ```bash frame="none" ACCESS_TOKEN=$(curl -s -X POST https://api.keycard.ai/service-account-token \ -d "grant_type=client_credentials" \ -d "client_id=$KEYCARD_CLIENT_ID" \ -d "client_secret=$KEYCARD_CLIENT_SECRET" \ | jq -r '.access_token') ```
Example response ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600 } ```
**Postman:** All management API requests require a Bearer token. Obtain one by exchanging your service account's client ID and client secret: ```bash frame="none" curl -X POST https://api.keycard.ai/service-account-token \ -d "grant_type=client_credentials" \ -d "client_id={{client_id}}" \ -d "client_secret={{client_secret}}" ``` Save the `access_token` from the response as the `{{token}}` variable for subsequent requests.
Example response ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600 } ```
**Console:** Sign in to the Keycard Console at **console.keycard.ai**. You'll be redirected to your identity provider to authenticate. Once signed in, navigate to your zone and select **Policies** from the sidebar.
2.
**Review the schema** **Python:** List the available Cedar schemas for your zone to find the latest schema version. ```python schemas = requests.get( f"{base}/zones/{zone_id}/policy-schemas", headers=headers, ).json() print(schemas) ```
Example response ```json { "items": [ { "id": "01JKXYZ123ABC456DEF789GH", "version": "2026-06-18", "cedar_schema": "namespace Keycard {\n entity Role;\n entity CustomRole;\n entity Group;\n\n entity User in [Role, CustomRole, Group] {\n identifier: String,\n email: String,\n };\n\n entity Application in [Role, CustomRole] {\n identifier: String,\n name: String,\n dependencies: Set,\n };\n\n entity Resource {\n identifier: String,\n name: String,\n scopes: Set,\n };\n\n type Claims = {\n email?: String,\n groups?: Set,\n };\n}\n", "created_at": "2026-06-18T00:00:00Z" } ] } ```
**HTTP:** List the available Cedar schemas for your zone to find the latest schema version. ```bash frame="none" curl https://api.keycard.ai/zones/$ZONE_ID/policy-schemas \ -H "Authorization: Bearer $ACCESS_TOKEN" ```
Example response ```json { "items": [ { "id": "01JKXYZ123ABC456DEF789GH", "version": "2026-06-18", "cedar_schema": "namespace Keycard {\n entity Role;\n entity CustomRole;\n entity Group;\n\n entity User in [Role, CustomRole, Group] {\n identifier: String,\n email: String,\n };\n\n entity Application in [Role, CustomRole] {\n identifier: String,\n name: String,\n dependencies: Set,\n };\n\n entity Resource {\n identifier: String,\n name: String,\n scopes: Set,\n };\n\n type Claims = {\n email?: String,\n groups?: Set,\n };\n}\n", "created_at": "2026-06-18T00:00:00Z" } ] } ```
**Postman:** List the available Cedar schemas for your zone to find the latest schema version. ```bash frame="none" curl https://api.keycard.ai/zones/{{zone_id}}/policy-schemas \ -H "Authorization: Bearer {{token}}" ```
Example response ```json { "items": [ { "id": "01JKXYZ123ABC456DEF789GH", "version": "2026-06-18", "cedar_schema": "namespace Keycard {\n entity Role;\n entity CustomRole;\n entity Group;\n\n entity User in [Role, CustomRole, Group] {\n identifier: String,\n email: String,\n };\n\n entity Application in [Role, CustomRole] {\n identifier: String,\n name: String,\n dependencies: Set,\n };\n\n entity Resource {\n identifier: String,\n name: String,\n scopes: Set,\n };\n\n type Claims = {\n email?: String,\n groups?: Set,\n };\n}\n", "created_at": "2026-06-18T00:00:00Z" } ] } ```
**Console:** The Console policy editor validates against the latest schema automatically. When editing a policy, the Cedar preview sidebar displays available entity types and context attributes. > **Tip:** You don't need to review the schema separately in the Console. The editor handles schema validation inline as you author policies.
Example response ```json { "items": [ { "id": "01JKXYZ123ABC456DEF789GH", "version": "2026-06-18", "cedar_schema": "namespace Keycard {\n entity RegistrationMethod enum [\"managed\", \"dcr\"];\n entity CredentialType enum [\"token\", \"password\", \"public-key\", \"url\", \"public\"];\n\n entity Role;\n entity CustomRole;\n entity Group;\n\n entity User in [Role, CustomRole, Group] {\n identifier: String,\n email: String,\n };\n\n entity Application in [Role, CustomRole] {\n identifier: String,\n name: String,\n registration_method: RegistrationMethod,\n credential_type?: CredentialType,\n traits: Set,\n dependencies: Set,\n };\n\n entity Resource {\n identifier: String,\n name: String,\n scopes: Set,\n };\n\n type Claims = {\n email?: String,\n groups?: Set,\n };\n\n action any appliesTo {\n principal: [User, Application],\n resource: Resource,\n context: {\n on_behalf: Bool,\n impersonate: Bool,\n subject?: User,\n resource?: Resource,\n scopes?: Set,\n actor_claims?: Claims,\n subject_claims?: Claims,\n },\n };\n}\n", "created_at": "2026-06-18T00:00:00Z" } ] } ```
2.
**Create a policy** **Python:** Create a named policy container. You'll add the Cedar content as a version in the next step. ```python policy = requests.post( f"{base}/zones/{zone_id}/policies", headers=headers, json={ "name": "require-token-credentials", "description": "Require token credential type for all application access", }, ).json() policy_id = policy["id"] ```
Example response ```json { "id": "01JKXYZ456DEF789ABC123GH", "zone_id": "01JKXYZ789ABC456DEF123GH", "name": "require-token-credentials", "description": "Require token credential type for all application access", "owner_type": "customer", "created_at": "2026-03-03T10:00:00Z", "updated_at": "2026-03-03T10:00:00Z", "archived_at": null } ```
Save the `id` from the response. You'll need it for the next step. **HTTP:** Create a named policy container. You'll add the Cedar content as a version in the next step. ```bash frame="none" curl -X POST https://api.keycard.ai/zones/$ZONE_ID/policies \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "require-token-credentials", "description": "Require token credential type for all application access" }' ```
Example response ```json { "id": "01JKXYZ456DEF789ABC123GH", "zone_id": "01JKXYZ789ABC456DEF123GH", "name": "require-token-credentials", "description": "Require token credential type for all application access", "owner_type": "customer", "created_at": "2026-03-03T10:00:00Z", "updated_at": "2026-03-03T10:00:00Z", "archived_at": null } ```
Save the `id` from the response. You'll need it for the next step. **Postman:** Create a named policy container. You'll add the Cedar content as a version in the next step. ```bash frame="none" curl -X POST https://api.keycard.ai/zones/{{zone_id}}/policies \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{ "name": "require-token-credentials", "description": "Require token credential type for all application access" }' ```
Example response ```json { "id": "01JKXYZ456DEF789ABC123GH", "zone_id": "01JKXYZ789ABC456DEF123GH", "name": "require-token-credentials", "description": "Require token credential type for all application access", "owner_type": "customer", "created_at": "2026-03-03T10:00:00Z", "updated_at": "2026-03-03T10:00:00Z", "archived_at": null } ```
Save the `id` from the response. You'll need it for the next step. **Console:** Navigate to **Policies** in the sidebar and click **Create policy**. Enter a **name** (e.g., `require-token-credentials`) and click **Create**. > **Note:** Creating a policy opens the policy editor, where you author its first version. Continue to the next step to configure the policy rules.
3.
**Author the policy version** > **Note: Policy** ```cedar @id("require-token-credentials") forbid ( principal is Keycard::Application, action, resource ) unless { principal has credential_type && principal.credential_type == Keycard::CredentialType::"token" }; ``` **Python:** Create an immutable policy version with Cedar content and a schema version. Submit the policy as `cedar_raw` (human readable Cedar syntax). The API also accepts `cedar_json` (JSON AST). You must provide exactly one. ```python cedar_policy = """\ @id("require-token-credentials") forbid ( principal is Keycard::Application, action, resource ) unless { principal has credential_type && principal.credential_type == Keycard::CredentialType::"token" };""" version = requests.post( f"{base}/zones/{zone_id}/policies/{policy_id}/versions", headers=headers, json={ "cedar_raw": cedar_policy, "schema_version": "2026-06-18", }, ).json() version_id = version["id"] ```
Example response ```json { "id": "01JKXYZ789ABC456DEF123GH", "policy_id": "01JKXYZ456DEF789ABC123GH", "version": 1, "schema_version": "2026-06-18", "cedar_raw": "@id(\"require-token-credentials\")\nforbid (\n principal is Keycard::Application,\n action,\n resource\n) unless {\n principal has credential_type && principal.credential_type == \"token\"\n};", "content_sha256": "a3f5d8c9e2b1a4c7d6e5f8a9b2c1d4e7", "created_at": "2026-03-03T10:01:00Z", "archived_at": null } ```
> **Note:** Keycard validates the Cedar policy against the schema before storing the version. If validation fails, the API returns a 400 error with details about what went wrong. Save the `id` from the response. This is the `policy_version_id` you'll reference in the policy set manifest. **HTTP:** Create an immutable policy version with Cedar content and a schema version. Submit the policy as `cedar_raw` (human readable Cedar syntax). The API also accepts `cedar_json` (JSON AST). You must provide exactly one. ```bash frame="none" curl -X POST https://api.keycard.ai/zones/$ZONE_ID/policies/$POLICY_ID/versions \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "cedar_raw": "@id(\"require-token-credentials\")\nforbid (\n principal is Keycard::Application,\n action,\n resource\n) unless {\n principal has credential_type && principal.credential_type == Keycard::CredentialType::\"token\"\n};", "schema_version": "2026-06-18" }' ```
Example response ```json { "id": "01JKXYZ789ABC456DEF123GH", "policy_id": "01JKXYZ456DEF789ABC123GH", "version": 1, "schema_version": "2026-06-18", "cedar_raw": "@id(\"require-token-credentials\")\nforbid (\n principal is Keycard::Application,\n action,\n resource\n) unless {\n principal has credential_type && principal.credential_type == \"token\"\n};", "content_sha256": "a3f5d8c9e2b1a4c7d6e5f8a9b2c1d4e7", "created_at": "2026-03-03T10:01:00Z", "archived_at": null } ```
> **Note:** Keycard validates the Cedar policy against the schema before storing the version. If validation fails, the API returns a 400 error with details about what went wrong. Save the `id` from the response. This is the `policy_version_id` you'll reference in the policy set manifest. **Postman:** Create an immutable policy version with Cedar content and a schema version. Submit the policy as `cedar_raw` (human readable Cedar syntax). The API also accepts `cedar_json` (JSON AST). You must provide exactly one. ```bash frame="none" curl -X POST https://api.keycard.ai/zones/{{zone_id}}/policies/{{policy_id}}/versions \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{ "cedar_raw": "@id(\"require-token-credentials\")\nforbid (\n principal is Keycard::Application,\n action,\n resource\n) unless {\n principal has credential_type && principal.credential_type == Keycard::CredentialType::\"token\"\n};", "schema_version": "2026-06-18" }' ```
Example response ```json { "id": "01JKXYZ789ABC456DEF123GH", "policy_id": "01JKXYZ456DEF789ABC123GH", "version": 1, "schema_version": "2026-06-18", "cedar_raw": "@id(\"require-token-credentials\")\nforbid (\n principal is Keycard::Application,\n action,\n resource\n) unless {\n principal has credential_type && principal.credential_type == \"token\"\n};", "content_sha256": "a3f5d8c9e2b1a4c7d6e5f8a9b2c1d4e7", "created_at": "2026-03-03T10:01:00Z", "archived_at": null } ```
> **Note:** Keycard validates the Cedar policy against the schema before storing the version. If validation fails, the API returns a 400 error with details about what went wrong. Save the `id` from the response. This is the `policy_version_id` you'll reference in the policy set manifest. **Console:** Use the rule builder to configure the policy effect, principal, and conditions. The **Cedar preview** sidebar on the right shows the generated Cedar in real time. 1. Set the **effect** to `forbid` 2. Set the **principal** to `Keycard::Application` 3. Add an **unless** condition: `principal has credential_type && principal.credential_type == "token"` 4. Click **Publish policy** to save the policy and its first version > **Note:** The Console validates the Cedar against the schema before saving. If validation fails, you'll see inline errors in the rule builder.
Example response ```json { "id": "01JKXYZ789ABC456DEF123GH", "policy_id": "01JKXYZ456DEF789ABC123GH", "version": 1, "schema_version": "2026-06-18", "cedar_raw": "@id(\"require-token-credentials\")\nforbid (\n principal is Keycard::Application,\n action,\n resource\n) unless {\n principal has credential_type && principal.credential_type == Keycard::CredentialType::\"token\"\n};", "content_sha256": "a3f5d8c9e2b1a4c7d6e5f8a9b2c1d4e7", "created_at": "2026-03-03T10:01:00Z", "archived_at": null } ```
> **Note:** Keycard validates the Cedar policy against the schema before storing the version. If validation fails, the API returns a 400 error with details about what went wrong. Save the `id` from the response. This is the `policy_version_id` you'll reference in the policy set manifest.
4.
**Create a policy set** **Python:** Create a policy set that will serve as the deployment unit for your policies. ```python policy_set = requests.post( f"{base}/zones/{zone_id}/policy-sets", headers=headers, json={ "name": "custom-zone-policies", "scope_type": "zone", }, ).json() policy_set_id = policy_set["id"] ```
Example response ```json { "id": "01JKXYZ123ABC789DEF456GH", "zone_id": "01JKXYZ789ABC456DEF123GH", "name": "custom-zone-policies", "scope_type": "zone", "owner_type": "customer", "created_at": "2026-03-03T10:02:00Z", "updated_at": "2026-03-03T10:02:00Z", "archived_at": null } ```
Save the `id` from the response. **HTTP:** Create a policy set that will serve as the deployment unit for your policies. ```bash frame="none" curl -X POST https://api.keycard.ai/zones/$ZONE_ID/policy-sets \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "custom-zone-policies", "scope_type": "zone" }' ```
Example response ```json { "id": "01JKXYZ123ABC789DEF456GH", "zone_id": "01JKXYZ789ABC456DEF123GH", "name": "custom-zone-policies", "scope_type": "zone", "owner_type": "customer", "created_at": "2026-03-03T10:02:00Z", "updated_at": "2026-03-03T10:02:00Z", "archived_at": null } ```
Save the `id` from the response. **Postman:** Create a policy set that will serve as the deployment unit for your policies. ```bash frame="none" curl -X POST https://api.keycard.ai/zones/{{zone_id}}/policy-sets \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{ "name": "custom-zone-policies", "scope_type": "zone" }' ```
Example response ```json { "id": "01JKXYZ123ABC789DEF456GH", "zone_id": "01JKXYZ789ABC456DEF123GH", "name": "custom-zone-policies", "scope_type": "zone", "owner_type": "customer", "created_at": "2026-03-03T10:02:00Z", "updated_at": "2026-03-03T10:02:00Z", "archived_at": null } ```
Save the `id` from the response. **Console:** Navigate to the **Policy Sets** tab and click **New Policy Set**. Enter a **name** (e.g., `custom-zone-policies`), then use the **Add policy** combobox to select the policies you want to include.
5.
**Create a policy set version** **Python:** Create an immutable policy set version with a manifest that references your custom policy version. > **Caution:** Your policy set manifest should include the platform-managed default policies to avoid blocking legitimate traffic. ```python ps_version = requests.post( f"{base}/zones/{zone_id}/policy-sets/{policy_set_id}/versions", headers=headers, json={ "manifest": { "entries": [ { "policy_id": policy_id, "policy_version_id": version_id, } ] }, "schema_version": "2026-06-18", }, ).json() ps_version_id = ps_version["id"] ```
Example response ```json { "id": "01JKXYZ456DEF123ABC789GH", "policy_set_id": "01JKXYZ123ABC789DEF456GH", "version": 1, "schema_version": "2026-06-18", "manifest": { "entries": [ { "policy_id": "01JKXYZ456DEF789ABC123GH", "policy_version_id": "01JKXYZ789ABC456DEF123GH" } ] }, "manifest_sha256": "b7e9d2a5c8f1d4e7a9b2c5d8e1f4a7b9", "created_at": "2026-03-03T10:03:00Z", "archived_at": null } ```
Save the `id` from the response. **HTTP:** Create an immutable policy set version with a manifest that references your custom policy version. > **Caution:** Your policy set manifest should include the platform-managed default policies to avoid blocking legitimate traffic. ```bash frame="none" curl -X POST https://api.keycard.ai/zones/$ZONE_ID/policy-sets/$POLICY_SET_ID/versions \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "manifest": { "entries": [ { "policy_id": "", "policy_version_id": "" } ] }, "schema_version": "2026-06-18" }' ```
Example response ```json { "id": "01JKXYZ456DEF123ABC789GH", "policy_set_id": "01JKXYZ123ABC789DEF456GH", "version": 1, "schema_version": "2026-06-18", "manifest": { "entries": [ { "policy_id": "01JKXYZ456DEF789ABC123GH", "policy_version_id": "01JKXYZ789ABC456DEF123GH" } ] }, "manifest_sha256": "b7e9d2a5c8f1d4e7a9b2c5d8e1f4a7b9", "created_at": "2026-03-03T10:03:00Z", "archived_at": null } ```
Save the `id` from the response. **Postman:** Create an immutable policy set version with a manifest that references your custom policy version. > **Caution:** Your policy set manifest should include the platform-managed default policies to avoid blocking legitimate traffic. The Postman collection automatically handles this by merging your custom policy with the managed policy entries. ```bash frame="none" curl -X POST https://api.keycard.ai/zones/{{zone_id}}/policy-sets/{{policy_set_id}}/versions \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{{ps_version_body}}' ``` The pre-request script in the Postman collection automatically builds the request body by merging your custom policy entry with the managed policy entries.
Example response ```json { "id": "01JKXYZ456DEF123ABC789GH", "policy_set_id": "01JKXYZ123ABC789DEF456GH", "version": 1, "schema_version": "2026-06-18", "manifest": { "entries": [ { "policy_id": "01JKXYZ456DEF789ABC123GH", "policy_version_id": "01JKXYZ789ABC456DEF123GH" } ] }, "manifest_sha256": "b7e9d2a5c8f1d4e7a9b2c5d8e1f4a7b9", "created_at": "2026-03-03T10:03:00Z", "archived_at": null } ```
Save the `id` from the response. **Console:** Select the desired policy versions using the version dropdowns for each policy in the set. Then click **Publish as Candidate**. Confirm the publish in the dialog. This creates an immutable policy set version with the selected policy versions pinned in its manifest.
Example response ```json { "id": "01JKXYZ456DEF123ABC789GH", "policy_set_id": "01JKXYZ123ABC789DEF456GH", "version": 1, "schema_version": "2026-06-18", "manifest": { "entries": [ { "policy_id": "01JKXYZ456DEF789ABC123GH", "policy_version_id": "01JKXYZ789ABC456DEF123GH" } ] }, "manifest_sha256": "b7e9d2a5c8f1d4e7a9b2c5d8e1f4a7b9", "created_at": "2026-03-03T10:03:00Z", "archived_at": null } ```
Save the `id` from the response.
6.
**Activate the policy set** **Python:** Bind the policy set version as the active policy set for your zone. ```python response = requests.patch( f"{base}/zones/{zone_id}/policy-sets/{policy_set_id}/versions/{ps_version_id}", headers=headers, json={"active": True}, ).json() ```
Example response ```json { "id": "01JKXYZ456DEF123ABC789GH", "policy_set_id": "01JKXYZ123ABC789DEF456GH", "version": 1, "schema_version": "2026-06-18", "manifest": { "entries": [ { "policy_id": "01JKXYZ456DEF789ABC123GH", "policy_version_id": "01JKXYZ789ABC456DEF123GH" } ] }, "manifest_sha256": "b7e9d2a5c8f1d4e7a9b2c5d8e1f4a7b9", "active": true, "created_at": "2026-03-03T10:03:00Z", "archived_at": null } ```
**HTTP:** Bind the policy set version as the active policy set for your zone. ```bash frame="none" curl -X PATCH https://api.keycard.ai/zones/$ZONE_ID/policy-sets/$POLICY_SET_ID/versions/$VERSION_ID \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"active": true}' ```
Example response ```json { "id": "01JKXYZ456DEF123ABC789GH", "policy_set_id": "01JKXYZ123ABC789DEF456GH", "version": 1, "schema_version": "2026-06-18", "manifest": { "entries": [ { "policy_id": "01JKXYZ456DEF789ABC123GH", "policy_version_id": "01JKXYZ789ABC456DEF123GH" } ] }, "manifest_sha256": "b7e9d2a5c8f1d4e7a9b2c5d8e1f4a7b9", "active": true, "created_at": "2026-03-03T10:03:00Z", "archived_at": null } ```
**Postman:** Bind the policy set version as the active policy set for your zone. ```bash frame="none" curl -X PATCH https://api.keycard.ai/zones/{{zone_id}}/policy-sets/{{policy_set_id}}/versions/{{version_id}} \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{"active": true}' ```
Example response ```json { "id": "01JKXYZ456DEF123ABC789GH", "policy_set_id": "01JKXYZ123ABC789DEF456GH", "version": 1, "schema_version": "2026-06-18", "manifest": { "entries": [ { "policy_id": "01JKXYZ456DEF789ABC123GH", "policy_version_id": "01JKXYZ789ABC456DEF123GH" } ] }, "manifest_sha256": "b7e9d2a5c8f1d4e7a9b2c5d8e1f4a7b9", "active": true, "created_at": "2026-03-03T10:03:00Z", "archived_at": null } ```
**Console:** From the **Policy Sets** list, click the candidate policy set to open its detail page. Click the **Activate** button. Confirm the activation in the dialog. This deploys the policy set zone-wide and atomically replaces the currently active policy set.
Example response ```json { "id": "01JKXYZ456DEF123ABC789GH", "policy_set_id": "01JKXYZ123ABC789DEF456GH", "version": 1, "schema_version": "2026-06-18", "manifest": { "entries": [ { "policy_id": "01JKXYZ456DEF789ABC123GH", "policy_version_id": "01JKXYZ789ABC456DEF123GH" } ] }, "manifest_sha256": "b7e9d2a5c8f1d4e7a9b2c5d8e1f4a7b9", "active": true, "created_at": "2026-03-03T10:03:00Z", "archived_at": null } ```
7.
**Verify** **Python:** Confirm the policy set is active for your zone. ```python policy_sets = requests.get( f"{base}/zones/{zone_id}/policy-sets", headers=headers, ).json() for ps in policy_sets["items"]: print(ps["name"], ps.get("active"), ps.get("mode")) ```
Example response ```json { "items": [ { "id": "01JKXYZ123ABC789DEF456GH", "zone_id": "01JKXYZ789ABC456DEF123GH", "name": "custom-zone-policies", "scope_type": "zone", "owner_type": "customer", "active": true, "mode": "active", "created_at": "2026-03-03T10:02:00Z", "updated_at": "2026-03-03T10:03:00Z" } ] } ```
You should see your policy set with `"active": true` and `"mode": "active"`. **HTTP:** Confirm the policy set is active for your zone. ```bash frame="none" curl https://api.keycard.ai/zones/$ZONE_ID/policy-sets \ -H "Authorization: Bearer $ACCESS_TOKEN" ```
Example response ```json { "items": [ { "id": "01JKXYZ123ABC789DEF456GH", "zone_id": "01JKXYZ789ABC456DEF123GH", "name": "custom-zone-policies", "scope_type": "zone", "owner_type": "customer", "active": true, "mode": "active", "created_at": "2026-03-03T10:02:00Z", "updated_at": "2026-03-03T10:03:00Z" } ] } ```
You should see your policy set with `"active": true` and `"mode": "active"`. **Postman:** Confirm the policy set is active for your zone. ```bash frame="none" curl https://api.keycard.ai/zones/{{zone_id}}/policy-sets \ -H "Authorization: Bearer {{token}}" ```
Example response ```json { "items": [ { "id": "01JKXYZ123ABC789DEF456GH", "zone_id": "01JKXYZ789ABC456DEF123GH", "name": "custom-zone-policies", "scope_type": "zone", "owner_type": "customer", "active": true, "mode": "active", "created_at": "2026-03-03T10:02:00Z", "updated_at": "2026-03-03T10:03:00Z" } ] } ```
You should see your policy set with `"active": true` and `"mode": "active"`. **Console:** Navigate to the **Policy Sets** tab. Your active policy set is indicated by a green accent bar and an **Active Policy Set** badge. Verify that your custom policy set shows as active with the correct policies listed.
8.
**Rollback** **Python:** To revert to the previous managed policy set, re-activate the platform-owned policy set version that was active before your custom set replaced it. ```python response = requests.patch( f"{base}/zones/{zone_id}/policy-sets/{managed_ps_id}/versions/{managed_ps_version_id}", headers=headers, json={"active": True}, ).json() ```
Example response ```json { "id": "01JKXYZ999AAA888BBB777CC", "policy_set_id": "01JKXYZ789DEF456ABC123GH", "version": 2, "schema_version": "2026-06-18", "active": true, "created_at": "2026-03-02T21:41:29Z", "archived_at": null } ```
The managed policy set is now active again, restoring the previous authorization baseline. **HTTP:** To revert to the previous managed policy set, re-activate the platform-owned policy set version that was active before your custom set replaced it. ```bash frame="none" curl -X PATCH https://api.keycard.ai/zones/$ZONE_ID/policy-sets/$MANAGED_PS_ID/versions/$MANAGED_PS_VERSION_ID \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"active": true}' ```
Example response ```json { "id": "01JKXYZ999AAA888BBB777CC", "policy_set_id": "01JKXYZ789DEF456ABC123GH", "version": 2, "schema_version": "2026-06-18", "active": true, "created_at": "2026-03-02T21:41:29Z", "archived_at": null } ```
The managed policy set is now active again, restoring the previous authorization baseline. **Postman:** To revert to the previous managed policy set, re-activate the platform-owned policy set version that was active before your custom set replaced it. ```bash frame="none" curl -X PATCH https://api.keycard.ai/zones/{{zone_id}}/policy-sets/{{managed_ps_id}}/versions/{{managed_ps_version_id}} \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{"active": true}' ```
Example response ```json { "id": "01JKXYZ999AAA888BBB777CC", "policy_set_id": "01JKXYZ789DEF456ABC123GH", "version": 2, "schema_version": "2026-06-18", "active": true, "created_at": "2026-03-02T21:41:29Z", "archived_at": null } ```
The managed policy set is now active again, restoring the previous authorization baseline. **Console:** Open the active policy set and use the **version selector** to switch to the previous version. Click **Activate** on that version to restore it. The previous version becomes active immediately. Keycard atomically replaces the current policy set, restoring the previous authorization baseline.
Example response ```json { "id": "01JKXYZ999AAA888BBB777CC", "policy_set_id": "01JKXYZ789DEF456ABC123GH", "version": 2, "schema_version": "2026-06-18", "active": true, "created_at": "2026-03-02T21:41:29Z", "archived_at": null } ```
The managed policy set is now active again, restoring the previous authorization baseline.
## Examples These examples show common policy patterns for real-world deployments. Each includes the Cedar policy, an explanation of when to use it, and API calls to create it.
Prevent shadow server access by requiring token-based credentials This policy blocks applications that don't use token-based credentials from accessing resources. It prevents shadow servers (services using static secrets instead of short-lived tokens) from obtaining upstream access through Keycard. Applications configured with workload identity federation receive a `credential_type` of `"token"`, meaning they authenticate with short-lived, verifiable tokens rather than long-lived secrets. This `forbid` policy denies access to any application that doesn't meet that requirement. The `credential_type` attribute is a `Keycard::CredentialType` enum entity. Other possible values are `Keycard::CredentialType::"password"` (client ID & secret), `Keycard::CredentialType::"public-key"` (key-based assertion), `Keycard::CredentialType::"url"` (URL-based identity), and `Keycard::CredentialType::"public"` (no secret). The attribute is optional and may be absent if the application's credential type is not yet determined. See the [entity properties](#entity-properties) reference for the full list. > **Note: Policy** ```cedar @id("require-workload-identity") forbid ( principal is Keycard::Application, action, resource ) unless { principal has credential_type && principal.credential_type == Keycard::CredentialType::"token" }; ``` **Python:** ```python import requests base = "https://api.keycard.ai" headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"} policy = requests.post( f"{base}/zones/{zone_id}/policies", headers=headers, json={ "name": "require-workload-identity", "description": "Block applications without token-based credentials to prevent shadow server access", }, ).json() cedar_policy = """\ @id("require-workload-identity") forbid ( principal is Keycard::Application, action, resource ) unless { principal has credential_type && principal.credential_type == Keycard::CredentialType::"token" };""" version = requests.post( f"{base}/zones/{zone_id}/policies/{policy['id']}/versions", headers=headers, json={ "cedar_raw": cedar_policy, "schema_version": "2026-06-18", }, ).json() ``` **HTTP:** ```bash frame="none" curl -X POST https://api.keycard.ai/zones/$ZONE_ID/policies \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "require-workload-identity", "description": "Block applications without token-based credentials to prevent shadow server access" }' curl -X POST https://api.keycard.ai/zones/$ZONE_ID/policies/$POLICY_ID/versions \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "cedar_raw": "@id(\"require-workload-identity\")\nforbid (\n principal is Keycard::Application,\n action,\n resource\n) unless {\n principal has credential_type && principal.credential_type == Keycard::CredentialType::\"token\"\n};", "schema_version": "2026-06-18" }' ``` **Postman:** **Create the policy:** ```bash frame="none" curl -X POST https://api.keycard.ai/zones/{{zone_id}}/policies \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{ "name": "require-workload-identity", "description": "Block applications without token-based credentials to prevent shadow server access" }' ``` **Create the policy version:** ```bash frame="none" curl -X POST https://api.keycard.ai/zones/{{zone_id}}/policies/{{policy_id}}/versions \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{ "cedar_raw": "@id(\"require-workload-identity\")\nforbid (\n principal is Keycard::Application,\n action,\n resource\n) unless {\n principal has credential_type && principal.credential_type == Keycard::CredentialType::\"token\"\n};", "schema_version": "2026-06-18" }' ```
Example response ```json { "id": "01JKXYZ000III111JJJ222KK", "policy_id": "01JKXYZ999HHH000III111JJ", "version": 1, "schema_version": "2026-06-18", "cedar_raw": "@id(\"require-workload-identity\")\nforbid (\n principal is Keycard::Application,\n action,\n resource\n) unless {\n principal has credential_type && principal.credential_type == Keycard::CredentialType::\"token\"\n};", "content_sha256": "e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1", "created_at": "2026-03-03T10:12:00Z", "archived_at": null } ```
Grant access based on upstream IdP groups This policy maps IdP group membership to resource access in Keycard. You manage who gets access from your existing identity provider (Okta, Entra ID, Google Workspace) rather than duplicating access rules. When a user authenticates through an upstream IdP, Keycard captures their group claims. This `permit` policy checks those claims at authorization time, granting access to users who belong to a specific IdP group. In this example, members of the "Engineering" group in Okta receive access to all resources. > **Note: Policy** ```cedar @id("permit-idp-engineering-group") permit ( principal is Keycard::User, action, resource ) when { context has subject_claims && context.subject_claims has groups && context.subject_claims.groups.contains("Engineering") }; ``` **Python:** ```python import requests base = "https://api.keycard.ai" headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"} policy = requests.post( f"{base}/zones/{zone_id}/policies", headers=headers, json={ "name": "permit-idp-engineering-group", "description": "Grant access to users in the Engineering group from the upstream IdP", }, ).json() cedar_policy = """\ @id("permit-idp-engineering-group") permit ( principal is Keycard::User, action, resource ) when { context has subject_claims && context.subject_claims has groups && context.subject_claims.groups.contains("Engineering") };""" version = requests.post( f"{base}/zones/{zone_id}/policies/{policy['id']}/versions", headers=headers, json={ "cedar_raw": cedar_policy, "schema_version": "2026-06-18", }, ).json() ``` **HTTP:** ```bash frame="none" curl -X POST https://api.keycard.ai/zones/$ZONE_ID/policies \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "permit-idp-engineering-group", "description": "Grant access to users in the Engineering group from the upstream IdP" }' curl -X POST https://api.keycard.ai/zones/$ZONE_ID/policies/$POLICY_ID/versions \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "cedar_raw": "@id(\"permit-idp-engineering-group\")\npermit (\n principal is Keycard::User,\n action,\n resource\n) when {\n context has subject_claims &&\n context.subject_claims has groups &&\n context.subject_claims.groups.contains(\"Engineering\")\n};", "schema_version": "2026-06-18" }' ``` **Postman:** **Create the policy:** ```bash frame="none" curl -X POST https://api.keycard.ai/zones/{{zone_id}}/policies \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{ "name": "permit-idp-engineering-group", "description": "Grant access to users in the Engineering group from the upstream IdP" }' ``` **Create the policy version:** ```bash frame="none" curl -X POST https://api.keycard.ai/zones/{{zone_id}}/policies/{{policy_id}}/versions \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{ "cedar_raw": "@id(\"permit-idp-engineering-group\")\npermit (\n principal is Keycard::User,\n action,\n resource\n) when {\n context has subject_claims &&\n context.subject_claims has groups &&\n context.subject_claims.groups.contains(\"Engineering\")\n};", "schema_version": "2026-06-18" }' ```
Example response ```json { "id": "01JKXYZ111JJJ222KKK333LL", "policy_id": "01JKXYZ000III111JJJ222KK", "version": 1, "schema_version": "2026-06-18", "cedar_raw": "@id(\"permit-idp-engineering-group\")\npermit (\n principal is Keycard::User,\n action,\n resource\n) when {\n context has subject_claims &&\n context.subject_claims has groups &&\n context.subject_claims.groups.contains(\"Engineering\")\n};", "content_sha256": "f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2", "created_at": "2026-03-03T10:13:00Z", "archived_at": null } ```
Grant access based on role membership This policy grants access to every principal that holds a Role, instead of enumerating individual users or applications. Role assignments are resolved at evaluation time, so adding or removing a member changes decisions immediately without a policy update. See [Role-based policies](#role-based-policies) for the full pattern reference. Role membership requires schema version `2026-03-16` or later. > **Note: Policy** ```cedar @id("permit-platform-admin-role") permit ( principal in Keycard::Role::"admin", action, resource ); ``` **Python:** ```python import requests base = "https://api.keycard.ai" headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"} policy = requests.post( f"{base}/zones/{zone_id}/policies", headers=headers, json={ "name": "permit-platform-admin-role", "description": "Grant access to members of the platform admin role", }, ).json() cedar_policy = """\ @id("permit-platform-admin-role") permit ( principal in Keycard::Role::"admin", action, resource );""" version = requests.post( f"{base}/zones/{zone_id}/policies/{policy['id']}/versions", headers=headers, json={ "cedar_raw": cedar_policy, "schema_version": "2026-06-18", }, ).json() ``` **HTTP:** ```bash frame="none" curl -X POST https://api.keycard.ai/zones/$ZONE_ID/policies \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "permit-platform-admin-role", "description": "Grant access to members of the platform admin role" }' curl -X POST https://api.keycard.ai/zones/$ZONE_ID/policies/$POLICY_ID/versions \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "cedar_raw": "@id(\"permit-platform-admin-role\")\npermit (\n principal in Keycard::Role::\"admin\",\n action,\n resource\n);", "schema_version": "2026-06-18" }' ``` **Postman:** **Create the policy:** ```bash frame="none" curl -X POST https://api.keycard.ai/zones/{{zone_id}}/policies \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{ "name": "permit-platform-admin-role", "description": "Grant access to members of the platform admin role" }' ``` **Create the policy version:** ```bash frame="none" curl -X POST https://api.keycard.ai/zones/{{zone_id}}/policies/{{policy_id}}/versions \ -H "Authorization: Bearer {{token}}" \ -H "Content-Type: application/json" \ -d '{ "cedar_raw": "@id(\"permit-platform-admin-role\")\npermit (\n principal in Keycard::Role::\"admin\",\n action,\n resource\n);", "schema_version": "2026-06-18" }' ```
Example response ```json { "id": "01JKXYZ222KKK333LLL444MM", "policy_id": "01JKXYZ111JJJ222KKK333LL", "version": 1, "schema_version": "2026-06-18", "cedar_raw": "@id(\"permit-platform-admin-role\")\npermit (\n principal in Keycard::Role::\"admin\",\n action,\n resource\n);", "content_sha256": "a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3", "created_at": "2026-07-20T10:14:00Z", "archived_at": null } ```
## Audit Log Integration Every policy mutation and authorization evaluation emits a structured audit event. These events give you a complete audit trail for compliance and debugging. ### Policy management events | Event Action | Trigger | |---|---| | policies:create | New policy created | | policies:update | Policy metadata updated | | policies:archive | Policy soft-deleted | | policy_versions:create | New immutable version created | | policy_sets:create | New policy set created | | policy_sets:update | Policy set metadata updated | | policy_set_versions:create | New policy set version created | | policy_set_versions:activate | Enable the policy set version | | policy_versions:archive | Policy version soft-deleted | | policy_sets:archive | Policy set soft-deleted | | policy_set_versions:archive | Policy set version soft-deleted | | policy_schema:set_default | Default schema changed for a zone | The [Activity Events registry](/reference/activity-events/) lists which of these also surface in the Console's per-entity Activity feeds. Archivals are audit-log only. ### Authorization evaluation events Keycard logs each authorization decision as a `policies:evaluate` event with: - **request_id** correlates the decision to the originating request - **decision** is the authorization outcome (allow or deny) - **determining_policies** lists the policy IDs that determined the outcome - **policy_set_id** identifies the active policy set that Keycard evaluated - **policy_set_version_id** identifies the specific version of the policy set that was evaluated - **evaluation_status** indicates whether all policies evaluated successfully (complete or partial) - **diagnostics** contains `{policy_id, message}` entries for evaluation errors or warnings - **evaluated_at** is the timestamp of when the evaluation occurred For manifest integrity verification, take the `policy_set_version_id` from the event and read `manifest_sha256` from the policy set version itself. The hash is a field on the policy set version object, not on the event. ### Privacy Audit events preserve privacy while maintaining a verifiable audit trail: - Keycard never logs Cedar source, only SHA-256 hashes of policy content - Entity attributes (email, name, scopes) never appear in plaintext - Keycard never logs JWT claims - Audit emission failures never block authorization decisions ## Diagnosing policy-blocked actions When a policy blocks a resource during token exchange, Keycard responds in one of two ways depending on whether the blocked resource is the **primary** resource or a **dependency**. ### Two types of policy denial | | Hard denial | Soft denial (dependency) | |---|---|---| | **What is blocked** | The primary resource in the token request | A dependency resource (e.g., a secondary API the primary resource depends on) | | **HTTP response** | access_denied error, no token issued | HTTP 200, token issued successfully | | **Signal** | error and error_description fields in the response body | The denied resource is silently dropped from the target claim in the issued token | | **Visibility** | Immediately obvious from the error response | Requires comparing requested resources against the token's target claim | > **Caution:** Soft denials are silent. The token endpoint returns HTTP 200 and the token is valid, but it contains fewer resources than you requested. If your agent assumes all requested resources will be present in the token, it will fail at the downstream API call, not at the token exchange. ### What you see in the error response (hard denial) A hard policy denial from the token endpoint looks like this: ```json { "error": "access_denied", "error_description": "Access denied by policy. Policy set: ps_abc123. Policy set version: psv_def456. Determining policies: policy-forbid-external-calendar.", "requestId": "req_a1b2c3" } ``` A **soft denial** returns a normal success response. The only clue is the missing resource in the token: ```json { "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 3600 } ``` Decode the `access_token` and inspect the `target` claim. If a resource you requested is missing, it was denied by policy. ### Detecting soft dependency denials When your agent requests multiple resources, compare what you asked for against what you received: **Python:** ```python import jwt token = jwt.decode(access_token, options={"verify_signature": False}) requested = ["https://api.google.com/calendar", "https://api.github.com"] granted = token.get("target", []) missing = [r for r in requested if r not in granted] if missing: print(f"Resources denied by policy: {missing}") # Use the requestId from the token response to find # policies:evaluate audit events with denial details ``` **TypeScript:** ```typescript const claims = decodeJwt(accessToken); const requested = ["https://api.google.com/calendar", "https://api.github.com"]; const granted = (claims.target as string[]) ?? []; const missing = requested.filter((r) => !granted.includes(r)); if (missing.length > 0) { console.log(`Resources denied by policy: ${missing.join(", ")}`); // Use the requestId from the token response to find // policies:evaluate audit events with denial details } ``` **Go:** ```go // After decoding the access token claims: requested := []string{"https://api.google.com/calendar", "https://api.github.com"} granted := claims.Target // []string from the "target" claim missing := []string{} for _, r := range requested { found := false for _, g := range granted { if r == g { found = true; break } } if !found { missing = append(missing, r) } } if len(missing) > 0 { log.Printf("Resources denied by policy: %v", missing) // Use the requestId from the token response to find // policies:evaluate audit events with denial details } ``` > **Note:** For custom-scheme apps (desktop/CLI), the HTML success page includes a yellow policy warning banner with the policy set ID when a dependency is denied. This is a UI-only signal and is not present in the token response itself. ### MCP SDK: handling both hard and soft denials If you are using the MCP SDK, hard policy denials surface through `AccessContext` errors. For soft denials, check which resources were actually granted: **Python:** ```python # Hard denial — access_denied error if access_context.has_errors(): errors = access_context.get_errors() # errors contains the policy denial details # Soft denial — check granted vs failed resources successful = access_context.get_successful_resources() failed = access_context.get_failed_resources() if failed: print(f"Resources denied by policy: {failed}") ``` **TypeScript:** ```typescript // Hard denial — access_denied error if (accessContext.hasErrors()) { const errors = accessContext.getErrors(); // errors contains the policy denial details } // Soft denial — decode the JWT target claim to check granted resources // (TS SDK does not yet have resource enumeration methods) const claims = decodeJwt(accessToken); const granted = (claims.target as string[]) ?? []; if (!granted.includes("https://api.github.com")) { // This resource was silently denied by policy } ``` **Go:** ```go // Hard denial — access_denied error if ac.HasErrors() { // ac contains the policy denial details } // Soft denial — decode the JWT target claim to check granted resources // (Go SDK does not yet have resource enumeration methods) // After decoding the access token claims: granted := claims.Target // []string from the "target" claim // Check if your expected resource is in the granted list ``` > **Tip:** The Python MCP SDK provides `get_successful_resources()` and `get_failed_resources()` on `AccessContext`. TypeScript and Go SDK support for resource enumeration is coming soon. In the meantime, decode the JWT `target` claim to check which resources were granted. See the [MCP SDK documentation](/sdk/mcp) and [Credential Issuance](/concepts/credentials/) for full details on handling access errors. ### Key fields in a denial | Field | What it tells you | |---|---| | error | Always access_denied for policy denials | | error_description | Human-readable message containing the policy set ID, policy set version ID, and determining policy IDs | | requestId | Correlates this denial with policies:evaluate audit events for the full evaluation context | > **Note:** The token endpoint returns policy details as a human-readable `error_description` string. For the full machine-readable evaluation (including `evaluation_status`, `diagnostics`, and `evaluated_at`), query the audit log using the `requestId`. ### Common deny scenarios | Scenario | What you see | Resolution | |---|---|---| | No matching permit (default deny) | access_denied with no determining policies listed | Add a permit policy that covers the actor, action, and resource. | | Explicit forbid matched | access_denied with determining policy IDs in the description | Inspect the listed forbid policies in Console. A forbid always overrides a permit. | | Partial evaluation | Audit log shows evaluation_status: "partial" with diagnostics entries | Check diagnostic messages for schema mismatches or malformed entity references. Fix the flagged policies. | | No active policy set version | HTTP 422 from the management API | No policy set version is active in the zone. Activate a policy set version first. | | Entity not found | HTTP 400 from the token endpoint | The actor or resource in the request does not exist in the zone. Verify entity IDs. | | Dependency denied by policy | Token issued but resource missing from target claim | Check audit log with requestId for the dependency denial. Add a permit policy covering the dependency resource. | ### Step-by-step debugging workflow 1. **Check the error response** Look at the `error_description` from the token endpoint. If it lists determining policy IDs, those policies explicitly blocked the action. If no determining policies are listed, this is a default deny because no permit policy matched. 2. **If the token was issued but a resource is missing** This is a soft dependency denial. The primary resource was allowed, but a dependency was denied by policy. Decode the access token and compare the `target` claim against the resources you requested. Use the `requestId` from the token response to find the specific `policies:evaluate` audit event showing which dependency was denied and why. 3. **If no determining policies, review your permit policies** No policy explicitly matched the request. Verify that a permit policy exists for the correct combination of actor, action, and resource. Check that the entity types and IDs match what the policy references. 4. **If determining policies are listed, inspect them in Console** Those are forbid policies that blocked the action. Open them in Console to review their conditions. Remember that forbid always takes precedence over permit. 5. **Check the audit log for full evaluation details** Filter audit logs by the `requestId` from the error response. The `policies:evaluate` event contains the complete evaluation context: - `evaluation_status` shows whether all policies evaluated (`complete`) or some failed (`partial`) - `diagnostics` contains `{policy_id, message}` entries for any evaluation errors (schema mismatches, etc.) - `policy_set_version_id` is the exact version that was evaluated. Fetch that policy set version to check its `manifest_sha256` integrity hash, which lives on the version object rather than on the event 6. **If evaluation was partial, fix the flagged policies** When `evaluation_status` is `partial`, the `diagnostics` array explains what went wrong. Common causes include schema mismatches, missing entity attributes, or malformed policy syntax. Fix the flagged policies and re-evaluate. > **Tip:** In Console, filter audit logs by `requestId` to see the full context of any authorization decision. This is the fastest way to get the machine-readable evaluation details that the token endpoint does not include directly. - [Audit Log Export](/admin/audit-log-export) ## https://docs.keycard.ai/admin/activities # Read an Activity Feed Every credential issued, policy decision, [Provider](/concepts/providers/) check, and configuration change is recorded as an activity. The **Activity** feed on an entity's page shows only the activities that touched that entity, in order; the same record is also visible Zone-wide in the [Audit Log](/admin/audit-log-and-sessions/) and per agent run in [Sessions](/admin/audit-log-and-sessions/). If an agent was denied a credential, the feed is the fastest place to see it, and the [troubleshooting tips](#troubleshooting) below cover when to switch to the other two views. ## Before you begin - Sign in to the [Keycard Console](https://console.keycard.ai) with the **Viewer** [Role](/admin/roles-and-permissions/) or higher. - The entity the activity touched: **Activity** is a tab on the detail page for a Resource, Provider, Application, or [User](/concepts/users/); **Policy Activity** has its own entry in the left nav and covers the whole Zone. ## Read an activity Say an agent asked for a credential for a billing API Resource and a policy turned it down. To find out what happened: 1. **Open the entity's Activity feed** Go to the Resource's detail page and select the **Activity** tab. Each row is one activity. For policy activities, open **Policy Activity** from the left nav instead. 2. **Scan the row** - **Outcome** shows a red **denied** indicator. An issued credential would show green **allowed**, configuration and service activities get a neutral marker, and an inconclusive activity shows a warning. - **Action** names the activity, here *Credential issued*, with a status message explaining that the request was denied. - **Actor** is who performed it. Here it is the agent, but it can also be a [User](/concepts/users/) or Keycard itself. 3. **Open the detail panel** Select the row and read the panel: - **Overview** carries the Event ID, the Request ID, when it occurred, and severity. - **Reason** says why the request was denied, in this case naming the deciding [policy](/admin/access-policies/). - **Actor** and **Related entity** identify the agent behind the call and the billing API Resource it targeted. - The middle of the panel depends on the activity type. A successful credential issuance shows the grant and scopes, a Provider validation lists each configuration check, and a policy evaluation shows the decision breakdown. - **Raw OCSF** is the full machine-readable record, in the same [OCSF](/admin/audit-log-export/) shape used for export. 4. **Narrow the feed** Two filters sit above the feed: **Outcome** (All, Success, or Failure) and **Time range** (last hour, 24 hours, 7 days, or 30 days). Filter to **Failure** to see every denial on this entity at once. ## Verify Copy the **Request ID** from the panel's **Overview** and search for it in the [Audit Log](/admin/audit-log-and-sessions/). The activity appears there as an audit event, alongside any others from the same request, confirming you found the decision you were looking for. ## Troubleshooting - An activity you expect isn't in the feed: feeds only surface the actions in the [Activity Events registry](/reference/activity-events/). Deletions, archivals, and lower-level operations land in the [Audit Log](/admin/audit-log-and-sessions/) and in [Audit Log Export](/admin/audit-log-export/). - You need more than the entity's slice: the feed's filters stop at Outcome and Time range. Use the [Audit Log](/admin/audit-log-and-sessions/) to filter by action type, request ID, session, or category across the Zone, or open [Sessions](/admin/audit-log-and-sessions/) to follow one agent's authenticated run in order. - The same activity shows on two feeds: expected. An activity appears on the feed of each entity it touched, so `credentials:issue` shows on both the Resource and the Provider it involves. ## Related - [Activity Events](/reference/activity-events/): the registry of actions each feed surfaces - [Audit Log & Sessions](/admin/audit-log-and-sessions/) - [Audit Log Export](/admin/audit-log-export/) - [Access Policies](/admin/access-policies/) - [Revoke a Grant](/admin/revoke-a-grant/) ## https://docs.keycard.ai/admin/audit-log-and-sessions # Reading the Audit Log & Sessions Keycard records every authentication, authorization, and credential it handles. The Console surfaces that record two ways: the **Audit Log** is the zone-wide stream of individual events, and **Sessions** organizes the same events around one authenticated session so you can follow an agent's activity in order. They draw from the same underlying events — the difference is the lens. This page is for reading those views: identifying who and what is on a single decision, tracing one action from sign-in to issued credential, telling the two views apart, and confirming that a change like a revocation actually took effect. > **Note: Audit Log vs. Sessions** - The **Audit Log** answers *"what happened across the Zone?"* — one filterable, searchable row per event, best for finding a specific decision or auditing broadly. - **Sessions** answers *"what did this agent do, in order?"* — one session's events laid out as a timeline, best for following a single run and seeing tool activity in context. Same events, two views. Most verification tasks start in one and confirm in the other. ## Before you begin - Sign in to the [Keycard Console](https://console.keycard.ai). Reading these views requires the **Viewer** [Role](/admin/roles-and-permissions/) or higher — Viewers can see sessions, users, and audit logs. - Both views are zone-scoped. If the activity you're verifying happened in a [custom Zone](/concepts/zones/), switch to that Zone first. - **Audit Log** and **Sessions** are separate items in the left nav. ## Read a single authorization decision Open the **Audit Log** from the left nav and select any event to expand its detail. A single event tells you the whole shape of one decision: - **Status** — `Success` or `Failure`. - **Actor Details** — the **identity chain** behind the call. Its **Type** is `identity_chain`, and it lists each identity in the delegation path: the [Application](/concepts/applications/) (the agent) and the [User](/concepts/users/) it acted for. This is how you read the composite identity on a decision — the User, the agent acting for them, and (through the entry below) the Resource they reached. - **Entity** — the [Resource](/concepts/resources/) the call targeted (for example, `Google Calendar API`). - **Raw Data** — the machine-readable record: the action (such as `credentials:issue`), the requested `scopes`, the `resource_path` (for example, `/oauth/2/token`), and — on a failure — the `error_code`. > **Note: The delegation chain** Keycard's [credentials](/concepts/credentials/#delegation-chaining) carry the full path of delegation. In a multi-hop flow — **User → agent → MCP server → downstream API** — each hop is its own credential exchange, and the identity chain lets you trace the original User through every intermediary. The chain is recorded in the audit trail; it's how a decision stays attributable to a person, not just a token. ## Trace one action end to end A single action moves through a predictable lifecycle. In the **Audit Log** you'll see the stages as separate events; in a **Session** timeline you'll see them stitched together in order: 1. **`users:authenticate`** — the caller's identity was verified. 2. **`users:authorize`** — their access was checked against [policy](/admin/access-policies/). 3. **`delegated_grants:create`** — a [grant](/admin/revoke-a-grant/) recorded the User's consent. 4. **`credentials:issue`** — Keycard issued the short-lived credential. To follow one run, open **Sessions** and select a session. The **Timeline** lays out that session's events oldest-to-newest, rendering each credential exchange as a delegation hop (`agent → credentials:issue → Resource`), so a two-hop flow reads as the client getting a token for the MCP server, then the MCP server getting a token for the downstream Resource. **Session Context** names the actor and owner, and **Resources Accessed** lists every Resource the session touched. ### Find both sides of a deny When a call is refused, the Audit Log records both the attempt and the reason. The denied call appears as a **Failure** event; the `error_code` in its **Raw Data** tells you which kind of denial it was: - **`access_denied`** — a [policy](/admin/access-policies/) denied the request. The **Error Message** names the deciding policy and policy set — for example, *Access to "Google Calendar API" is denied by Policy "restrict-calendar-access" in version 1 of Policy Set "default-zone-policies"* — so you can open **Policies** and read the rule that forbade it. - **`insufficient_authorization`** — there was no [grant](/admin/revoke-a-grant/) to satisfy the request (the User never consented, or the grant was revoked). This is distinct from a policy denial. So "both sides" of a deny are always available: the **Failure** event is the call, and the deciding policy (or missing grant) named in the error is the reason. ## Sessions vs. the Audit Log: when to look at which Use the **Audit Log** when you're starting from an event — searching by [Actor](/concepts/users/), action, Resource, or category (`API Call`, `Management Event`, `Service Event`), or filtering to `Failures` to find every refused call. Use **Sessions** when you're starting from an agent run and want its activity in order, including the tool calls made during that session. > **Note: Tool calls live in Sessions** Tool activity shows up in a session's **Timeline**, not as its own audit-log action — the Audit Log's actions are Keycard operations like `credentials:issue` and `users:authorize`. `Agent::ToolUse` is the Cedar policy [action](/admin/access-policies/) that governs a tool call, not an audit-log event name. Whether an individual tool call also produces a `credentials:issue` event depends on whether the server exchanges a Keycard credential for that call. ## Verify a revocation A common eval check is revoking access and confirming it took effect. The Audit Log shows both the change and its consequence: 1. **Revoke the grant.** Follow [Revoke a Grant](/admin/revoke-a-grant/). The grant's **Status** flips to **Revoked**. 2. **Trigger the agent again.** Its next credential request has no grant to satisfy it. 3. **Find both sides in the log.** The retry appears as a **Failure** `credentials:issue` event on `/oauth/2/token` with `error_code` `insufficient_authorization`, and the grant now shows as **Revoked**. From the grant's actions menu, **View in audit log** deep-links straight to the event. > **Caution: Credentials are short-lived, not killed** Revoking a grant stops the *next* credential from being issued; it does not kill a credential the agent already holds. An outstanding short-lived token keeps working against the Resource until it expires. See [what happens after you revoke](/admin/revoke-a-grant/#what-happens-after-you-revoke) for the full behavior. ## Where export fits Everything in the Audit Log can be streamed to your own AWS S3 bucket in OCSF (Parquet) format for your SIEM or data warehouse, delivered within the hour. Setup is a one-time, support-assisted step. - [Audit Log Export](/admin/audit-log-export/) ## Related - [Revoke a Grant](/admin/revoke-a-grant/) — withdraw an authorization and confirm it in the log - [Access Policies](/admin/access-policies/) — the rules that decide `access_denied` - [Credential Issuance](/concepts/credentials/) — how Keycard issues and chains the credentials you see - [Audit Log Export](/admin/audit-log-export/) — stream events to your SIEM - [Roles & Permissions](/admin/roles-and-permissions/) — who can view sessions and audit logs ## https://docs.keycard.ai/admin/configure-provider-apis/anthropic # Configure Anthropic Configure Workload Identity Federation (WIF) between Keycard and Anthropic. Your applications get Keycard OIDC tokens that Anthropic exchanges for short-lived API credentials — no static API keys anywhere. ## Prerequisites Before starting: - A [Keycard account](https://console.keycard.ai) - Anthropic Organization Owner or Admin role with access to [Workload Identity Federation settings](https://platform.claude.com/settings/workload-identity-federation) - Your Anthropic **Organization ID** (UUID) from [Settings → Organization](https://platform.claude.com/settings/organization) ## Configure Keycard 1. **Create the Anthropic resource** In [Keycard Console](https://console.keycard.ai), navigate to **Resources** → **Add Resource** → **Add Manually**. | Field | Value | | --- | --- | | **Resource Identifier** | `https://api.anthropic.com` | | **Credentials Provider** | Your zone provider | | **Credential Lifetime** | `1h` (under Advanced Settings) | > **Tip:** Selecting **Zone Provider** tells Keycard to issue OIDC tokens signed by the zone itself rather than brokering through an external OAuth flow. 2. **Create an application** Navigate to **Applications** → **Add Application**. | Field | Value | | --- | --- | | **Name** | e.g. `anthropic-workload` | Note the **Application ID** from the browser URL bar after creating. 3. **Link the resource** Open the application → **Dependencies** → **Add Dependency** → select `https://api.anthropic.com`. 4. **Create application credentials** (local development only) Open the application → **Application Credentials** → **Add Credential** → **Client ID & Secret**. Note the **Client ID** and **Client Secret** — the secret is shown once. > **Note:** Production workloads use [workload identity](/concepts/providers/#workload-identity) instead of client credentials. 5. **Note your Issuer URL** Copy your **Issuer URL** from Keycard Console under **Settings** → **Connection**. Anthropic needs this as the issuer URL. ## Configure Anthropic 1. **Create a service account** In [Anthropic Platform Console](https://platform.claude.com/settings/service-accounts), go to **Settings → Service accounts → Create service account**. | Field | Value | | --- | --- | | **Name** | e.g. `keycard-workload` | Note the service account ID (`svac_...`). 2. **Create a workspace** The Default Workspace has no ID and can't be used with WIF. Go to **Settings → [Workspaces](https://platform.claude.com/settings/workspaces) → Create workspace**. | Field | Value | | --- | --- | | **Name** | e.g. `keycard-workloads` | Note the workspace ID (`wrkspc_...`) from the workspaces list. 3. **Link the service account to the workspace** Select the workspace from the top navigation dropdown → **Manage → Service accounts → Add service account** → select the service account from step 1. 4. **Register Keycard as an issuer** In [Workload Identity Federation](https://platform.claude.com/settings/workload-identity-federation) settings, on the **Issuers** tab → **Create issuer**. | Field | Value | | --- | --- | | **Name** | e.g. `keycard-prod` | | **Issuer URL** | your Keycard Issuer URL, e.g. `` | | **JWKS source** | `discovery` | 5. **Create a federation rule** On the **Rules** tab → **New Rule**. | Field | Value | | --- | --- | | **Issuer** | Select your Keycard issuer | | **Match → Subject prefix** | Your Keycard Application ID | | **Target** | Your service account | | **Workspaces** | Select the workspace from step 2 | | **Scope** | `workspace:developer` | | **Token lifetime** | `3600` seconds | Note the rule ID (`fdrl_...`). > **Caution:** The service account must be a member of each selected workspace. Token exchanges fail silently if it isn't. ## Use from code Get a Keycard OIDC token and pass it to the Anthropic SDK for automatic WIF exchange. **Python:** ```python from keycardai.oauth import Client, BasicAuth from anthropic import Anthropic, WorkloadIdentityCredentials # 1. Get a Keycard OIDC token scoped to Anthropic. with Client( "", # from Settings → Connection auth=BasicAuth("", ""), ) as kc: token = kc.client_credentials_grant( resource="https://api.anthropic.com", ) # 2. Use it with the Anthropic SDK — WIF exchange happens automatically. client = Anthropic( credentials=WorkloadIdentityCredentials( identity_token_provider=lambda: token.access_token, federation_rule_id="", organization_id="", service_account_id="", workspace_id="", ), ) message = client.messages.create( model="claude-sonnet-4-6", max_tokens=1024, messages=[{"role": "user", "content": "Hello from a Keycard workload"}], ) print(message.content[0].text) ``` **TypeScript:** ```typescript // 1. Get a Keycard OIDC token scoped to Anthropic. const kc = new ClientCredentialsClient( "", // from Settings → Connection { clientId: "", clientSecret: "" }, ); const keycardToken = await kc.requestToken({ resource: "https://api.anthropic.com", }); // 2. Use it with the Anthropic SDK — WIF exchange happens automatically. const client = new Anthropic({ credentials: oidcFederationProvider({ identityTokenProvider: () => keycardToken.accessToken, federationRuleId: "", organizationId: "", serviceAccountId: "", workspaceId: "", baseURL: "https://api.anthropic.com", fetch, }), }); const message = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, messages: [{ role: "user", content: "Hello from a Keycard workload" }], }); console.log(message.content[0].text); ``` **Go:** ```go package main import ( "context" "fmt" "log" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" "github.com/keycardai/go-sdk/oauth" ) func main() { ctx := context.Background() // 1. Get a Keycard OIDC token scoped to Anthropic. kc := oauth.NewClientCredentialsClient( "", // from Settings → Connection oauth.WithCCBasicAuth("", ""), ) keycardToken, err := kc.RequestToken(ctx, oauth.ClientCredentialsRequest{ Resource: "https://api.anthropic.com", }) if err != nil { log.Fatal(err) } // 2. Use it with the Anthropic SDK — WIF exchange happens automatically. client := anthropic.NewClient( option.WithFederationTokenProvider( func(_ context.Context) (string, error) { return keycardToken.AccessToken, nil }, option.FederationOptions{ FederationRuleID: "", OrganizationID: "", ServiceAccountID: "", WorkspaceID: "", }, ), ) message, err := client.Messages.New(ctx, anthropic.MessageNewParams{ Model: "claude-sonnet-4-6", MaxTokens: 1024, Messages: []anthropic.MessageParam{ anthropic.NewUserMessage(anthropic.NewTextBlock("Hello from a Keycard workload")), }, }) if err != nil { log.Fatal(err) } fmt.Println(message.Content[0].Text) } ``` **Ruby:** ```ruby require "anthropic" require "keycardai/oauth" # 1. Get a Keycard OIDC token scoped to Anthropic. kc = Keycardai::OAuth::ClientCredentialsClient.new( issuer: "", # from Settings → Connection client_id: "", client_secret: "", ) keycard_token = kc.request_token(resource: "https://api.anthropic.com") # 2. Use it with the Anthropic SDK. WIF exchange happens automatically. client = Anthropic::Client.new( credentials: Anthropic::Credentials::WorkloadIdentity.new( identity_token_provider: -> { keycard_token.access_token }, federation_rule_id: "", organization_id: "", service_account_id: "", workspace_id: "", ), ) message = client.messages.create( model: "claude-sonnet-4-6", max_tokens: 1024, messages: [{role: "user", content: "Hello from a Keycard workload"}], ) puts message.content[0].text ``` **cURL:** ```bash # 1. Get a Keycard OIDC token scoped to Anthropic. # Replace with your Issuer URL from Settings → Connection. KC_TOKEN=$(curl -sS "/oauth/2/token" \ -u ":" \ -d "grant_type=client_credentials" \ -d "resource=https://api.anthropic.com" \ | jq -r .access_token) # 2. Exchange the Keycard token at Anthropic's WIF endpoint. ANTHROPIC_TOKEN=$(curl -sS https://api.anthropic.com/v1/oauth/token \ -H "content-type: application/json" \ -d "{ \"grant_type\": \"urn:ietf:params:oauth:grant-type:jwt-bearer\", \"assertion\": \"${KC_TOKEN}\", \"federation_rule_id\": \"\", \"organization_id\": \"\", \"service_account_id\": \"\", \"workspace_id\": \"\" }" | jq -r .access_token) # 3. Call the API. curl -sS https://api.anthropic.com/v1/messages \ -H "authorization: Bearer ${ANTHROPIC_TOKEN}" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -d '{ "model": "claude-sonnet-4-6", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello from a Keycard workload"}] }' | jq -r '.content[0].text' ``` > **Note:** All four Keycard SDKs support `client_credentials` with a `resource` parameter natively. The cURL tab shows the equivalent raw call against the token endpoint. ## Verify **In Keycard Console** — open **Audit Log**. Look for: | Event | Description | | --- | --- | | `credentials:issue` | OIDC token issued for `https://api.anthropic.com` | **In Anthropic Platform Console** — go to **Settings → Workload identity → Authentication events**. You should see the exchange with your zone's issuer URL and matched federation rule. ## Troubleshooting **Token exchange returns "service account not a member of workspace"** The service account must be explicitly added to the workspace. Go to the workspace → **Manage → Service accounts** and add it. **"Invalid issuer" error** The issuer URL in Anthropic must match the Keycard zone URL exactly, including the protocol (`https://`) and no trailing slash. **Federation rule doesn't match** Check that the **Subject prefix** in the rule matches your Keycard Application ID. The `sub` claim in the Keycard-issued JWT contains this value. ## https://docs.keycard.ai/admin/configure-provider-apis/overview # Provider APIs Overview Keycard issues short-lived, scoped credentials that external providers accept via Workload Identity Federation (WIF). Your application authenticates to Keycard, requests a token for a specific provider, and uses that token directly with the provider's API. No API keys stored anywhere.
Your App Uses credential with provider
Keycard-minted access token
Provider API Short-lived token, no secrets stored
Keycard Zone Issues scoped credential
Provider WIF Validates JWT, returns access token
## How it works 1. **Register the external API as a resource** and select your zone provider as the credentials issuer 2. **Configure the external provider** to trust your zone's OIDC issuer — each provider guide walks through this 3. **Your application authenticates** to Keycard and requests a token scoped to the external resource 4. **Keycard issues a short-lived OIDC JWT** signed by your zone, which the provider validates and exchanges for an access token For the conceptual model behind access federation and brokered credentials, see [Providers](/concepts/providers/#access-federation). ## Supported providers - [Anthropic](/admin/configure-provider-apis/anthropic/) ## https://docs.keycard.ai/admin/deployment # Deployment Keycard offers four different deployment models, providing customers with different characteristics and pricing models to suit the needs of developers, small companies, and the world's largest enterprises. - **Keycard Cloud**: Multi-tenanted deployment of Keycard without any Enterprise features such as SSO, Audit Log Export, Bring Your Own Key, and Private Networking. - **Keycard Dedicated Enterprise Cloud:** Single-tenanted deployment of a Data Plane in Keycard's Cloud for a specific Enterprise Customer with all Enterprise Features. The control plane is shared across all enterprise customers. - **Keycard Enterprise Bring Your Own Cloud (Coming Soon):** Ability to deploy a data plane tenant into a customer's cloud, ensuring, all traffic only passes through their VPC. By default, when you sign up for Keycard, your account will be assigned to Keycard Cloud. To gain access to our Enterprise deployment options, please contact sales@keycard.ai. --- ## Keycard Cloud (Multi-Tenant) Keycard's standard deployment model. Keycard uses a cell-based architecture which ensures high availability, performance and security. It consists of a control plane which manages provisioning zones across many Keycard managed data planes. Data planes are responsible for zone management (such as zone, resource, application creation) and zone operations (such as OAuth handshakes as well as token exchange). They are self-contained and operate without any control plane dependencies, ensuring high availability and reduced blast radius. ### Architecture ### Characteristics - **Shared Infrastructure**: Zones from multiple customers run on shared data planes - **Logical Isolation**: Each zone has isolated data encryption keys, credentials, and audit logs - **High Availability**: Automatic failover and redundancy across multiple cells - **Global Reach**: Access from anywhere via public endpoints - **Managed Operations**: Keycard handles all infrastructure management and scaling ### Best For - **Startups and scale-ups** getting started with Keycard - **Development and staging environments** before production deployment - **Applications without strict data residency requirements** - **Teams prioritizing speed and simplicity** over dedicated infrastructure ### Networking In the context of Keycard Cloud & Keycard Enterprise Cloud, all traffic flows through our WAF, and then, over a private-link to the data-plane. For customers with configured KMS Keys, traffic between our Vault and their KMS instance, travels over a private network. --- ## Enterprise Cloud (Dedicated Cell) A dedicated dataplane for your zones. Dedicated Enterprise Cloud comes with AWS PrivateLink secure connectivity to the Keycard cloud, enabling you to have a unidirectional communication link between your environment and Keycard Cloud. Keycard's Dedicated Enterprise Cloud provides runtime isolation, avoiding noisy neighbour problems or any potential memory sharing with other Keycard customers. This provides you with the highest trust in data privacy. Keycard's Dedicated Enterprise Cloud is unidirectional, and data flows from your cloud to Keycard Cloud. Keycard does not have access to your internal network. ### Architecture ### Characteristics - **Dedicated Data Plane**: Your zones run on infrastructure exclusively allocated to your organization - **AWS PrivateLink**: Unidirectional private connectivity from your VPC to Keycard - **Runtime Isolation**: Complete isolation from other Keycard customers at runtime - **No Noisy Neighbors**: Predictable performance without resource contention - **Private Network Traffic**: Data never traverses the public internet during runtime operations - **Enhanced Security**: Reduced attack surface with private connectivity ### Networking A dedicated dataplane for your zones. Dedicated Enterprise Cloud comes with AWS PrivateLink connectivity directly to the dataplane, enabling you to have a unidirectional communication link between your environment and Keycard Cloud. This ensures your data stays single tenant (your dedicated dataplane) and gives you increased high availability by bypassing Keycard's control plane. All data flows directly between your cloud and the Dedicated Enterprise Cloud, ensuring your data never leaves AWS's private network. Keycard's Dedicated Enterprise Cloud provides runtime isolation, avoiding noisy neighbour problems or any potential memory sharing with other Keycard customers. This provides you with the highest trust in data privacy. Keycard's Dedicated Enterprise Cloud is unidirectional, and data flows from your cloud to Keycard Cloud. Keycard does not have access to your internal network. ### Private Connectivity **AWS PrivateLink** provides a secure, private link between your infrastructure and Keycard: - **Unidirectional**: Traffic flows from your environment to Keycard only - **No Ingress**: Keycard cannot initiate connections into your network - **IP Allowlisting**: Optionally restrict access to specific IP ranges - **VPC Endpoints**: Connect directly from your VPC without internet gateway ### Console Access The Keycard Console operates at the control plane level for management operations: - **Console path**: Customer → Control Plane → Dedicated Cell - **Runtime path**: Customer → PrivateLink → Dedicated Cell (direct) > **Note:** **Recommended**: Use the Keycard Terraform Provider to manage zone configuration directly against your dedicated cell, bypassing the control plane entirely for infrastructure operations. ### Best For - **Enterprise production workloads** with compliance requirements - **Organizations requiring network isolation** (banking, healthcare, government) - **Customers with data residency mandates** requiring private network boundaries - **High-value applications** where performance consistency is critical - **Companies needing enhanced audit and compliance** capabilities ### Security Benefits | Feature | Benefit | | ---------------------- | ------------------------------------------------ | | Dedicated Runtime | No shared memory or compute with other customers | | Private Network | Data never leaves AWS's private network backbone | | IP Allowlisting | Restrict access to known networks only | | Audit Isolation | Your audit logs never mix with other customers | | Performance Guarantees | No resource contention from noisy neighbors | --- ## Comparison Table | Feature | Keycard Cloud | Enterprise Cloud | | ------------------------ | ----------------------------- | ------------------------- | | **Infrastructure** | Shared multi-tenant | Dedicated single-tenant | | **Network Access** | Public internet | AWS PrivateLink | | **Data Plane Isolation** | Logical (per-zone encryption) | Physical (dedicated cell) | | **Noisy Neighbor Risk** | Low (cell isolation) | None | | **Data Residency** | AWS regions | AWS regions (dedicated) | | **Setup Time** | Immediate | Days | | **Operational Burden** | Keycard managed | Keycard managed | | **Best For** | Most customers | Enterprise production | | **Pricing** | Standard | Premium | --- ## Choosing a Deployment Model ### Start with Keycard Cloud if you: - Are prototyping or in early development - Do not have strict data residency requirements - Want to minimize operational overhead - Need to get started quickly ### Upgrade to Enterprise Cloud if you: - Are deploying production workloads at scale - Require network isolation for compliance - Need predictable, isolated runtime performance - Have security policies requiring private connectivity - Want dedicated infrastructure for your organization --- ## Migration Paths ### Keycard Cloud → Enterprise Cloud **Process:** 1. Provision dedicated cell in your desired region 2. Configure AWS PrivateLink connection 3. Export zone configuration from Cloud deployment 4. Import configuration to Enterprise Cloud cell 5. Update application endpoints to use PrivateLink 6. Validate functionality 7. Cutover traffic **Downtime:** Typically < 1 hour with proper planning ## https://docs.keycard.ai/admin/groups # Groups A [Group](/concepts/groups/) is a named collection of Users in a [Zone](/concepts/zones/). Assign a Role to a Group and every member inherits it; reference a Group in an [access policy](/admin/access-policies/) and every member is covered by the rule. > **Tip: Coming soon** Provisioning Users and Groups from your identity provider over SCIM 2.0 is coming soon. ## Prerequisites Managing Groups requires the **Admin** organization Role, or the **Manager** Role on the custom Zone you are working in. See [Roles & Permissions](/admin/roles-and-permissions/). ## Create a Group 1. **Open the Groups page** In Keycard Console, open **People** and select the **Groups** tab. 2. **Create the Group** Click **Create group**, enter a **Name**, and click **Create group**. The identifier is derived from the name: `Data Analysts` becomes `data-analysts`. It must be unique within the Zone, and it is the value your policies match on. To change it, open the Group and click the gear icon to edit **Group settings**. ## Manage Membership Only Users can be Group members. A User can belong to any number of Groups. 1. Open **People** → **Groups** and click the Group to open its detail page. 2. On the **Members** tab, click **Add members** and select the Users to add. 3. To remove someone, use the **⋯** button on their row. A User's Groups are also shown on their detail page under **People**, on the **Groups** tab. You can add someone to a Group from there too, with **Add to group**. ## Assign Roles to a Group A Group can hold the same [Roles](/admin/roles-and-permissions/) a User can: either an organization Role or a custom Zone Role. Members inherit every Role assigned to the Group. 1. Open **People** → **Groups** and click the Group to open its detail page. 2. Select the **Access** tab. 3. Choose the **Organization role** and any **Zone access**. The **Policy access** section on the same tab gives you the Cedar snippet that matches this Group, ready to paste into a policy. > **Note:** A User's effective Roles are the union of the Roles assigned to them directly and the Roles assigned to every Group they belong to. Removing a User from a Group removes only the Roles they held through it. ## Use a Group in a Policy Match a Group in Cedar with the `in` operator, using its identifier: ```cedar permit ( principal in Keycard::Group::"data-analysts", action, resource == Keycard::Resource::"" ); ``` Membership is resolved when the request is evaluated, so adding or removing a member changes access without a policy update and without the User signing in again. See [Group-based policies](/admin/access-policies/#group-based-policies). ## Delete a Group Deleting a Group removes its memberships and its Role assignments. Members keep any Role assigned to them directly, and lose the Roles they held only through the Group. Policies referencing the deleted identifier stay valid but stop matching anyone. 1. Open the Group's detail page. 2. Click the **⋯** button in the header, choose **Delete group**, and confirm. ## https://docs.keycard.ai/admin/identity-providers # Identity Providers Keycard provides [zone user authentication](/admin/zone-authentication) by default. If you want to use your own identity provider (Okta, Auth0, Google, etc.) for zone-level user authentication, follow the steps below. > **Note:** This page covers **zone-level** identity providers for authenticating users of an [custom zone](/concepts/zones/), such as a customer-facing product. For signing your own team into Keycard, see [Single Sign-On](/admin/single-sign-on). ## Connect an Identity Provider 1. **Get your Redirect URL** - In Keycard Console, go to **Zones**, open the **⋯** menu on your zone's card, and select **Settings** - On the **Connection** tab, copy the **Redirect URL** - Keep this URL handy for the next step 2. **Configure your identity provider** **Okta:** **In Okta:** 1. Navigate to **Applications** → **Create App Integration** 2. Select **OIDC - OpenID Connect** and **Web Application** 3. Add the Redirect URL to **Sign-in redirect URIs** 4. Enable grant types: **Authorization Code**, **Refresh Token** 5. Assign users to the application 6. Note your **Issuer URL**, **Client ID**, and **Client Secret** **In Keycard Console:** 1. Click **Providers** in the sidebar and then 'Add Provider' 2. Enter **Issuer URL**: `https://.okta.com` 3. Enter **Client ID** and **Client Secret** 4. Click **Connect** **Auth0:** **In Auth0:** 1. Navigate to **Applications** → **Create Application** 2. Select **Regular Web Application** 3. Add the Redirect URL to **Allowed Callback URLs** 4. Note your **Domain**, **Client ID**, and **Client Secret** **In Keycard Console:** 1. Click **Providers** in the sidebar and then 'Add Provider' 2. Enter **Issuer URL**: `https://.auth0.com/` (include trailing `/`) 3. Enter **Client ID** and **Client Secret** 4. Click **Connect** **Google:** **In Google Cloud Console:** 1. Navigate to **APIs & Services** → **Credentials** 2. Create **OAuth 2.0 Client ID** (Web application) 3. Add the Redirect URL to **Authorized redirect URIs** 4. Note your **Client ID** and **Client Secret** **In Keycard Console:** 1. Click **Providers** in the sidebar and then 'Add Provider' 2. Enter **Issuer URL**: `https://accounts.google.com` 3. Enter **Client ID** and **Client Secret** 4. Click **Connect** **Other Provider:** **For any OAuth 2.0 / OIDC provider:** 1. Create a web application in your provider 2. Add the Redirect URL to allowed redirect URIs 3. Enable **Authorization Code** and **Refresh Token** grant types 4. Note your provider's issuer URL, client ID, and client secret **In Keycard Console:** 1. Click **Providers** in the sidebar and then 'Add Provider' 2. Enter your provider's **Issuer URL** 3. Enter **Client ID** and **Client Secret** 4. Click **Connect** 3. **Select the identity provider for the zone** **In Keycard Console:** 1. Go to **Zones**, open the **⋯** menu on your zone's card, and select **Settings** 2. Select the **Settings** tab 3. In the **Zone sign in configuration** section, open the **Identity Provider** dropdown and select the provider you just configured 4. Click **Save Changes** ## Troubleshooting
OAuth flow fails or redirects to error page - Verify the Redirect URL is correctly added to your identity provider - Ensure your identity provider's issuer URL is correct (include/exclude trailing `/` as required) - Check that users are assigned to the application in your identity provider
## https://docs.keycard.ai/admin/revoke-a-grant # Revoke a Grant A **grant** records that a User authorized an Application to reach a specific Resource on their behalf. When a User approves an Application on a [consent screen](/concepts/applications/#consent), Keycard stores a grant that captures the Resource, the [Provider](/concepts/providers/) behind it, and the scopes approved. From then on, Keycard [issues credentials](/concepts/credentials/) for that access without prompting the User again. Revoking a grant withdraws that authorization. You'd do this when: - **Offboarding** a person or decommissioning an Application. - A **compromised or misbehaving agent** needs to be cut off. - An Application ended up **over-scoped** and you want to force it back through consent. - You're **wrapping up a proof of concept** and want to clean up the access it accumulated. > **Note: Grant vs. policy vs. credential** These three controls are related but distinct: - A [**policy**](/admin/access-policies/) decides whether a User and Application are *allowed* to access a Resource. It's a standing rule you author. - A **grant** records that a User *authorized* a specific Application to act for them against a Resource. It's the consent, captured per User. - A [**credential**](/concepts/credentials/) is the short-lived token Keycard issues once policy and consent both pass. Revoking a grant removes the authorization. It doesn't change your policies, and — as covered [below](#what-happens-after-you-revoke) — it doesn't reach back and kill credentials that were already issued. ## Who can revoke grants Grants live in your organization, and revoking one is a management operation, so it requires a management role: - **Admins** (organization-wide) and **Managers** of a [custom Zone](/concepts/zones/) can view and revoke grants for people in that scope. **Viewers** are read-only: they can see grants but not revoke them. - [Roles & Permissions](/admin/roles-and-permissions/) ## Before you begin - Sign in to the [Keycard Console](https://console.keycard.ai) with an Admin or Manager role. - Know which **person** holds the grant. Grants are managed per User from the **People** page. - People and grants live in your **organization** by default. If the access was granted in a [custom Zone](/concepts/zones/), switch to that Zone from the **Zones** page first. - For the management API, have your organization's `` (its org Zone) and a service-account token. ## Revoke a grant **Console:** 1. **Open the person's profile** In the left nav, go to **People** and click the person whose access you want to withdraw. Their profile shows their identifiers, last activity, and two tabs: **Sessions** and **Grants**. 2. **Open the Grants tab** Select **Grants**. Each row is one grant, showing the **Entity** (the Resource, such as `Google Calendar API`), its **Provider**, the approved **Scopes**, a **Status** (`Active now` or `Revoked`), and when it **Expires** (for example, `refreshable`). Active grants are listed first. 3. **Revoke the grant** On the grant's row, open the actions menu (**⋯**) and choose **Revoke grant**. 4. **Confirm the status flipped** The grant's **Status** changes to **Revoked**. Use **Refresh** if the list doesn't update immediately. See [Verify the revocation](#verify-the-revocation) to confirm the effect on live traffic. **HTTP:** All management API requests use a Bearer token obtained from your service account's client credentials. If you haven't set that up, follow the [Authenticate step in Access Policies](/admin/access-policies/#setup) — the same token works here. In the paths below, `` is your organization's Zone (its org Zone), or a custom Zone's ID if the grant lives there. 1. **List the grants in the Zone** Find the grant you want to revoke. Filter by the person's user ID, and optionally `status=active`. ```bash frame="none" curl "https://api.keycard.ai/zones//delegated-grants?user_id=&status=active" \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` Each entry in `items` has an `id` — that's the grant you'll revoke. 2. **Revoke the grant** Set the grant's status to `revoked`. This mirrors the Console's **Revoke grant** action: the grant stays listed with **Status: Revoked**. ```bash frame="none" curl -X PATCH https://api.keycard.ai/zones//delegated-grants/ \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "revoked"}' ``` To permanently remove the grant instead of marking it revoked, send `DELETE` to the same path — it returns `204 No Content`. **Python:** All management API requests use a Bearer token obtained from your service account's client credentials. See the [Authenticate step in Access Policies](/admin/access-policies/#setup) for the full token exchange. Here `zone_id` is your organization's Zone (its org Zone), or a custom Zone's ID if the grant lives there. 1. **List the grants in the Zone** ```python grants = requests.get( f"{base}/zones/{zone_id}/delegated-grants", headers=headers, params={"user_id": user_id, "status": "active"}, ).json() for grant in grants["items"]: print(grant["id"]) ``` 2. **Revoke the grant** Set the grant's status to `revoked` — the same action as the Console's **Revoke grant**: ```python requests.patch( f"{base}/zones/{zone_id}/delegated-grants/{grant_id}", headers=headers, json={"status": "revoked"}, ) ``` To permanently remove the grant instead, use `requests.delete(...)` on the same path. > **Note: No dedicated CLI command** The Keycard [CLI](/cli/) doesn't have a grant-revocation command today. If you script against the management API, you can reach the same endpoint through the generic passthrough, which authenticates against the Management API: ```bash frame="none" keycard agent api /zones//delegated-grants/ \ -X PATCH -d '{"status": "revoked"}' ``` ## What happens after you revoke Revoking a grant stops Keycard from issuing **new** access for that User and Resource. It does **not** reach out and invalidate credentials that are already in the wild. **New credential requests stop.** The next time the Application asks Keycard for a credential for that access — a fresh [token exchange](/concepts/credentials/#delegation-chaining) or a [refresh](/concepts/credentials/#refreshing) — Keycard has no grant to satisfy it and the request fails with `insufficient_authorization` (an `OAuth2Error`) and the message *User authorization is required for resource ``*. The User has to authorize the access again before anything is issued, so the Application should treat this as a re-authentication prompt. **Already-issued credentials live until they expire.** Keycard issues short-lived credentials, but there is no per-token kill-switch today. A token the agent already holds keeps working against the Resource until it expires on its own. In practice, access stops within the lifetime of the current credential — not the instant you click **Revoke grant**. **Refresh stops.** A grant that shows `refreshable` in the Console can mint new credentials without re-prompting the User. Revoking the grant ends that — refresh attempts fail, so the Application can't quietly extend its access past the current token. **Brokered credentials are a special case.** For [brokered access](/concepts/resources/#brokered-access) to an external Provider (Google, GitHub, and so on), revoking the Keycard grant stops Keycard from brokering *new* credentials — but it does not sign the User out at the Provider or revoke a token the Provider already issued. To fully cut off external access, also revoke Keycard's access from the Provider's own connected-apps settings. **What the agent or Application sees.** The current token keeps working against the Resource until it expires; after that, the Application can no longer obtain a new credential. Its next token request to Keycard fails with `insufficient_authorization` — the User must re-authorize. That's distinct from `access_denied`, which is what a [policy](/admin/access-policies/) denial returns. > **Caution: One grant at a time** Revoking a grant is per-Resource, and there's no per-token kill-switch — you can't revoke a single outstanding credential. To cut a person off from *everything* at once rather than grant by grant, [disable or remove the person](#cut-a-person-off-entirely) instead. ## Cut a person off entirely When you're offboarding someone or responding to a compromise, revoking grants one at a time is slower than you want. From the **People** page, open the person's access drawer (the **⋯** on their row) and use the **Danger zone**: - **Disable user** blocks the person from signing in or obtaining new credentials while keeping their account and configuration intact. It's reversible. - **Remove from organization** removes the person entirely. You can't disable or remove yourself. Disabling or removing a person stops new credential issuance the same way revoking a grant does; credentials their Applications already hold keep working until they expire. ## Verify the revocation 1. **Check the grant status.** On the person's **Grants** tab, the revoked grant now shows **Status: Revoked**. 2. **Check the Audit Log.** From the grant's actions menu, choose **View in audit log**, or open the [Audit Log](/admin/audit-log-export/) directly. After revocation, the Application's next attempt shows as a **Failure** on the credential exchange (resource path `/oauth/2/token`), with error code `insufficient_authorization` and the message *User authorization is required for resource ``*. The event's actor is the full identity chain — the Application and the User it acted for. 3. **Retry from the Application.** Once the current credential expires, trigger the Application again. Its next token request to Keycard should fail with `insufficient_authorization`, and the **Grants** tab should still show the grant as **Revoked**. ## Re-grant access Revocation isn't permanent. To restore access, the User goes back through the [authorization flow](/concepts/credentials/#user-delegation) and approves the [consent screen](/concepts/applications/#consent) again. Keycard records a new grant and credential issuance resumes. For an Application configured with implicit consent, there's no screen to approve — the grant is re-created automatically the next time the User runs the flow. ## Related - [Access Policies](/admin/access-policies/) — the standing rules that decide who's allowed access in the first place - [Credential Issuance](/concepts/credentials/) — how Keycard issues, refreshes, and brokers the credentials a grant unlocks - [Applications](/concepts/applications/#consent) — the consent model and how grants are created - [Providers](/concepts/providers/) — the external providers behind brokered grants - [Audit Log Export](/admin/audit-log-export/) — stream credential and access events to your SIEM - [Roles & Permissions](/admin/roles-and-permissions/) — who can perform management operations ## https://docs.keycard.ai/admin/tutorials/auth0-sign-in # Connect Auth0 > **Caution:** This tutorial configures sign-in for users of **your zone**: the people who use the application Keycard protects. If you are setting up SSO for **Keycard Console administrators** (your team signing into Console to manage Keycard), see [Single sign-on for Console](/admin/single-sign-on/) instead. In this tutorial, we will configure user authentication to occur through Auth0. We will start by creating an application in your Auth0 [tenant](https://auth0.com/docs/get-started/auth0-overview/create-tenants). We will then configure Auth0 as a [provider](/concepts/providers/) in your Keycard zone. This application and provider pair creates a connection that will allow users to sign in using Auth0. For illustrative purposes, the zone in this tutorial is named "Example". Each zone also has a unique **Zone ID** that appears in the zone's domain: `.keycard.cloud`. The Zone ID is distinct from the zone's name. Throughout this tutorial, `` is a placeholder you will replace with the ID of your own zone. ## Create application in Auth0 In the first phase of this tutorial, we will create an application in Auth0. You'll configure the application with the necessary settings to connect it to your zone. 1. In Auth0 Dashboard, select **Applications > Applications** in the navigation menu. 2. On the **Applications** page, click the **Create Application** button. A **Create application** wizard will appear. In the **Name** field, enter "Example", which is the name of your zone. Choose **Regular Web Application** as the application type. Click the **Create** button. You will be directed to a page to set up the newly created Example application. 3. On the Example application page, click the **Settings** tab. Notice that the **Quickstart** tab was originally selected, prompting you for information about what framework you are using to build your project. Keycard has pre-built features to integrate with Auth0, so this can be skipped. In the **Settings** tab, scroll down to **Application URIs**. > **Tip:** **Finding your Redirect URL:** In Keycard Console, go to **Zones**, open the **⋯** menu on your zone's card, and select **Settings**. On the **Connection** tab, copy the **Redirect URL**. The URLs below should use *your* zone ID, not the literal string `` and not `example`. In the **Allowed Callback URLs** field, and enter: `https://.keycard.cloud/oauth/2/redirect` In the **Allowed Logout URLs** field, enter `https://.keycard.cloud/openid/connect/redirect/logout`. Double check that the domain in the URLs matches the domain of your zone. Click the **Save** button. You've successfully created an application in Auth0. You should now be on the settings page for the new Example application. This application will allow users in your Auth0 tenant to sign into your zone. Remain on this page, as we will need to refer to the settings in the next phase. ## Create provider in Keycard In the next phase of this tutorial, we will create a provider in Keycard. You'll configure the provider with the necessary credentials to connect to your Auth0 tenant. It is recommended that you complete these steps in a new browser tab or window, as you'll need to copy and paste settings between Auth0 Dashboard and Keycard Console. 1. In Keycard Console, select **Providers** in the navigation menu. 2. On the **Providers** page, click the **Add provider** button. A **Create provider** screen will appear. In the **Name** field, enter "Auth0". In the **Issuer URL** field, enter your Auth0 domain, prefixed with `https://` as the URL scheme and suffixed with a trailing `/`. For example: `https://.auth0.com/`. Your Auth0 domain can be found on the application settings page in the **Domain** field in Auth0 Dashboard. It is easiest to copy and paste the value from Auth0 Dashboard to Keycard Console. In the **Client ID** field, enter the Client ID that Auth0 assigned to the newly created Example application. This can be found in the **Client ID** field on the settings page for the application in Auth0 Dashboard. It is easiest to copy and paste the Client ID from Auth0 Dashboard to Keycard Console. In the **Client Secret** field, enter the Client Secret that Auth0 generated for the Example application. This can be found on the same settings page. It is easiest to copy and paste the secret from Auth0 Dashboard to Keycard Console. You've just create a provider in Keycard that is connected to your Auth0 tenant! ## Use Auth0 for sign in In the final phase of this tutorial, we will configure user authentication to occur through Auth0. 1. In Keycard Console, go to **Zones** and open the **⋯** menu on your zone's card, then select **Settings**. 2. On the zone configuration page, select the **Settings** tab. 3. In the **Zone sign in configuration** section, open the **Identity Provider** dropdown and select **Auth0**. 4. Click **Save Changes**. You have just configured your zone to authenticate users through Auth0! Now, whenever a user of your service attempts to access an application or resource protected by Keycard, they'll sign in via Auth0. ## https://docs.keycard.ai/admin/tutorials/okta-sign-in # Connect Okta > **Caution:** This tutorial configures sign-in for users of **your zone**: the people who use the application Keycard protects. If you are setting up SSO for **Keycard Console administrators** (your team signing into Console to manage Keycard), see [Single sign-on for Console](/admin/single-sign-on/) instead. In this tutorial, we will configure user authentication to occur through Okta. We will start by creating an application in your Okta [organization](https://developer.okta.com/docs/concepts/okta-organizations/). We will then configure Okta as a [provider](/concepts/providers/) in your Keycard zone. This application and provider pair creates a connection that will allow users to sign in using Okta. For illustrative purposes, the zone in this tutorial is named "Example". Each zone also has a unique **Zone ID** that appears in the zone's domain: `.keycard.cloud`. The Zone ID is distinct from the zone's name. Throughout this tutorial, `` is a placeholder you will replace with the ID of your own zone. ## Create application in Okta In the first phase of this tutorial, we will create an application in Okta. You'll configure the application with the necessary settings to connect it to your zone. 1. In Okta Admin Console, select **Applications > Applications** in the navigation menu. 2. On the **Applications** page, click the **Create App Integration** button. A **Create a new app integration** wizard will appear. Select **OIDC - OpenID Connect** as the **Sign-in method**. We use OpenID Connect here because it is a more modern and standard authentication protocol. An **Application type** section will appear. Select **Web application**. Click the **Next** button. You will be directed to a **New Web App Integration** page. 3. The **New Web App Integration** page is where settings are configured. Under **General Settings**, enter "Example", which is the name of your zone, as the **App integration name**. > **Tip:** **Finding your Redirect URL:** In Keycard Console, go to **Zones**, open the **⋯** menu on your zone's card, and select **Settings**. On the **Connection** tab, copy the **Redirect URL**. The URLs below should use *your* zone ID, not the literal string `` and not `example`. Scroll down to **Sign-in redirect URIs** and enter: `https://.keycard.cloud/oauth/2/redirect` Scroll down to **Sign-out redirect URIs** and enter `https://.keycard.cloud/openid/connect/redirect/logout`. Double check that the domain in the URLs matches the domain of your zone. Scroll down to **Assignments**. For the **Controlled access** setting, select **Allow everyone in your organization to access**. An **Enable immediate access** setting will appear, which is enabled by default. Click the **Save** button. You've successfully created an application in Okta. You should now be on the settings page for the new Example application. This application will allow users in your organization to sign into your zone using their Okta account. Remain on this page, as we will need to refer to the settings in the next phase. ## Create provider in Keycard In the next phase of this tutorial, we will create a provider in Keycard. You'll configure the provider with the necessary credentials to connect to your Okta organization. It is recommended that you complete these steps in a new browser tab or window, as you'll need to copy and paste settings between Okta Admin Console and Keycard Console. 1. In Keycard Console, select **Providers** in the navigation menu. 2. On the **Providers** page, click the **Add provider** button. A **Create provider** screen will appear. In the **Name** field, enter "Okta". In the **Issuer URL** field, enter your Okta domain, prefixed with `https://` as the URL scheme. For example: `https://.okta.com`. You can [find](https://developer.okta.com/docs/guides/find-your-domain/main/) your domain by clicking your name in the top-right corner of Okta Admin Console. The domain appears in the menu. In the **Client ID** field, enter the Client ID that Okta assigned to the newly created Example application. This can be found on the settings page for the application in Okta Admin Console. It is easiest to copy and paste the Client ID from Okta Admin Console to Keycard Console. In the **Client Secret** field, enter the Client Secret that Okta generated for the Example application. This can be found on the same settings page. Notice that there is a list of Client Secrets, which should contain a single secret. It is easiest to copy and paste the secret from Okta Admin Console to Keycard Console. You've just create a provider in Keycard that is connected to your Okta organization! ## Use Okta for sign in In the final phase of this tutorial, we will configure user authentication to occur through Okta. 1. In Keycard Console, go to **Zones** and open the **⋯** menu on your zone's card, then select **Settings**. 2. On the zone configuration page, select the **Settings** tab. 3. In the **Zone sign in configuration** section, open the **Identity Provider** dropdown and select **Okta**. 4. Click **Save Changes**. You have just configured your zone to authenticate users through Okta! Now, whenever an employee in your company attempts to access an application or resource protected by Keycard, they'll sign in using their Okta account. ## https://docs.keycard.ai/admin/unified-access-gateway/agent-access # Autonomous Agent Access Some agents do work of their own, with no person in the loop. Those agents can call MCP tools through a [Unified Access Gateway](/admin/unified-access-gateway/) by authenticating as themselves: [Access Policies](/admin/access-policies/) decide what an agent can reach, and each call is recorded in the audit log under the agent's identity. For why an agent should have its own identity, see [Give an agent its own identity](/use-cases/agent-with-its-own-identity/). > **Note: Not just agents** The same steps work for any headless workload, such as a CI job or a backend service. > **Caution: API-key upstreams only** An agent acting as itself can only reach upstreams that Keycard holds a [vaulted credential](/concepts/resources/#vaulted-static-credentials) for, such as an MCP server that takes an API key. Upstreams that use per-user OAuth stay user-only: an agent's request for one fails with `invalid_target`. ## How it works The agent gets an access token from Keycard using its own identity (behind the scenes, the SDK uses the `client_credentials` grant) and sends it to the gateway's MCP endpoint. To keep credentials fully separated, there are two actors in the flow: the agent communicating with the gateway, and the gateway acting on behalf of the agent toward the upstream. On each request, the gateway exchanges the agent's token with Keycard, which runs a policy check for each actor before the request is allowed through: | Check | Principal | Context | | --- | --- | --- | | Subject check | The agent | `context.on_behalf == false` | | Delegation check | The gateway | `context.on_behalf == true` | ## Prerequisites - **Manager** access to the Zone (see [Roles & Permissions](/admin/roles-and-permissions/)) - The agent registered as an Application with a client credential (see [Applications](/concepts/applications/#credentials)) - The upstream MCP server registered as a Resource with a vaulted credential (see [Vaulted Static Credentials](/concepts/resources/#vaulted-static-credentials)) - A Unified Access Gateway with the API-key MCP server attached as a dependency (see [Unified Access Gateway](/admin/unified-access-gateway/)) ## Give an agent access Access is configured entirely through [Access Policies](/admin/access-policies/): a Policy permits the agent for the gateway it connects through and for each upstream it may reach. 1. **Author the Policy** Permit the agent for the gateway and each API-key upstream, scoped to the agent acting as itself (`context.on_behalf` and `context.impersonate` both `false`): ```cedar @id("nightly-report-agent-access") @description("Permit the reporting agent the gateway and its API-key upstream") permit ( principal is Keycard::Application, action, resource ) when { principal.identifier == "" && ["", ""].contains(resource.identifier) && context.on_behalf == false && context.impersonate == false }; ``` An agent permitted for the gateway but not for an upstream connects successfully and is denied that upstream's tools. 2. **Add it to your policy set** Add the new Policy to your policy set and activate the set. See [Access Policies](/admin/access-policies/) for authoring and activating policy sets. > **Note:** If you don't have a custom policy set yet, create one containing the [managed default policies](/admin/access-policies/#managed-policies) plus the new Policy, then activate it. The defaults keep existing user access working and cover the gateway's own checks. ## Connect the agent Copy the gateway's **MCP Access URL** from its **Application settings** page and your Zone's **Issuer URL** from **Settings** → **Connection**. The SDK handles authentication for the agent and brokers a token for the gateway. The agent then calls the gateway like any MCP server. This example uses the Keycard LangChain SDK: ```python import os import httpx from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client from keycardai.langchain import Access, KeycardGrantMiddleware GATEWAY_URL = "" keycard = KeycardGrantMiddleware( zone_url=os.environ["KEYCARD_ISSUER"], resources=[GATEWAY_URL], client_id=os.environ["KEYCARD_CLIENT_ID"], client_secret=os.environ["KEYCARD_CLIENT_SECRET"], ) async def list_gateway_tools(): with keycard.grant(Access.as_self(), resources=[GATEWAY_URL]) as access: token = access.access(GATEWAY_URL).access_token http_client = httpx.AsyncClient(headers={"Authorization": f"Bearer {token}"}) async with http_client, streamable_http_client(GATEWAY_URL, http_client=http_client) as (read, write): async with ClientSession(read, write) as session: await session.initialize() return (await session.list_tools()).tools ``` There is no sign-in and no authorization prompt: your Policies already authorized the access. ## Verify In Keycard Console, open the agent Application's page and select the **Activity** tab: each run shows the credential issuance events for the gateway and the upstream, with no user attached. See [Read an Activity Feed](/admin/activities/) for how to read the feed. ## Revoke access Remove the agent's Policy from your policy set, or remove the upstream from that Policy if the agent should keep its other access. The gateway checks policy on every request, so the agent's very next request is rejected. There is nothing to revoke. The denial shows up in the Audit Log as a denied exchange. Revoking a user's access works through grants instead. See [Revoke a Grant](/admin/revoke-a-grant/). ## Troubleshooting - **The token exchange fails with `invalid_target`.** Either the upstream uses per-user OAuth (which stays on the user path), or the Resource has no vaulted credential yet. For the latter, add one on the Resource with **Add credential**. - **The agent gets a token but an upstream's tools are missing or denied.** No Policy permits the agent for that upstream. Being permitted for the gateway is not enough. Add the upstream to the agent's Policy, then retry. See [Diagnosing policy-blocked actions](/admin/access-policies/#diagnosing-policy-blocked-actions). - **After activating a custom policy set, an upstream is unreachable through the gateway despite an agent permit.** The set is missing the managed [`default-app-direct-access`](/admin/access-policies/#managed-policies) policy, which the gateway needs for its own access to the upstream. The denial appears in the audit log with the gateway as principal. Add the managed policy back to the set. ## https://docs.keycard.ai/admin/unified-access-gateway/fine-grained-authorization # Fine-Grained Authorization Fine-grained authorization lets you govern a [Unified Access Gateway](/admin/unified-access-gateway/) at the level of individual MCP tool calls. On every tool call the gateway forwards, Keycard evaluates your Zone's active [Access Policies](/admin/access-policies/) to verify that the specific action is allowed, and you can configure different permissions for different sets of users. For example, permit read-only tools to everyone while restricting write or delete tools to a smaller cohort. > **Caution: Tools only** The initial release of Unified Access Gateway only exposes MCP **tools**. Requests for other items such as `prompts/*` or `resources/*` are answered with a JSON-RPC "method not found" error (`-32601`) rather than forwarded to an upstream. Fine-grained authorization therefore covers tool calls and tool discovery. See [Prompts and resources](#prompts-and-resources) for the planned shape. ## How it works The gateway checks the MCP method and tool name against your Policies before forwarding the request to the upstream MCP server. A denial blocks the call before it ever reaches the upstream, and every decision is recorded in the audit log. Gateway activity maps onto Cedar actions as follows: | MCP activity | Cedar action | | --- | --- | | Tool call (`tools/call`) | `Keycard::Action::"mcp::tools/call::"` | | Tool discovery (`tools/list`) | `Keycard::Action::"mcp::tools/list"`, a member of `Keycard::Action::"MCPProtocol"` | These are the only two actions a gateway evaluates. The `resource` in each evaluation is the upstream MCP server the gateway is talking to, so `resource.identifier` is that upstream's MCP URL — not the gateway's own URL. ## Prerequisites - A Unified Access Gateway with upstream MCP servers attached (see [Unified Access Gateway](/admin/unified-access-gateway/)) - **Manager** access to the Zone (see [Roles & Permissions](/admin/roles-and-permissions/)) - Familiarity with authoring and activating Policies (see [Access Policies](/admin/access-policies/)) ## Create fine-grained Policies 1. **Identify the tools to gate** Reference each upstream MCP server's documentation for the tool names it exposes. To construct the Cedar action for a tool, prepend `mcp::tools/call::` to the tool name exactly as the upstream publishes it. For example, a tool named `create_event` becomes: ```cedar Keycard::Action::"mcp::tools/call::create_event" ``` Use the bare tool name from the upstream, not the `__` prefixed name shown in the gateway's aggregated tool list. The prefix is presentation only: the gateway strips it before evaluating Policy, so the action always carries the name the upstream will actually execute. > **Note:** Tool discovery and management in the Console is coming soon: you will be able to browse the tools each upstream exposes and review tool call activity directly in Keycard. 2. **Permit sign-in and discovery** Users need to sign in to the gateway and list its tools before any tool is called. Permit the gateway endpoint itself, and tool discovery on each upstream. To find the identifiers, open the gateway's Application details page in Keycard Console (**Applications → your gateway**): the gateway's own identifier is the **MCP Access URL** on the **Application settings** page, and the upstream identifiers are the MCP URLs of the Resources listed under **Dependencies**. ```cedar @id("gateway-signin") @description("Allow users to sign in to and authorize the gateway") permit ( principal is Keycard::User, action, resource ) when { resource.identifier == "https://..mcp.gateway.context/mcp" }; @id("gateway-mcp-protocol") @description("Tool discovery on the upstreams") permit ( principal is Keycard::User, action in Keycard::Action::"MCPProtocol", resource ) when { ["https:///mcp", "https:///mcp"].contains(resource.identifier) }; ``` The `MCPProtocol` group covers the MCP protocol actions that target no specific item, including the discovery actions `mcp::tools/list`, `mcp::prompts/list`, `mcp::resources/list`, and `mcp::resources/templates/list`. > **Caution:** Without the sign-in permission users cannot authorize the gateway at all. Without the discovery permission an upstream's tools cannot be listed, so that upstream is excluded from the user's aggregated tool list. Discovery never invokes a tool, so permitting `MCPProtocol` does not grant tool access by itself. > **Note:** The ability to reduce the number of tools presented to the client is coming soon: the aggregated tool list will only include the tools each user is permitted to call. Today, discovery is permitted or denied per upstream — a user who can list an upstream sees all of its tools, whether or not they can call them. 3. **Permit the baseline tools for all users** Enumerate the read-only tools every authenticated user may call. For example, on a calendar upstream: ```cedar @id("calendar-events-read") @description("Read-only event tools for all users") permit ( principal is Keycard::User, action in [Keycard::Action::"mcp::tools/call::list_events", Keycard::Action::"mcp::tools/call::get_event", Keycard::Action::"mcp::tools/call::search_events"], resource ) when { resource.identifier == "https:///mcp" }; ``` Tool calls that no Policy permits are denied: fine-grained authorization is default-deny, like every other Keycard enforcement point. 4. **Restrict sensitive tools to identity provider groups** Gate mutating and destructive tools on the `groups` claim your identity provider issues, using `context.subject_claims`. To gate on membership you manage in Keycard instead, match a [Group](/concepts/groups/) with `principal in Keycard::Group::""`; see [Group-based policies](/admin/access-policies/#group-based-policies). ```cedar @id("calendar-events-write") @description("Event mutations granted to Calendar Writers") permit ( principal is Keycard::User, action in [Keycard::Action::"mcp::tools/call::create_event", Keycard::Action::"mcp::tools/call::update_event"], resource ) when { resource.identifier == "https:///mcp" && context has subject_claims && context.subject_claims has groups && context.subject_claims.groups.contains("Calendar Writers") }; @id("calendar-events-delete") @description("Event deletion granted to Calendar Admins") permit ( principal is Keycard::User, action == Keycard::Action::"mcp::tools/call::delete_event", resource ) when { resource.identifier == "https:///mcp" && context has subject_claims && context.subject_claims has groups && context.subject_claims.groups.contains("Calendar Admins") }; ``` 5. **Activate the Policies** Activate the Policies in your Zone on the **Console → Policies** page. Changes take effect on the next tool call. There is nothing to deploy or restart on the gateway. ## Verify Sign in to the gateway as a user in one of the permitted groups and call a permitted tool: it succeeds. Then call a tool that your Policies do not permit. The call is denied before reaching the upstream, and the client receives a JSON-RPC error (`-32603`, `access denied by policy for `) over HTTP 403 rather than a tool result. Because it is a protocol-level error and not a failed tool result, MCP clients typically surface it as a connection or request error rather than as tool output. If discovery is denied on every upstream at once, the gateway answers the client's `tools/list` with HTTP 403 and `no unified upstreams are accessible with the presented grant`. When only some upstreams are denied, the response is a successful, partial tool list. Check **Console → Audit Log** for both decisions. Every evaluation is logged with the user, the upstream, the tool name, and the Policy outcome, so denied calls carry the context of what was denied and why. ## Prompts and resources MCP prompts and resources are not yet available through a Unified Access Gateway. When these surfaces arrive, they are expected to follow the same action-naming pattern as tools, with the item identifier appended to the method: | Planned MCP activity | Planned Cedar action | | --- | --- | | Prompt (`prompts/get`) | `Keycard::Action::"mcp::prompts/get::"` | | Resource read (`resources/read`) | `Keycard::Action::"mcp::resources/read::"` | A Policy written against those actions would look like any other: ```cedar @id("prompts-code-review") @description("The code_review prompt template for Engineering") permit ( principal is Keycard::User, action == Keycard::Action::"mcp::prompts/get::code_review", resource ) when { resource.identifier == "https:///mcp" && context has subject_claims && context.subject_claims has groups && context.subject_claims.groups.contains("Engineering") }; ``` > **Caution:** This is a preview of the intended shape, not a supported configuration. A Policy like the one above is accepted and activated, but no gateway traffic will ever match it, so it neither grants nor blocks anything today. The action names and the identifier used for MCP resources may change before the surfaces ship. ## Troubleshooting - **A tool call is denied that a Policy should allow.** Confirm the action uses the bare tool name (`mcp::tools/call::create_event`), not the prefixed name from the aggregated tool list (`calendar__create_event`), and that `resource.identifier` matches the upstream MCP server URL exactly. The gateway evaluates against the upstream's identifier, never the gateway's own. - **A group-gated Policy never matches.** Verify your identity provider issues the `groups` claim and that the user is a member. The Policy's `context has subject_claims` guards only prevent evaluation errors; a missing claim means the Policy silently does not match. - **A user sees no tools from an upstream at all.** Tool listing is gated by `mcp::tools/list` on that upstream. Check the user is permitted it (directly or via the `MCPProtocol` group), then have them reconnect. - **A client reports "method not found" for a prompt or resource.** Expected: a gateway serves the tools surface only. Reach that upstream directly instead of through the gateway if you need its prompts or resources. ## https://docs.keycard.ai/admin/unified-access-gateway/tool-management # Tool Management MCP servers expose tools to their clients. Keycard records these tools and enables you to curate which of them each gateway serves — the same MCP server proxied by two different gateways can expose a broad tool set on one and a narrow one on the other. Curation decides **which tools a gateway has**; to limit **who may call them**, use [Fine-Grained Authorization](/admin/unified-access-gateway/fine-grained-authorization/). > **Caution: Preview** Tool management is in preview, and Keycard turns it on per Zone. If the **Actions** tab and the gateway's per-server actions panel aren't in your Console, contact Keycard to have it turned on. ## Prerequisites - A Unified Access Gateway with upstream MCP servers attached (see [Unified Access Gateway](/admin/unified-access-gateway/)) - Manager access to the Zone (see [Roles & Permissions](/admin/roles-and-permissions/)) ## Reviewing available MCP tools Open **Resources**, select an MCP server, and open the **Actions** tab. The table lists every tool Keycard has recorded for that server, with a **Source** column saying where each one came from: | Source | Meaning | | --- | --- | | **Catalog** | Seeded from the package description when the server was installed from the [Catalog](/admin/catalog/) | | **User** | Added by hand on this tab | > **Note: The list is a record, not a live view** Keycard doesn't poll your MCP servers. A Resource's actions are what the install seeded plus whatever you've added since, so a tool the vendor shipped after you installed the server won't appear until somebody adds it. ### Adding new or unlisted MCP tools Servers you build in-house have no Catalog package to seed from, so their tool list starts empty. Click **Add tool** and enter the tool's **Name**, which is the full policy action name rather than the bare MCP tool name: prepend `mcp::tools/call::` to the name the server publishes. The name must be unique within the Resource. Each row's menu also offers **Edit tool** and **Remove tool**, so you can correct a hand-built list. Because the name you enter is the exact string your Policies match on, you can write Policy against a tool whether or not any gateway serves it. > **Caution: Coming soon** Guided tool discovery and refresh from upstream MCP servers is currently in development and coming soon. ## Choosing the tools a gateway serves The Unified Access Gateway can operate in two modes. The **All actions** mode acts as an unfiltered proxy: the gateway continues to serve all available actions from the upstreams. Use this mode if you want the latest tools to become available as soon as the upstreams release them. The **Selected actions** mode acts as a configuration gate: new tools must be explicitly enabled when the upstream releases them. Use this mode when you want to review new tools before making them visible to consumers. 1. **Open the gateway's actions panel** In Keycard Console, open **Applications**, select the gateway, then open the **MCP Servers** tab. Click an attached server to open its actions panel. 2. **Read the current mode** The **Allow** field shows the server's current mode on this gateway. Every server starts in **All actions** when you attach it. The panel says *New actions are enabled automatically*, and the count reads *All*, followed by the number of tools. 3. **Turn off the tools this gateway shouldn't serve** Find a tool with the search box and turn its switch off. Turning off the first tool moves the server to **Selected actions**, where the panel says *Until you enable them, new actions stay disabled* and the count becomes a ratio, such as *64 of 66 enabled*. Expand a row to see the tool's description and its full policy action name, with a button to copy the name for a Cedar Policy. 4. **Cut the list down to a few tools** Where a gateway should serve only a handful of tools, click **Disable all**, then turn on the few you want. The button reads **Enable all** whenever anything is off. A server in **Selected actions** with nothing enabled serves no tools. [Troubleshooting](#troubleshooting) covers what a client sees in that case. 5. **Return to serving everything** Click **All actions** to stop curating. The gateway goes back to serving whatever the upstream returns, including tools added later, and Keycard discards your per-tool selections for that server. > **Caution: Curation shapes the gateway, not the upstream** A client that holds a credential for the upstream MCP server and connects to it directly sees whatever the upstream gives it, because curation constrains the gateway path only. Where that matters, control it by not issuing the credential. Curation also applies only to the unified gateway endpoint; a single-server proxy endpoint serves the upstream's full tool set. ## Verify Connect to the gateway as one of its users and list its tools: a tool you turned off is absent. Call it by name anyway and the client reports `unknown tool: __`, with no matching request reaching the upstream. Changes take effect on the next request through the gateway — there is nothing to deploy and no need for users to sign in again. Then check **Console → Audit Log** for the `applications:disable_action` event recording who turned it off and when. See [Activity events](/reference/activity-events/) for the other events curation emits. ## Troubleshooting If a tool is missing from a user's tool list, check whether the server is in **Selected actions** on that gateway and whether the tool is turned on — a tool the upstream released after the server moved to **Selected actions** arrives switched off. The client error doesn't distinguish a disabled tool from one that never existed, but activity events record the real reason: `tool is outside the curated surface of `. When curation isn't the cause, the user may not be permitted to list that upstream; see [Fine-Grained Authorization](/admin/unified-access-gateway/fine-grained-authorization/). When a server is in **Selected actions** with nothing enabled, the gateway refuses every call to it, and the error names Policy rather than curation: a call returns `access denied by policy for `, and if that server is the gateway's only upstream, `tools/list` returns `no unified upstreams are accessible with the presented grant`. Check that the panel doesn't read *0 of N enabled* before you start debugging Policy. Click **Enable all** to restore the full set, or **All actions** to stop curating. A client can still list a tool you turned off, because the gateway doesn't push a tool-list change notification and the client shows the list it last fetched. Calling the tool fails regardless; have the user reconnect to refresh the list. ## Next steps - Gate the remaining tools per User or Group with [Fine-Grained Authorization](/admin/unified-access-gateway/fine-grained-authorization/). - Review who called what in the [audit log](/admin/audit-log-and-sessions/). ## https://docs.keycard.ai/admin/usage # Usage & Billing A **transaction** is recorded each time Keycard is involved in a tool call - issuing a credential, validating a request, or handling a step-up approval. Every plan includes a monthly allocation of transactions. See [Pricing](https://www.keycard.ai/pricing) for plan details. ## When Keycard is involved How often Keycard participates in the request path depends on how your resources are connected. ### Protected resources Keycard is involved in both issuing and validating the agent's credential, providing per-request policy enforcement and real-time telemetry. Each tool call generates a transaction. This applies to resources protected via the [SDK](/sdk/mcp/), the [gateway](/admin/deployment/), or the [CLI](/guides/secure-agentic-coding/). ### Brokered resources Keycard issues a credential that the agent uses to directly access a downstream application. Once the credential is issued, Keycard is no longer involved in the request path. Only the initial issuance generates a transaction - subsequent calls go straight to the service. This is the pattern for third-party services like GitHub, Slack, and Google Workspace, connected via [access credential providers](/concepts/providers/). > **Note:** Identity federation (e.g., authenticating users via Okta or apps via AWS) is part of the authentication flow and doesn't generate transactions. ### Step-up authentication When policy requires human approval for a sensitive action, that approval generates a transaction. Routine actions proceed without interruption. ## Estimating usage | Pattern | Transactions | | --- | --- | | Agent calls tools on a **protected** resource | 1 per tool call | | Agent accesses a **brokered** resource | 1 at issuance, then none | | Agent [exchanges](/concepts/credentials/#delegation-chaining) a credential for a different resource | 1 per exchange | | Sensitive action triggers **human approval** | 1 per approval | ## https://docs.keycard.ai/admin/zone-authentication # Zone Authentication Keycard provides zone user authentication by default. If you want to use your own identity provider (Okta, Auth0, Google, etc.) for zone-level user authentication, see [Identity Providers](/admin/identity-providers/). > **Note:** This page covers **custom zones** that have their own pool of users, such as a customer-facing product. Your organization does not need this: members join through [Single Sign-On](/admin/single-sign-on) or an invitation, with no separate zone sign-up. ## Configuring and using Zone Authentication 1. **Configure Zone Authentication** 1. In Keycard Console, go to **Zones**, open the **⋯** menu on your zone's card, and select **Settings**. 2. On the **Settings** tab, find **Zone sign in configuration** and open the **Identity Provider** dropdown. Keep the default to use Keycard's built-in authentication, or select [your own identity provider](/admin/identity-providers/). 3. Click **Save Changes**. 2. **Use a Zone Protected Application or MCP Server / Create a Zone User Account** For ease of use, zone user sign up is done in-band; when a user attempts to connect to a zone provided application or MCP server, they will be prompted to login or sign up. After sign up or login if a user has not yet verified their email, they will be prompted to do so. ## Troubleshooting
Account creation fails - Ensure they do not have an account, look for their email in **People** in Keycard Console, if they have an account, instruct them to click **Forgot Password?** from the sign in page to reset their password.
Login fails - Ensure they have an account, look for their email in **People** in Keycard Console. If they have an account, instruct them to click **Forgot Password?** from the sign in page to reset their password.
I verified my email, but I still am not authenticated? - If you clicked the email verification link rather than entering the code to continue the flow, you will need to reconnect to your application or MCP server and login to the zone to proceed.
## https://docs.keycard.ai/admin/zones # Custom Zones A [Zone](/concepts/zones/) is a security domain for a set of users, Applications, and Resources. Custom Zones support advanced use cases, such as building systems with a different set of external users than your organization for example, a customer-facing product with its own user pool, or separate environments with their own security controls. > **Note:** Your organization is already a Zone, with no separate setup required. Create a custom Zone only when you need a separate set of users or an isolated security domain. See [Zones](/concepts/zones/) for the full concept. Because a custom Zone has its own users, it requires additional configuration beyond creating it: 1. An organization Admin must assign explicit management access to the Zone, including themselves. 2. The Zone needs an Identity Provider so its users can sign in: either your Organization Zone, or an external Identity Provider such as Okta, Auth0, or Google. ## Prerequisites Only organization **Admins** can create Zones and assign Zone access. See [Roles & Permissions](/admin/roles-and-permissions/). ## Create a Zone 1. **Open the Zones page** In Keycard Console, click **Zones** in the sidebar. 2. **Create the Zone** Click **Create Zone**, enter a **Name**, and click **Create**. ## Assign Zone Admin Access Custom Zones have their own management roles: **Manager** (full access to the Zone) and **Viewer** (read-only). An organization Admin must assign these roles explicitly. > **Caution:** Organization Admins are not automatically Managers of custom Zones. Assign yourself **Manager** access to the Zone you just created, or you will not be able to manage it. 1. Open the **People** page. 2. Find the member and click the **⋯** button on their row to open their access drawer. 3. Under **Zone access**, choose **Manager** for the new Zone. 4. Repeat for yourself and anyone else who needs to manage the Zone. See [Roles & Permissions](/admin/roles-and-permissions/) for details on organization and Zone roles. ## Configure Zone Authentication Every custom Zone needs an Identity Provider so its users can sign in. There are two options. ### Use your Organization Zone as the Identity Provider Every Keycard Zone can act as an Identity Provider for other Zones. If the custom Zone's users are members of your organization, connect your Organization Zone as the custom Zone's Identity Provider. Users then sign in to the custom Zone with their organization accounts. 1. **Copy your Organization Zone's Issuer URL** In Keycard Console, go to **Settings** → **Connection** and copy the **Issuer URL** of your Organization Zone. 2. **Create a Provider in the custom Zone** 1. Switch to the custom Zone, click **Providers** in the sidebar, and then **Add Provider**. 2. Enter a **Name**, such as "Organization". 3. In the **Issuer URL** field, enter the Organization Zone's Issuer URL you copied. 4. Click **Connect**. 3. **Select the Provider for the Zone** 1. Go to **Zones**, open the **⋯** menu on your Zone's card, and select **Settings**. 2. On the **Settings** tab, in the **Zone sign in configuration** section, open the **Identity Provider** dropdown and select the Provider you just created. 3. Click **Save Changes**. ### Use an external Identity Provider as the source of truth If your Zone's users already have accounts in an Identity Provider such as Okta, Auth0, or Google, connect it and select it as the Zone's Identity Provider. Users then sign in to the Zone with their existing accounts. 1. Connect your Provider by following [Identity Providers](/admin/identity-providers/). 2. Go to **Zones**, open the **⋯** menu on your Zone's card, and select **Settings**. 3. On the **Settings** tab, in the **Zone sign in configuration** section, open the **Identity Provider** dropdown and select your Provider. 4. Click **Save Changes**. For end-to-end Provider setup, see the [Okta](/admin/tutorials/okta-sign-in/) and [Auth0](/admin/tutorials/auth0-sign-in/) tutorials. ## Next Steps - Control what the Zone's users and agents can access with [Access Policies](/admin/access-policies/). - Add [Applications](/concepts/applications/) and [Resources](/concepts/resources/) to the Zone. --- # Concepts ## https://docs.keycard.ai/concepts/zones # Zones A zone is a logical grouping of [users](/concepts/users/), [applications](/concepts/applications/), and [resources](/concepts/resources/) that share a common set of security controls and policies. Zones are also referred to as security domains or trust domains. Zones control access to resources by users and applications. Zones verify _authentication credentials_ presented by people and agents, evaluate policies that determine allowed permissions, and issue _access credentials_ that permit scoped access to resources. Each zone has a default domain in the format `.keycard.cloud`. Keycard supports custom domains to change this to any domain that you or your organization owns. Your organization is a zone: the security domain for its users, applications, resources, and providers. No separate setup is required to begin issuing credentials. More broadly, a Keycard zone is either your organization or an custom zone you create for a separate set of users, such as separating environments or running a customer-facing product. ## User Interaction Every zone has the ability to interact with users, displaying prompts that are necessary for authentication and authorization. These prompts include dialogs to sign in, step-up to multi-factor authentication, or grant consent for applications to access data. These prompts are typically displayed to users as HTML forms within a web browser. These prompts become embedded user interface (UI) components within applications. When an application needs to authenticate a user or obtain authorization to access resources, it sends an authentication or authorization request to the zone. Thus, the zone is referred to as an _authentication server_ or _authorization server_. Or, more broadly, an _identity server_. ## Security Token Service Every zone has an instance of Keycard STS, short for Security Token Service. Keycard STS is the identity server for the zone. Keycard STS implements OpenID Connect and OAuth 2.1 - both industry standard protocols - along with a suite of extensions necessary to secure agentic applications, which operate in highly-federated environments requiring delegated authority. As part of fulfilling its role as an authorization server, Keycard STS handles authentication and authorization requests. These requests often trigger user interaction in a sequence of challenges and responses. Assuming the challenges are sucessfully passed and policy permits access, Keycard STS issues access credentials valid at protected resources within the zone. ## Federation While a zone can be self-contained, its full capabilities are realized by connecting to other domains, in what is termed _federation_. Zones connect to other domains through [providers](/concepts/providers/). Providers fall into one or more categories: 1. User identity providers 2. Application identity providers 3. Access credential providers User identity providers allow people to sign in using accounts from other domains. In enterprise scenarios, this is known as single sign-on (SSO), and allows employees to sign in using their company account, from providers such as Okta or Microsoft Entra. In consumer scenarios, people can sign in using an account at Google, Apple, or a social network in what is referred to as social login. Application identity providers allow applications to authenticate using identity tokens. These tokens are typically issued by cloud providers to applications, or workloads, running on cloud infrastructure operated by Amazon Web Services (AWS), Microsoft Azure, Google Cloud, and others. Native desktop and mobile applications may also have identity asserted by the underlying operating system or device. Access credential providers issue access credentials, also referred to as access tokens, for resources hosted by third-parties. This is common when accessing APIs provided by software-as-a-service (SaaS) vendors. Access credential providers enable credentials to be brokered between domains. Every zone is also capable of acting as a provider for other domains. This enables inter-zone federation within Keycard, as well as federation from Keycard zones to other identity systems. ## https://docs.keycard.ai/concepts/users # Users Users are people who access protected [resources](/concepts/resources/), either directly or by delegating access to [applications](/concepts/applications/) and agents that act on their behalf. ## Credentials Users authenticate to a [zone](/concepts/zones/) using credentials, including passwords and passkeys, or through single sign-on (SSO) via their identity provider (IdP). ### Passwords Keycard supports issuing passwords to users. A password is a secret shared between Keycard (where it is stored in secure hashed format) and the user, who is responsible for keeping it secret. Passwords and other zone-specific credentials are applicable when there is not an existing user identity system in place, as well as when supporting users who prefer to create accounts rather than sign in via SSO or social login. > **Caution:** While passwords are a familiar method of signing in for many people, they pose inherent security risks. Keycard recommends disabling passwords entirely, or combining passwords with other authentication factors for increased security. ### Federated Keycard supports federated credentials, whereby a user logs in via SSO or social login. In enterprise scenarios, the corporate IdP can be used for employee SSO. Keycard supports Okta, Microsoft Entra, or any provider that implements standard protocols, including OpenID Connect and SAML. In consumer scenarios, people can sign in using their existing accounts at Google, Apple, or a social network. This is often referred to as social login, and uses the same underlying protocols, including OpenID Connect, OAuth, and SAML. ## Identifier Every user has an identifier, a stable, zone-scoped value that uniquely identifies the user. By default, the identifier is the user's Keycard ID. Providers can auto-populate identifiers on first user login. See [User Identifier Claim](/concepts/providers/#user-identifier-claim) for details. Identifiers can also be set or updated in Keycard Console or via the management API. Each identifier must be unique. The identifier defines the [`sub` claim](/reference/token-claims/) in tokens issued for the user. ## Groups A user can belong to any number of [groups](/concepts/groups/): named collections of users within the zone. Groups carry role assignments that every member inherits, and policies can grant access to a group instead of naming each user. ## Registration Keycard supports private and public modes for user registration. ### Private In private mode, users must have a pre-existing account at a configured identity provider. This is necessary for internal or private domains such as a company, organization, or family. ### Public In public mode, users are free to create accounts or sign in using any configured identity provider. This is useful when providing a product or service to the general public. ## https://docs.keycard.ai/concepts/applications # Applications Applications are software that access [resources](/concepts/resources/), either autonomously or as a result of a user delegating access to the application. Applications encompass a variety of implementation and deployment characteristics including, but not limited to, web applications running on a server, single-page applications running in a browser, native desktop and mobile apps, command line interfaces, and background daemons. ## Identifier Every Application has an identifier, which must be unique. It defines the [`keycard_app_id` claim](/reference/token-claims/) in every token Keycard issues, and the `sub` claim in application tokens. ## Credentials Applications authenticate to a [zone](/concepts/zones/) using credentials. Keycard supports multiple types of application credentials suitable for varying security levels, deployment models, and compatibility requirements. ### Client ID & Secret Keycard supports issuing a client ID and secret (equvalient to a username and password) to applications. The secret is shared between Keycard (where it is stored in secure hashed format) and the application. Keycard verifies the secret to confirm the application's identity. It is the responsibility of the application to ensure that the secret is stored securely and remains secret. > **Caution:** Use of shared secrets poses inherent security risks. Keycard recommends use of credential types other than client ID and secret. ### Workload Identity Applications that run on cloud infrastructure are referred to as _workloads_. Cloud providers, including Amazon Web Services, Google Cloud, and Vercel, issue workload identity tokens to applications running on thier platforms. These workload identity tokens can be used to authenticate to Keycard. Workload identity is a form of federation, although it is not broadly standardized today. Keycard implements support for popular cloud providers, and each provider's proprietary token format and claim sets. > **Note:** Due to lack of standardization, each cloud provider refers to workload identity using slightly different terminology. Confusingly, it is sometimes referred to as OpenID Connect, due to use of shared technology - in particular JSON Web Token (JWT) and JSON Web Key (JWK) sets. OpenID Connect is, however, a user identity protocol rather than a workload identity protocol. ### Public Public applications, such as single-page apps running in a browser, cannot securely store secrets. These applications authenticate using only their client ID, relying on PKCE to protect the authorization code exchange. Public credentials are only valid for [user delegation](/concepts/credentials/#user-delegation) flows and cannot be used for [autonomous access](/concepts/credentials/#autonomous-access). ### URL Applications can prove control of a URL using key-based assertions. Applications authenticate by signing an assertion claiming control of a URL. Keycard fetches the public key from metadata associated with the claimed URL in order verify the signature. If the signature is valid, the application has authenticated using the URL as its identity. Keycard supports authenticating URLs using the JWT assertions, as defined by OAuth 2.0 and OpenID Connect. Public keys are fetched from [OAuth Client ID Metadata Documents (CIMD)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document). ## Registration Before an application can make requests to a zone, it must be registered. Registration supplies the information needed by Keycard to authenticate the application, including credentials and URLs. It also supplies metadata, including name and logo, which are displayed to users so that they can make informed consent decisions. Keycard supports private and public modes for user registration. ### Private In private mode, applications must be explicitly registered by an administrator. Upon registration, the application is assigned an identifier and a credential. The application is expected to be configured with this identifier and credential by the administrator, prior to the application being deployed or distributed to users. ### Public In public mode, applications can register dynamically, obtaining an identifier and credential at runtime. This is useful in open, standards-based ecosystems such as MCP. It allows applications to connect to resources, and corresponding authorization servers, without ahead-of-time registration. This eliminates friction for both users and developers. Keycard supports public registration via OAuth 2.0 Dynamic Client Registration Protocol (DCR) and [OAuth Client ID Metadata Document (CIMD)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document). ## Applications as Consumers Applications can consume [resources](/concepts/resources/) directly or while acting on behalf of a user. A coding agent may need repository access, an MCP server may need to call GitHub for the authenticated user, and a backend service may need direct access to a billing API. In Keycard, those relationships are modeled through dependencies, consent, and policy. Dependencies describe what an application is allowed to request; policy decides whether a specific request should receive credentials. ## Dependencies Applications may have dependencies on [resources](/concepts/resources/). Dependencies declare service-to-service relationships, where the application requires access to the resource in order to properly function. For example, a web application may require access to both a database and a billing API. In Keycard, such access is permitted by creating two dependencies of the application, one on the database and another on the billing API. Dependencies only permit access when an application is acting on its own behalf. If the same web application requires access to a user's calendar, that access is not permitted by a dependency. Rather, policy would have to explicitly allow such access or consent would need to be granted by each user on an individual basis. Because dependencies are used to authorize service-to-service access, it is uncommon to to declare dependencies of native applications, which typically access resources on a user's behalf. Native applications instead request consent from each user, and should gracefully handle conditions in which consent is not granted. ## Providing Resources Applications may provide [resources](/concepts/resources/). This usually implies a server-side application that, in addition to rendering an interactive user interface (UI), also hosts an API. For example, an appointment scheduling service would offer a web-based application accessed via a browser, as well as a scheduling API called by the application. The same scheduling service may offer an AI-native MCP server that renders UI inside ChatGPT or Claude, in addition to exposing tools to the LLM. Declaring the resources provided by an application is needed in complex multi-service architectures that operate on behalf of a user. For example, consider a user scheduling an appointment with their dentist. Upon signing in, the scheduling application presents the user with a list of times the dentist is available. This list is rendered as a result of an authenticated request to the scheduling API. If the API can obtain a credential to access the user's calendar, it can make it easier to book an appointment by showing only times that both the user and dentist are available. Because the application provides the API, it can exchange access tokens received in API calls for access tokens needed to call other APIs, such as the user's calendar API. This creates a chain of delegation. ## Consent When an application needs to access resources on a user's behalf, Keycard presents a consent screen before the authorization flow continues. ### The consent screen The consent screen shows: - The requesting application's name and logo - The zone being accessed - The [resources](/concepts/resources/) the application will access - Any [dependencies](#dependencies) on other resources The user can approve the full request or cancel. There is no partial consent. ### Transitive consent When an application calls another application to fulfill a request, each hop in the chain requires its own credential. Transitive consent covers the full chain in a single prompt, so the user approves access to the application and all downstream applications it [depends on](#dependencies) at once. See [Delegation Chaining](/concepts/credentials/#delegation-chaining) for how transitive consent fits into credential issuance. ### Configuration The `consent` property on an application controls whether users see a consent screen: - **`required`** — Users must approve before the application can access resources on their behalf. This is the default. - **`implicit`** — Users are not shown a consent screen; access is granted automatically. Privately registered applications remember consent across sessions. Publicly registered applications require consent each session, regardless of the `consent` property. This property is configured per-application in [Console](/admin/) or via the Terraform provider. ## https://docs.keycard.ai/concepts/resources # Resources Resources allow [users](/concepts/users/) and [applications](/concepts/applications/) to access and operate on protected data. Resources typically take the form of web APIs, MCP servers, and databases. More generally, they are services which make access available via a network protocol, such as HTTP, FTP, or SSH. In some cases, resources may expose physical infrastructure such as printers. Resources represent anything that requires authentication and authorization in order to access. ## Identifier Every Resource has an identifier, which must be unique. It defines the [`aud` claim](/reference/token-claims/) in tokens issued to access the Resource. ## Access Keycard [zones](/concepts/zones/) authenticate users and agents, authorize their access, and issue _access credentials_ that reflect the authentication and authorization that was performed. Resources validate these credentials, thereby enforcing end-to-end access control. Tokens are encoded in standard formats, including JSON Web Token (JWT) and CBOR Web Token (CWT). ## Credentials Every resource is associated with a credential provider that determines what kind of credential Keycard produces when access is granted. The credential provider is configured when a resource is registered with a zone. ### Keycard-Issued Tokens When the zone's built-in provider is used, Keycard STS signs a JWT for the resource. The JWT contains claims identifying the user (or application), the resource audience, granted scopes, and expiration. These can be validated using the zone's public keys, available at the zone's JWKS endpoint. See [Token Claims](/reference/token-claims/) for the claims and how to verify them. This is the default for resources hosted within the zone's trust domain, including MCP servers and APIs that integrate with Keycard directly. ### Vaulted Static Credentials When a Keycard Vault provider is used, Keycard retrieves a stored credential from the vault and returns it to the requesting application. This is the path for legacy systems that do not support dynamic, token-based authentication: databases with static passwords, older APIs that expect long-lived API keys, and services that require basic auth. Keycard stores these credentials encrypted, brokers them on demand, and audits every retrieval, bringing the same access control and observability to resources that cannot participate in OAuth flows. ### Brokered Access When an external [access credential provider](/concepts/providers/#access-federation) is used, Keycard brokers credentials from a third-party authorization server. The external provider issues tokens for resources it controls (e.g., GitHub, Google, Slack), and Keycard stores and manages those tokens on behalf of users. Brokered credentials are established during interactive flows such as the [authorization code flow](/concepts/credentials/#user-delegation), where the user consents to the application accessing external resources. Subsequent requests use the stored credentials without requiring user interaction. See [Brokered Credentials](/concepts/providers/#brokered-credentials) for how these credentials are managed and refreshed. See it in practice: the [Catalog](/admin/catalog/) provides one-click brokered resources for GitHub, Gmail, Slack, Linear, Notion, Stripe, and more. ## https://docs.keycard.ai/concepts/providers # Providers Providers issue _authentication credentials_ to [users](/concepts/users/) and [applications](/concepts/applications/), and issue _access credentials_ that permit users and agents to access [resources](/concepts/resources/). Every Keycard [zone](/concepts/zones/) has a built-in provider which can authenticate users and applications using locally-issued credentials, such as passwords and client secrets. The same provider can also issue access tokens that authorize access to resources. In this default configuration, every zone is a self-contained trust domain that authenticates users and agents and authorizes access to resources within the zone. External providers can be added to a zone, enabling trust relationships with other domains - also referred to as federation. ## Identity Federation ### User Identity External identity providers (IdPs) can be added to a zone, creating a trust relationship between the zone and the IdP. This enables single sign-on (SSO), allowing users to sign in to the zone with one set of credentials. Keycard zones support standard protocols, including OpenID Connect and SAML. These protocols are broadly supported by both enterprise IdPs, including Microsoft Entra and Okta, and social login providers, including Google and Apple. For your organization, the user identity provider cannot be changed directly. Configure it by setting up [single sign-on](/admin/single-sign-on/). External zones can set their user identity provider directly. #### User Identifier Claim OpenID Connect providers used as the Zone Identity Provider can optionally configure a `user_identifier_claim`, which specifies a claim from the provider's ID token to use as the [user identifier](/concepts/users/#identifier) (e.g. `email`, `oid`, `sub`). When set, new users created through the provider have their identifier populated from this claim automatically. Existing users are not affected by this setting. Their identifiers can be backfilled in Keycard Console or via the management API. ### Workload Identity Cloud service providers (CSPs) can be added to a zone, creating a trust relationship between the zone and the CSP. This enables applications and workloads running on the provider to authenticate to the zone using provider-issued tokens, eliminating the need for static secrets. Keycard zones support popular infrastructure providers, including Amazon Web Services, Microsoft Azure, and Google Cloud. Platforms such as Vercel and GitHub Actions are also supported. ## Access Federation External authorization servers which issue access credentials for resources hosted by third parties can be added to a zone. This allows the zone to broker access to resources located in other trust domains. Keycard zones support brokering via a variety of different mechanisms, including OAuth 2.0 grants that enable cross app access and programmatic access interfaces that are bespoke to specific resource types or providers. ### Brokered Credentials When a user authorizes an application to access an external resource, Keycard coordinates with the external provider to obtain access credentials. These credentials are stored encrypted in the zone and managed by Keycard on behalf of the user. Brokered credentials are established during interactive flows. In the [authorization code flow](/concepts/credentials/#user-delegation), if an application has declared [dependencies](/concepts/applications/#dependencies) on external resources, Keycard redirects the user to each external provider as part of the consent process. The user authorizes access once and Keycard stores the resulting credentials for subsequent use. When an application later requests access to the external resource via [token exchange](/concepts/credentials/#delegation-chaining), Keycard returns the stored credentials without requiring user interaction. If no credentials exist for the user and resource, Keycard returns an error code signaling that the user must complete an interactive authorization flow before access can be granted. ### Credential Refresh Brokered credentials have a limited lifetime determined by the external provider. When credentials include a refresh token, Keycard can obtain fresh credentials from the external provider without user interaction. Applications can also use a [refresh token](/concepts/credentials/#credential-refresh) from the original authorization to request credentials for a different resource than the one originally authorized. This allows a single interactive authorization to serve as the basis for accessing multiple resources over time. ## https://docs.keycard.ai/concepts/policies # Policies Policies are the rules Keycard evaluates before issuing or brokering access. They are what turn identity into controlled action: a user, application, and resource may all be known to the zone, but access is still denied unless policy permits it. Keycard uses a default-deny model. Every authorization request needs an explicit permit for the user, the application, and the resource being accessed. A forbid rule overrides a permit, which lets teams start with safe baselines and layer more restrictive rules where needed. ## What Policies Decide Policies answer questions like: - Can this user access this resource? - Can this application act on behalf of this user? - Can this application access a resource directly, without a user? - Does the request match the resource dependencies configured on the application? - Does this user or application hold a role that grants access? - Should runtime context, such as delegated access, roles, groups, or claims, change the decision? [Group](/concepts/groups/) membership is resolved when the request is evaluated, not from a claim on the presented credential, so adding or removing a member changes access without a policy update and without the user signing in again. Group rules also apply consistently to flows that run without a user session. Policies can still read the raw `groups` claim an identity provider puts on an ID token, but that claim reflects one sign-in rather than current directory state. When policy permits access, Keycard can issue a [credential](/concepts/credentials/) for the target [resource](/concepts/resources/). When policy denies access, Keycard does not issue the credential. ## Where Policies Fit A policy decision sits in the middle of the Keycard model: 1. A [user](/concepts/users/) or [application](/concepts/applications/) requests access. 2. The request targets a [resource](/concepts/resources/). 3. Keycard evaluates policy inside the [zone](/concepts/zones/). 4. If permitted, Keycard issues or brokers a scoped credential. 5. The decision and resulting access are available for audit. This is the core control loop for agents: they can keep acting, but every access request is checked against policy before credentials are handed out. ## Managed And Customer Policies Keycard includes managed policies for common access patterns, including user access, delegated application access, and direct application access based on resource dependencies. Teams can also define customer policies for their own requirements. Policies are assembled into policy sets and deployed across a zone. That makes policy operationally safe: you can version rules, activate a known-good set, and roll back when needed. ## Policy Language Keycard policies are written in Cedar, a declarative authorization language. Cedar is intentionally constrained: policies describe who can do what under which conditions, without loops or side effects. For authoring details, schemas, examples, and lifecycle operations, see [Access Policies](/admin/access-policies/). ## https://docs.keycard.ai/concepts/credentials # Credential Issuance Keycard STS supports several methods for issuing credentials, each suited to different access scenarios. Every method produces a credential scoped to a specific [resource](/concepts/resources/). What varies is how the caller [authenticates](/concepts/applications/#credentials) and whether access is on behalf of a user. The credential itself is determined by the resource's [credential type](/concepts/resources/#credentials): a [Keycard-issued token](/concepts/resources/#keycard-issued-tokens), a [vaulted static credential](/concepts/resources/#vaulted-static-credentials), or [brokered access](/concepts/resources/#brokered-access) from an external provider. Every issuance is recorded in the [audit log](/admin/audit-log-export/), evaluated against the zone's [access policies](/admin/access-policies/), and, when a user is involved, requires [consent](/concepts/applications/#consent). ## User Delegation Used when an application needs to access resources on behalf of a user. The user authenticates interactively and the application receives credentials it can use to act for that user. Keycard STS implements this using the authorization code flow with PKCE ([OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15)). The application redirects the user to Keycard STS, the user authenticates via the zone's identity provider, and the application exchanges the resulting authorization code for credentials. Confidential applications authenticate with their client credentials during the exchange; public applications (native apps, CLI tools, MCP clients) rely on PKCE alone. During this flow, Keycard STS also handles: - **Consent** — Keycard STS evaluates [consent](/concepts/applications/#consent) requirements for the application. If consent is required and has not been previously granted, the user sees a consent screen before the authorization code is issued. - **Brokered credentials** — If the application has [dependencies](/concepts/applications/#dependencies) on external resources, the user authorizes the zone to [broker credentials](/concepts/providers/#brokered-credentials) for each dependency, so that they are available for issuing later. Keycard STS returns a credential, a refresh token, and (if the `openid` scope was requested) an ID token.
User
Application
Resource
Keycard STS brokered credentials
User
authorize
Application
authorization codeKeycard STS
credential
Resource
### Refreshing Used to obtain new credentials without requiring the user to interact again. Covers two cases: replacing an expired credential for the same resource, and obtaining a credential for a different resource using the existing authorization. If the target resource requires brokered credentials that have not been established, Keycard STS returns an error indicating user interaction is needed. ## Autonomous Access Used by an application to access resources on its own behalf, with no user involved. Covers service-to-service, machine-to-machine, and agent-to-agent communication. The application authenticates directly to Keycard STS using the client credentials flow ([OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15)) with any of the supported [application credentials](/concepts/applications/#credentials) (client secret, workload identity, or URL credential). Public credentials are not supported for autonomous access because there is no user present to perform PKCE-protected authorization. No user context is available. Because there is no user, client credentials cannot directly obtain [brokered access](/concepts/resources/#brokered-access) to external resources. To access brokered resources on behalf of a user, an autonomous application can use [impersonation](#impersonation).
Application
Resource
Keycard STS
Application
client credentialsKeycard STS
credential
Resource
## Delegation Chaining Used when an application needs to access a downstream resource to fulfill a request on behalf of a user. For example, a user authorizes an MCP client, the MCP client calls an MCP server, and the MCP server needs to call an external API. Each hop in this chain requires its own credential, but the user only authenticates once. The application at each hop presents the credential it received (the subject token) along with the target resource to Keycard STS using token exchange ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)). Keycard STS validates the subject token, evaluates [policies](/admin/access-policies/) to check that the application and subject are allowed to access the resource, and returns a new credential scoped to the requested resource. The new credential carries the full chain of delegation, so policy and audit logs can trace the path from the original user through every intermediary. If the subject has not authorized access, Keycard STS returns an error code letting the application know that user interaction is required to establish credentials. The application authenticates itself using any of the [application credentials](/concepts/applications/#credentials) supported for [autonomous access](#autonomous-access).
User
MCP Client
MCP Server
Resource
Keycard STS brokered credential
User
authorize
MCP Client
authorization codeKeycard STS
user token
MCP Server
token exchangeuser tokenKeycard STS
brokered credential
Resource
## Impersonation Used by an autonomous application to act as a specific user without the user being present. The application authenticates *itself* using [autonomous access](#autonomous-access) and names the *user* it acts for with a substitute-user token, then exchanges the two for a credential. It requires that the user has previously authorized the application through [user delegation](#user-delegation). Using token exchange ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)), instead of a subject token from an active user, the application provides a substitute-user token containing the user's [identifier](/concepts/users/#identifier). Keycard STS resolves the identifier to the user and issues a credential as if that user had made the request.
INTERACTIVE SETUP
User
Landing Page
Keycard STS brokered credential
NON-INTERACTIVE
Background Agent
Resource
INTERACTIVE SETUP
User
authorize
Landing Page
storeKeycard STS
NON-INTERACTIVE
Background Agent
token exchangeKeycard STS
substitute-user token
credential
Resource
### How the agent authenticates Impersonation involves two independent authentication concerns. Keep them separate when designing an agent: - **The agent authenticates itself.** The application presents one of its confidential [application credentials](/concepts/applications/#credentials) (client secret, workload identity, or URL credential), exactly as in [autonomous access](#autonomous-access). Public credentials are not supported — there is no user present to perform PKCE, and an unauthenticated caller must not be able to impersonate anyone. - **The agent names the user.** Instead of a subject token from a live session, the application supplies a *substitute-user token* carrying the user's [identifier](/concepts/users/#identifier). This token is not a credential and proves nothing on its own. > **Caution: The token is intentionally unsigned** The substitute-user token carries no security guarantees on its own; anyone could construct one. Impersonation is safe only because of the layers around it: the application authenticates with its own confidential credential, the user has previously authorized the application for the resource, and the exchange is still subject to access-policy evaluation. ### Requirements All of the following must hold: - **Confidential client.** The application authenticates with a client secret, workload identity, or URL credential. Public clients are rejected. - **Implicit consent.** The application must be configured with [consent](/concepts/applications/#configuration) set to `implicit`, because the user is not present to approve a consent screen during the exchange. - **Authorized by policy.** The exchange is evaluated by normal [access policy](/admin/access-policies/). Under the default policies an application may impersonate a user for any resource it declares as a dependency that the user has authorized; add a `forbid` policy to restrict it. The exchange is denied if the resource is not a dependency or a policy forbids impersonation. - **Prior authorization exists.** The user must have authorized the application for the resource through [user delegation](#user-delegation) (the *interactive setup* above), which establishes the consent grant and, for [brokered](/concepts/resources/#brokered-access) resources, the brokered credentials. Otherwise the exchange returns `interaction_required`. ### What you receive Keycard STS resolves the identifier to the user and issues a credential that acts as that user against the resource. What the credential contains depends on the resource: a [Keycard-issued token](/concepts/resources/#keycard-issued-tokens) carries the user as its subject (`sub`) with an `act` claim chain naming the application for audit; a [brokered](/concepts/resources/#brokered-access) resource returns the provider's own token for that user, which is often opaque and carries no such claims. Because no user is actually present there is no session, so the credential carries no session id — if your downstream authorization depends on session-derived context, account for its absence. Every impersonated issuance is recorded in the [audit log](/admin/audit-log-export/) with the application as the actor and the impersonated user as its delegator, so access is always traceable to both. For a worked example using the SDK, see [Act on Behalf of Absent Users](/guides/act-on-behalf-of-absent-users/). ## https://docs.keycard.ai/concepts/groups # Groups A Group is a named collection of [Users](/concepts/users/) within a [Zone](/concepts/zones/). Groups let you grant access to a set of Users at once: assign a Role to a Group and every member inherits it, or name the Group in a policy instead of enumerating each User. Groups are created and managed by an Admin. See [Groups](/admin/groups/) for the Console steps. > **Tip: Coming soon** Provisioning Users and Groups from your identity provider over SCIM 2.0 is coming soon. ## Name and Identifier Every Group has a display name and an identifier that is unique within the Zone. The identifier is what policy rules match on, so it is the stable reference for a Group. Both can be changed at any time. ## Membership A User can belong to any number of Groups. Groups cannot be nested: only Users can belong to Groups. ## Roles A Group can be assigned organization Roles and custom Zone Roles. Every member of the Group inherits them, and a User's effective Roles are the union of the Roles assigned to them directly and the Roles assigned to every Group they belong to. Removing a User from a Group removes the Roles they were assigned through it. ## Groups in Policy Group membership is a first-class entity in [policy](/concepts/policies/) evaluation. A Cedar rule matches a Group with the `in` operator: ```cedar permit ( principal in Keycard::Group::"data-analysts", action, resource ); ``` > **Note: Membership is resolved at evaluation time** Group membership is read when the request is evaluated, not from a claim on the credential the caller presented, so adding or removing a member changes access without a policy update and without the User signing in again. This is distinct from the raw `groups` claim your identity provider may put on an ID token, which policies can still read as `context.subject_claims.groups`. See [Group-based policies](/admin/access-policies/#group-based-policies) for the full policy reference and more examples. --- # Reference ## https://docs.keycard.ai/cli # CLI The `keycard` CLI handles authentication, credential management, resource authorization, and secure agent sessions. > **Tip:** Set your `zone.id` in a `keycard.toml` file to avoid passing `--zone` on every command. See [Configuration](#configuration) below. ## Install ```bash brew install keycardai/tap/keycard ``` Verify the installation: ```bash keycard version ``` ### Install plugin Install the Keycard plugin for Claude Code: ```bash keycard plugin install ``` The plugin includes a set of [Skills](/skills/) Claude uses to manage credentials, query policy, and update `keycard.toml`. Pull down the latest version of the plugin at any time: ```bash keycard plugin update ``` > **Note:** `keycard plugin install` and `keycard plugin update` currently support Claude Code only. To manage the plugin through Claude Code instead, use `claude plugin marketplace add keycardai/plugins` followed by `claude plugin install keycard-cli@keycardai`. ### Supported platforms The Keycard CLI supports macOS and Linux on both `amd64` and `arm64` architectures. ### Update Update to the latest version with Homebrew: ```bash brew upgrade keycard ``` ## Authentication Sign into your Keycard account. The CLI opens an OIDC browser sign-in flow and stores the resulting tokens in your system keyring. ```bash keycard auth signin --zone --org ``` | Flag | Description | | -------------- | ------------------------------------------------------------ | | `-z`, `--zone` | Zone ID. Falls back to `ZONE` or `zone.id` in `keycard.toml` | | `-o`, `--org` | Organization ID | Check your current identity: ```bash keycard auth whoami --zone ``` Sign out and remove stored tokens. Accepts `-z`/`--zone` and `-o`/`--org`: ```bash keycard auth signout --zone ``` ## Resource authorization Authorize access to one or more Resources. The command also initiates sign-in if you don't have an active session. ```bash keycard auth resource ... --zone ``` Use this when a tool reports that resource access requires authorization. > **Note:** `keycard auth resource` requires an interactive terminal. In a non-interactive agent subprocess, use [`keycard agent exec`](#agent-exec) instead, which fails fast rather than prompting. ## Running commands Run a command inside a Keycard secure session to provision just-in-time credentials and enforce policy on tool use. ```bash keycard run --zone -- ``` | Flag | Description | | -------------- | ------------------------------------------------------------------------------------------------- | | `-z`, `--zone` | Zone ID. Falls back to `ZONE` or `zone.id` in `keycard.toml` | | `--itl-prompt` | In-the-loop prompter backend: `osascript`, `browser`, or `native`. Also settable via `ITL_PROMPT` | Credentials are sourced from `[[credentials.default]]` entries in `keycard.toml`. See [Configuration](#configuration) for details. To run Claude Code inside a secure session, use the dedicated subcommand: ```bash keycard run claude --zone ``` The policy enforced on tool use comes from your local policy file. [Platform-Managed Policy](/cli/platform-managed-policy/) (coming soon) distributes and enforces that policy centrally from your Zone instead. ### Session environment variables Inside a secure session, the following environment variables are set automatically: - `KEYCARD_RUN=1`: indicates the process is running inside a Keycard session. - `KEYCARD_RUN_SESSION_ID`: the unique session identifier. ## Credentials Get an access credential for a Resource URI: ```bash keycard credential read --zone ``` The URI must be present in the default credential set. Configure it in `keycard.toml` first. List all credential entries configured in `keycard.toml` that would be hydrated by `keycard run`: ```bash keycard credential info ``` Add a gateway credential entry, or sync credential configuration down from your Zone: ```bash keycard credential add keycard credential sync ``` ## Configuration ### Config file The CLI loads configuration from `keycard.toml` in the current directory. Override the path with `--config` or `CLI_CONFIG`. ```toml [zone] id = "" [[credentials.default]] env_var = "GH_TOKEN" resource = "https://api.github.com" ``` ### Global flags | Flag | Description | | -------------- | --------------------------------------------------- | | `--config` | Configuration file path. Defaults to `keycard.toml` | | `-h`, `--help` | Show help for a command | The Zone ID is resolved from `--zone`, then the `ZONE` environment variable, then `zone.id` in `keycard.toml`. Commands that need a Zone fail with an explicit error if none of the three is set. ## Commands | Command | Description | | ------------------------- | -------------------------------------------- | | `keycard run` | Run a command in a Keycard secure session | | `keycard run claude` | Run Claude Code in a Keycard secure session | | `keycard auth signin` | Authenticate with your account | | `keycard auth signout` | Remove local authentication tokens | | `keycard auth whoami` | Identify the current account | | `keycard auth resource` | Authorize access to Resources | | `keycard credential read` | Read a credential for a URI | | `keycard credential info` | Show configured credential identifiers | | `keycard credential add` | Add a gateway credential entry | | `keycard credential sync` | Sync credential configuration from your Zone | | `keycard plugin install` | Install the Keycard plugin for Claude Code | | `keycard plugin update` | Update the Keycard plugin for Claude Code | | `keycard version` | Show version info. Accepts `--json` | ## Agent commands > **Caution:** Commands under `keycard agent` are designed to be invoked by AI agents and automated tooling, not directly by humans. They assume a non-interactive environment and may behave unexpectedly when run manually. Use the top-level commands (`keycard run`, `keycard auth`, `keycard credential`) for interactive use instead. | Command | Description | | ---------------------- | -------------------------------------------- | | `keycard agent hook` | Process agent hooks | | `keycard agent policy` | Print the active Cedar policy | | `keycard agent exec` | Run a command with credentials injected | | `keycard agent api` | Make an authenticated Management API request | ### Agent hooks Process hook events from AI agents (Claude Code, Cursor, and others). ```bash keycard agent hook ``` ### Agent policy Print the Cedar policy currently in effect, including its `@description`, `@credentials`, and `@itl` annotations: ```bash keycard agent policy ``` When policy is managed on the platform, this prints the version resolved for the session. See [Platform-Managed Policy](/cli/platform-managed-policy/). ### Agent exec Run a command with credentials hydrated from `[[credentials.default]]` entries in `keycard.toml`. Unlike `keycard run`, this fails immediately if any credential requires interactive authentication. Safe for use in non-interactive agent subprocesses. ```bash keycard agent exec --zone -- ``` Inside an `agent exec` subprocess, `KEYCARD_AGENT_EXEC=1` is set automatically. ### Agent API Make an authenticated HTTP request to the Keycard Management API and print the response body to stdout. | Flag | Description | | ---------------- | ----------------------------------------- | | `-X`, `--method` | HTTP method. Defaults to `GET` | | `-d`, `--data` | JSON request body. Reads stdin if omitted | | `-o`, `--org` | Organization ID | | `-z`, `--zone` | Zone ID | ```bash keycard agent api ``` Use `-X` to override the HTTP method (default: `GET`): ```bash keycard agent api -X POST ``` Use `-d` to pass a JSON request body (reads stdin if omitted): ```bash keycard agent api -X POST -d '{"key":"value"}' ``` Use `-o` / `--org` to specify an organization ID: ```bash keycard agent api --org ``` ## https://docs.keycard.ai/skills/keycard-credentials # keycard-credentials Shows what credentials are configured in this Keycard session — which services are available and what they provide access to. ## When to Use Use this skill when you want to know: - What credentials, tokens, or services are available in the current Keycard session - Which services are authenticated ("am I signed in to X?", "is my X token loaded?") - What tokens are present ("what tokens do I have?", "list my credentials") - Whether you have access to a specific service ("do I have access to X?") Do **not** use this skill to add, remove, or rotate credentials — use `keycard credential` commands directly for that. ## Arguments None. ## Examples - "What credentials do I have?" - "Am I authenticated?" - "What services are available in this session?" - "Is my GitHub token loaded?" ## https://docs.keycard.ai/skills/keycard-discover-entities # keycard-discover-entities Discover and wire credential entities or MCP servers via the Keycard Management API — find available entity URIs and register them in `keycard.toml`, or find MCP-provider applications and add them to `.mcp.json`. ## When to Use Use this skill when: - You need credentials for a specific service ("I need GitHub credentials") - You want to add a resource or credential entry ("add a resource", "add an X credential") - You want to know what entities are available in the Management API - You want to configure access to a new service ("configure access to X service", "set up X integration") - You want to add an MCP server ("add an MCP server", "what MCP servers are available in my zone", "discover MCP servers") Do **not** use this skill if you already have credentials and want to inspect them (use `keycard-credentials` instead), if you want to edit an existing config field that is not a credential entry (use `keycard-upsert-config`), or if you want to set a non-MCP config field (use `keycard-upsert-config`). ## Arguments `[service or action, e.g. 'I need GitHub credentials', 'list available entities', or 'add an MCP server']` ## Examples - "I need GitHub credentials" - "Add a Linear credential" - "What entities are available in the Management API?" - "Set up Slack integration" - "Add an MCP server" - "What MCP servers are available in my zone?" ## https://docs.keycard.ai/skills/keycard-query-policy # keycard-query-policy Answer questions about the active Cedar policy and diagnose tool blocks — read-only; does not modify the policy. ## When to Use Use this skill when: - You want to know what tools are allowed by the current policy - You want to know whether a specific tool is permitted ("Can I use X?", "Am I allowed to use X?", "What's my policy?") - You want to understand why a tool was blocked - A tool was just blocked and you want to diagnose it Do **not** use this skill to change, add, or remove policy rules — use `keycard-upsert-policy` for that. Do not use it for general questions about Cedar concepts that don't reference your active policy. ## Arguments `[policy question or blocked tool, e.g. 'May I use the Bash tool?' or 'Why was Read blocked?']` ## Examples - "Why was the Bash tool blocked?" - "What tools does my policy allow?" - "Am I allowed to use WebFetch?" - "What's my current policy?" ## https://docs.keycard.ai/skills/keycard-upsert-config # keycard-upsert-config Set or change a field in `keycard.toml` — reads the current value and writes a targeted update. ## When to Use Use this skill when: - You want to set or change a field in `keycard.toml` - You want to add or update a credentials entry Do **not** use this skill for Cedar policy rules (use `keycard-query-policy`), to inspect what credentials are active in the current session (use `keycard-credentials`), or for read-only field questions where you don't intend to write. ## Arguments `[what to change, e.g. 'set my zone to dev-123' or 'add a GitHub credential entry for GITHUB_TOKEN']` ## Examples - "Set my zone to dev-abc123" - "Add a GitHub credential entry for GITHUB_TOKEN" - "Update the management API URL" ## https://docs.keycard.ai/skills/keycard-upsert-mcp-config # keycard-upsert-mcp-config Add or update an MCP server entry in `.mcp.json`. ## When to Use Use this skill when you want to add or update an MCP server in `.mcp.json`. Do **not** use this skill to set a `keycard.toml` field (use `keycard-upsert-config`), or to discover which MCP servers are available in your zone (use `keycard-discover-entities`). ## Arguments Two invocation forms are supported: - **HTTP transport** (e.g. Keycard gateway URLs): `add "" type=http url=""` - **Stdio transport** (local process-based servers): `add "" command="" args=[...] env={...}` `type` defaults to `stdio` when not specified. ## Examples - "Add an MCP server called my-server using npx with args -y @my/mcp-server" - "Add a local MCP tool at /usr/local/bin/my-mcp" - "Add an HTTP MCP server called granola at https://granola.mcp.example.com/sse" ## https://docs.keycard.ai/skills/keycard-upsert-policy # keycard-upsert-policy Propose, confirm, and apply a Cedar policy change — propose → confirm → write → verify. ## When to Use Use this skill when you want to: - Change, add, remove, enable, or disable a policy rule - Allow, deny, grant, restrict, or block a specific tool or action - Add ITL (in-the-loop) gating to a tool Do **not** use this skill to ask questions about what is currently allowed or why something was blocked — use `keycard-query-policy` for that. ## Arguments `[policy change request, e.g. 'Allow the Bash tool' or 'Require approval for WebFetch']` ## Examples - "Allow the Bash tool" - "Block WebFetch" - "Require human approval before Edit runs" - "Add ITL gating to the Write tool" - "Remove the restriction on Read" ## https://docs.keycard.ai/sdk/agent-to-agent # Agent-to-Agent The Agent-to-Agent package adds Keycard authentication to the [A2A protocol](https://github.com/google/A2A), so one AI agent can delegate tasks to another while preserving the user's identity and authorization context. > **Note:** The A2A packages in all four SDKs (Python `keycardai-a2a`, TypeScript `@keycardai/a2a`, Go `go-sdk/a2a`, and the `keycardai-a2a` gem) are pre-1.0 preview surfaces. APIs may change between minor versions. ## When to Use - Building agents that delegate work to other specialized agents - Exposing an agent as a service that other agents can call - Wiring Keycard auth into an existing [a2a-sdk](https://github.com/a2aproject/A2A) server - Implementing the [A2A protocol](https://github.com/google/A2A) with Keycard authentication ## Installation **Python:** ```bash pip install keycardai-a2a ``` Pulls in `keycardai-oauth`, `keycardai-starlette`, and `a2a-sdk[http-server] >= 1.0`. **TypeScript:** ```bash npm install @keycardai/a2a ``` Pulls in `@a2a-js/sdk` and Express handlers. **Go:** ```bash go get github.com/keycardai/go-sdk/a2a ``` Part of the `github.com/keycardai/go-sdk` module. Builds on the module's `oauth` package; wraps no A2A framework SDK. **Ruby:** ```bash bundle add keycardai-a2a ``` Pulls in the `keycardai-oauth` gem; wraps no A2A framework SDK. ## Key Exports Python and TypeScript follow a wrap-don't-reinvent pattern: you implement the executor against the native A2A SDK, and the Keycard package contributes auth wiring, agent card construction, and outbound delegation. Go and Ruby ship the delegation contract without wrapping an A2A SDK: discovery, token exchange, and invocation against any HTTP agent endpoint. **Python:** `keycardai.a2a` | Export | Description | | ----------------------------------- | ---------------------------------------------------------------------------- | | `AgentServiceConfig` | Service identity, Keycard credentials, and agent card metadata | | `build_agent_card_from_config` | Construct an `a2a.types.AgentCard` from an `AgentServiceConfig` | | `KeycardServerCallContextBuilder` | Propagate the verified bearer token onto `ServerCallContext.state` so executors can use it for downstream token exchange | | `DelegationClient` | Async client for remote agent invocation with Keycard token exchange | | `DelegationClientSync` | Synchronous variant of `DelegationClient` | | `ServiceDiscovery` | Resolve a remote service's `.well-known/agent-card.json` with caching | Server-side auth uses `KeycardAuthBackend` from `keycardai-starlette`. Executors implement `a2a.server.agent_execution.AgentExecutor` directly from `a2a-sdk`. **TypeScript:** `@keycardai/a2a` | Export | Description | | ------------------------------- | ---------------------------------------------------------------------- | | `AgentServiceConfig` | Service identity and agent card metadata (type) | | `buildAgentCard` | Construct an A2A `AgentCard` from `AgentServiceConfig` | | `createKeycardRequestHandler` | Wire an executor and agent card into the A2A request handler | | `keycardUserBuilder` | Validate Keycard JWTs on incoming A2A requests | | `getKeycardAuth` | Extract the verified `AccessToken` from a `RequestContext` | | `KeycardUser` | User type the auth builder injects into the A2A request context | | `DelegationClient` | Call remote agents with Keycard token exchange | | `ServiceDiscovery` | Discover available agent services | The package also re-exports Express handlers (`agentCardHandler`, `jsonRpcHandler`) and types (`AgentCard`, `Message`, `Task`, `AgentExecutor`) from [`@a2a-js/sdk`](https://github.com/a2aproject/a2a-js). **Go:** `go-sdk/a2a` | Export | Description | | ------------------------------- | ---------------------------------------------------------------------- | | `DelegationClient` | Call remote agents with Keycard token exchange (`NewDelegationClient`) | | `ServiceDiscovery` | Resolve a remote service's `.well-known/agent-card.json` with caching | | `NewTextMessage` | Build a user-role text `Message` with a generated message ID | | `Message` / `Part` / `AgentCard` / `Result` | Protocol types the client sends and returns | | `DiscoveryError` / `InvocationError` / `ConfigurationError` | Typed errors, one per stage of a delegated call | The package implements the delegation contract only and wraps no A2A SDK. Hosting an agent inside a specific framework is out of scope; protect your agent's endpoint with the `mcp` package's bearer verification instead. **Ruby:** `keycardai/a2a` | Export | Description | | ------------------------------- | ---------------------------------------------------------------------- | | `DelegationClient` | Call remote agents with Keycard token exchange | | `ServiceDiscovery` | Resolve a remote service's `.well-known/agent-card.json` with caching | | `Keycardai::A2A.text_message` | Build `message/send` params carrying a single text part | | `AGENT_CARD_PATH` / `JSONRPC_PATH` / `MESSAGE_SEND_METHOD` / `PROTOCOL_VERSION` | A2A protocol constants | | `DiscoveryError` / `InvocationError` | Typed errors for discovery and invocation (a rejected exchange raises `Keycardai::OAuth::OAuthError`) | The `keycardai-a2a` gem implements the delegation contract only and wraps no A2A SDK. Hosting an agent inside a specific framework is out of scope; protect your agent's endpoint with the `keycardai-mcp` gem's bearer verification instead. ## Quickstart ### Serve an Agent **Python:** Compose the Keycard helpers with `a2a-sdk`'s route factories and Starlette's `AuthenticationMiddleware`: ```python from a2a.server.agent_execution import AgentExecutor from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes from a2a.server.tasks import InMemoryTaskStore from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware from starlette.routing import Mount from keycardai.a2a import ( AgentServiceConfig, KeycardServerCallContextBuilder, build_agent_card_from_config, ) from keycardai.oauth.server.credentials import ClientSecret from keycardai.starlette import AuthProvider, KeycardAuthBackend, keycard_on_error config = AgentServiceConfig( service_name="Research Agent", description="Searches the web and summarizes findings", client_id="", client_secret="", identity_url="http://localhost:9000", zone_id="", ) auth_provider = AuthProvider( zone_url=config.auth_server_url, server_name=config.service_name, server_url=config.identity_url, application_credential=ClientSecret((config.client_id, config.client_secret)), ) class ResearchExecutor(AgentExecutor): async def execute(self, context, event_queue): # The verified bearer token is available for downstream delegation token = context.call_context.state["access_token"] # Process the delegated task... async def cancel(self, context, event_queue): pass agent_card = build_agent_card_from_config(config) request_handler = DefaultRequestHandler( agent_executor=ResearchExecutor(), task_store=InMemoryTaskStore(), agent_card=agent_card, ) app = Starlette(routes=[ *create_agent_card_routes(agent_card=agent_card), Mount( "/a2a", routes=create_jsonrpc_routes( request_handler=request_handler, rpc_url="/jsonrpc", context_builder=KeycardServerCallContextBuilder(), ), middleware=[Middleware( AuthenticationMiddleware, backend=KeycardAuthBackend( auth_provider.get_token_verifier(), require_authentication=True, ), on_error=keycard_on_error, )], ), ]) ``` For a runnable end-to-end version, see [`examples/keycard_protected_server`](https://github.com/keycardai/python-sdk/tree/main/packages/a2a/examples/keycard_protected_server) in the SDK repo. **TypeScript:** The TypeScript package builds on top of [`@a2a-js/sdk`](https://github.com/a2aproject/a2a-js) and Express. Compose the Keycard helpers with the SDK's Express handlers: ```typescript import express from "express"; import { buildAgentCard, createKeycardRequestHandler, keycardUserBuilder, getKeycardAuth, agentCardHandler, jsonRpcHandler, type AgentExecutor, } from "@keycardai/a2a"; const config = { serviceName: "Research Agent", description: "Searches the web and summarizes findings", clientId: process.env.KEYCARD_CLIENT_ID!, clientSecret: process.env.KEYCARD_CLIENT_SECRET!, identityUrl: "http://localhost:9000", zoneId: process.env.KEYCARD_ZONE_ID!, }; const executor: AgentExecutor = { async execute(requestContext, eventBus) { const auth = getKeycardAuth(requestContext); if (!auth) throw new Error("unauthenticated"); // Process the delegated task eventBus.publish({ messageId: crypto.randomUUID(), role: "agent", parts: [{ kind: "text", text: "Research complete" }], }); eventBus.finished(); }, async cancelTask() {}, }; const agentCard = buildAgentCard(config); const requestHandler = createKeycardRequestHandler(executor, agentCard); const userBuilder = keycardUserBuilder({ issuer: `https://${config.zoneId}.keycard.cloud`, }); const app = express(); app.use(express.json()); app.use("/.well-known/agent-card.json", agentCardHandler({ agentCardProvider: requestHandler })); app.use("/a2a/jsonrpc", jsonRpcHandler({ requestHandler, userBuilder })); app.listen(9000); ``` **Go:** The Go SDK implements the delegation contract only; hosting an agent inside a specific framework is out of scope. Serve the agent card and JSON-RPC endpoint with `net/http`, protecting the endpoint with the `mcp` package's bearer verification: ```go package main import ( "encoding/json" "log" "net/http" "os" "github.com/keycardai/go-sdk/mcp" "github.com/keycardai/go-sdk/oauth" ) func main() { issuer := os.Getenv("KEYCARD_ISSUER") serverURL := "http://localhost:9000" // Verify inbound bearer tokens against the Zone, audience-bound to this agent verifier, err := mcp.NewZoneTokenVerifier(issuer, oauth.WithAudiences(serverURL)) if err != nil { log.Fatal(err) } agentCard := map[string]any{ "name": "Research Agent", "description": "Searches the web and summarizes findings", "url": serverURL + "/a2a/jsonrpc", } jsonrpc := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { ID any `json:"id"` } _ = json.NewDecoder(r.Body).Decode(&req) // The verified bearer token (auth.Token) is available for downstream delegation auth := mcp.AuthInfoFromRequest(r) // Process the delegated task, then reply over JSON-RPC _ = json.NewEncoder(w).Encode(map[string]any{ "jsonrpc": "2.0", "id": req.ID, "result": map[string]any{"message": map[string]any{ "messageId": "m1", "role": "agent", "parts": []map[string]any{ {"kind": "text", "text": "Research complete for " + auth.Subject}, }, }}, }) }) mux := http.NewServeMux() mux.HandleFunc("/.well-known/agent-card.json", func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(agentCard) }) mux.Handle("/a2a/jsonrpc", mcp.RequireBearerAuth(verifier)(jsonrpc)) log.Fatal(http.ListenAndServe(":9000", mux)) } ``` **Ruby:** The `keycardai-a2a` gem implements the delegation contract only; hosting an agent inside a specific framework is out of scope. Serve the agent card and JSON-RPC endpoint as a Rack app, protecting the endpoint with the `keycardai-mcp` gem's bearer verification: ```ruby # config.ru require "json" require "keycardai/a2a" require "keycardai/mcp" ISSUER = ENV.fetch("KEYCARD_ISSUER") AGENT_URL = "http://localhost:9000" # Verify inbound bearer tokens against the Zone, audience-bound to this agent verifier = Keycardai::OAuth::TokenVerifier.new(issuers: ISSUER, audiences: AGENT_URL) AGENT_CARD = { "name" => "Research Agent", "description" => "Searches the web and summarizes findings", "protocolVersion" => Keycardai::A2A::PROTOCOL_VERSION, "url" => "#{AGENT_URL}#{Keycardai::A2A::JSONRPC_PATH}", }.freeze jsonrpc = lambda do |env| request = JSON.parse(env["rack.input"].read) # The verified bearer token (auth.token) is available for downstream delegation auth = Keycardai::MCP.auth_info(env) # Process the delegated task, then reply over JSON-RPC result = { "kind" => "message", "role" => "agent", "parts" => [{ "kind" => "text", "text" => "Research complete for #{auth.subject}" }], } [200, { "content-type" => "application/json" }, [JSON.dump({ "jsonrpc" => "2.0", "id" => request["id"], "result" => result })]] end protected_jsonrpc = Keycardai::MCP::RequireBearerAuth.new(jsonrpc, verifier: verifier) run lambda { |env| case env["PATH_INFO"] when Keycardai::A2A::AGENT_CARD_PATH [200, { "content-type" => "application/json" }, [JSON.dump(AGENT_CARD)]] when Keycardai::A2A::JSONRPC_PATH protected_jsonrpc.call(env) else [404, { "content-type" => "application/json" }, [JSON.dump({ "error" => "not_found" })]] end } ``` For a runnable end-to-end version, see [`examples/a2a-delegation`](https://github.com/keycardai/ruby-sdk/tree/main/examples/a2a-delegation) in the SDK repo. ### Call a Remote Agent **Python:** Use `DelegationClient` from inside your `AgentExecutor` to invoke a downstream agent on behalf of the calling user: ```python from keycardai.a2a import DelegationClient client = DelegationClient(config) # same AgentServiceConfig from the server setup class ResearchExecutor(AgentExecutor): async def execute(self, context, event_queue): user_token = context.call_context.state["access_token"] # Exchange the user's token for a delegation token bound to the remote service delegation_token = await client.get_delegation_token( "", subject_token=user_token, ) result = await client.invoke_service( "", {"task": "Summarize the latest news on AI agents"}, delegation_token, ) # Forward result.message back to the caller via event_queue... ``` **TypeScript:** Call `DelegationClient` from inside an agent executor. The user's bearer token comes from the current request context, so token exchange happens on the caller's behalf: ```typescript import { DelegationClient, getKeycardAuth, type AgentExecutor } from "@keycardai/a2a"; // config is the same AgentServiceConfig from the server setup const client = new DelegationClient(config); const executor: AgentExecutor = { async execute(requestContext, eventBus) { const auth = getKeycardAuth(requestContext); if (!auth) throw new Error("unauthenticated"); const result = await client.invokeService( "", "Summarize the latest news on AI agents", { subjectToken: auth.token }, ); eventBus.publish(result.message); eventBus.finished(); }, async cancelTask() {}, }; ``` **Go:** Use `DelegationClient` from inside your agent's JSON-RPC handler to invoke a downstream agent on behalf of the calling user. One `Invoke` call performs discovery, RFC 8693 token exchange, and the invocation: ```go import "github.com/keycardai/go-sdk/a2a" // Construct once per calling agent client, err := a2a.NewDelegationClient( os.Getenv("KEYCARD_ISSUER"), os.Getenv("KEYCARD_CLIENT_ID"), os.Getenv("KEYCARD_CLIENT_SECRET"), ) if err != nil { log.Fatal(err) } // userToken is the verified bearer token from the inbound request, // e.g. mcp.AuthInfoFromRequest(r).Token result, err := client.Invoke(ctx, "", userToken, a2a.NewTextMessage("Summarize the latest news on AI agents")) if err != nil { log.Fatal(err) } fmt.Println(result.AgentCard.Name) fmt.Println(result.Message.Parts[0].Text) ``` **Ruby:** Use `DelegationClient` from inside your agent's JSON-RPC handler to invoke a downstream agent on behalf of the calling user. One `invoke` call performs discovery, RFC 8693 token exchange, and the invocation: ```ruby require "keycardai/a2a" # Construct once per calling agent client = Keycardai::A2A::DelegationClient.new( issuer: ENV.fetch("KEYCARD_ISSUER"), client_id: ENV.fetch("KEYCARD_CLIENT_ID"), client_secret: ENV.fetch("KEYCARD_CLIENT_SECRET"), ) # user_token is the verified bearer token from the inbound request, # e.g. Keycardai::MCP.auth_info(env).token result = client.invoke( target: "", subject_token: user_token, message: Keycardai::A2A.text_message("Summarize the latest news on AI agents"), ) result.message # the remote agent's JSON-RPC result result.agent_card # the agent card discovered on the way ``` ## Related ## Source ## https://docs.keycard.ai/sdk/cloudflare # Cloudflare Workers The Cloudflare Workers package adapts Keycard's OAuth primitives to Workers' `fetch(request, env)` handler model. It's the Workers-native equivalent of [`@keycardai/mcp`](/sdk/mcp/) (Express). ## When to Use - Deploying an MCP server or API on Cloudflare Workers - Building Code Mode workers that need Keycard auth - Any Worker that needs JWT verification + token exchange with isolate-safe caching > **Note:** This package is TypeScript only. For Express-based servers, use [`@keycardai/mcp`](/sdk/mcp/). ## Installation ```bash npm install @keycardai/cloudflare ``` ## Key Exports `@keycardai/cloudflare` | Export | Description | | ------------------------ | ------------------------------------------------------------------- | | `createKeycardWorker` | High-level wrapper. Chains metadata, auth, and your handler | | `verifyBearerToken` | Verify JWTs from `Request`. Returns `AuthInfo` or error `Response` | | `handleMetadataRequest` | Serves `.well-known` OAuth endpoints, returns `Response` or `null` | | `IsolateSafeTokenCache` | Per-user token cache. Safe for CF isolate reuse | | `resolveCredential` | Auto-detect credential type from env bindings | | `WorkersClientSecret` | Application credential using client ID + secret | | `WorkersWebIdentity` | Application credential using private key JWT (no secret needed) | ## Quickstart ```typescript export default createKeycardWorker({ resourceName: "My MCP Server", scopesSupported: ["mcp:tools"], requiredScopes: ["mcp:tools"], async fetch(request, env, ctx, auth) { // auth is verified. auth.subject, auth.scopes, auth.token available return new Response(JSON.stringify({ message: "Hello from Keycard!", user: auth.subject, }), { headers: { "Content-Type": "application/json" }, }); }, }); ``` `createKeycardWorker` automatically handles: - **CORS preflight** responses - **OAuth metadata** at `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` - **Bearer token verification** with JWKS, scope checks, and expiration validation - **JWKS serving** at `/.well-known/jwks.json` when using WebIdentity ## Delegated Access (Token Exchange) Exchange the user's Keycard token for an upstream API token using `IsolateSafeTokenCache`: ```typescript let tokenCache: IsolateSafeTokenCache; function getCache(env) { if (!tokenCache) { const credential = resolveCredential(env); const client = new TokenExchangeClient( env.KEYCARD_ISSUER, credential.getAuth() ?? undefined, ); tokenCache = new IsolateSafeTokenCache(client, { credential }); } return tokenCache; } export default createKeycardWorker({ async fetch(request, env, ctx, auth) { const cache = getCache(env); const token = await cache.getToken( auth.subject!, auth.token, "https://api.github.com", ); const response = await fetch("https://api.github.com/user", { headers: { Authorization: `Bearer ${token.accessToken}` }, }); return new Response(await response.text()); }, }); ``` > **Caution:** **Never use module-level token caches** in Workers. Cloudflare reuses isolates across requests. A module-level cache would leak user A's token to user B. `IsolateSafeTokenCache` keys by `${subject}::${resource}` to prevent this. ## Credential Modes ### Client Credentials (client ID + secret) The simplest setup. Create application credentials in Keycard Console and store them as Worker secrets: ```bash wrangler secret put KEYCARD_CLIENT_ID wrangler secret put KEYCARD_CLIENT_SECRET ``` ### Web Identity (private key JWT) No client secret needed. The Worker generates JWT client assertions signed with a private key: ```bash # Generate and store a private key openssl genrsa 2048 | wrangler secret put KEYCARD_PRIVATE_KEY ``` The Worker automatically: 1. Serves its public key at `/.well-known/jwks.json` 2. Signs token exchange requests with `private_key_jwt` (RFC 7523) Register the Worker's JWKS URL in Keycard Console as the application's public key endpoint. `createKeycardWorker` auto-detects which mode to use based on which env vars are set. ## Environment Variables | Variable | Required | Description | |---|---|---| | `KEYCARD_ISSUER` | Yes | Keycard zone URL | | `KEYCARD_CLIENT_ID` | Option A | Application client ID | | `KEYCARD_CLIENT_SECRET` | Option A | Application client secret | | `KEYCARD_PRIVATE_KEY` | Option B | PEM-encoded RSA private key | | `KEYCARD_RESOURCE_URL` | For token exchange | Upstream resource URL | ## Related ## Source ## https://docs.keycard.ai/sdk/mcp # MCP The MCP package adds OAuth-based authentication to your MCP server. It handles bearer token verification, serves OAuth metadata endpoints, and provides grant decorators for delegated access to external APIs. ## When to Use - Building an MCP server that requires user authentication - Adding delegated access to external APIs (GitHub, Google, etc.) from MCP tools - Serving OAuth `.well-known` metadata for MCP clients > **Tip:** Deploying to **Cloudflare Workers** instead of Express? Use [`@keycardai/cloudflare`](/sdk/cloudflare/). It uses the same auth primitives, adapted for Workers' `fetch()` handler model. ## Installation **Python:** ```bash # Standard MCP SDK pip install keycardai-mcp # FastMCP framework (see FastMCP section below) pip install keycardai-fastmcp ``` **TypeScript:** ```bash npm install @keycardai/mcp ``` **Go:** ```bash go get github.com/keycardai/go-sdk ``` **Ruby:** ```bash bundle add keycardai-mcp ``` ## Key Exports **Python:** `keycardai.mcp.server.auth` | Export | Description | | -------------------------- | -------------------------------------------------------------- | | `AuthProvider` | Core auth provider. Wraps your MCP app with OAuth middleware | | `AccessContext` | Token exchange result. Check errors, access tokens per resource | | `TokenVerifier` | Verify incoming bearer tokens | | `ClientSecret` | Application credential using client ID + secret | | `WebIdentity` | Application credential using private key JWT | | `EKSWorkloadIdentity` | Application credential for EKS environments | **TypeScript:** `@keycardai/mcp` | Export | Description | | -------------------------- | -------------------------------------------------------------- | | `AuthProvider` | Core auth provider with `grant()` middleware | | `AccessContext` | Token exchange result. Check errors, access tokens per resource | | `mcpAuthMetadataRouter` | Express router for OAuth `.well-known` endpoints | | `requireBearerAuth` | Express middleware for bearer token verification | | `ClientSecret` | Application credential using client ID + secret | | `WebIdentity` | Application credential using private key JWT | | `EKSWorkloadIdentity` | Application credential for EKS environments | **Go:** `go-sdk/mcp`. Composable HTTP middleware for MCP server auth | Export | Description | | -------------------------- | -------------------------------------------------------------- | | `AuthProvider` | Core auth provider with `Grant()` middleware | | `AccessContext` | Token exchange result. Check errors, access tokens per resource | | `WithUserIdentifier` | Grant option: impersonate a resolved user instead of exchanging the caller's token | | `AuthMetadataHandler` | `http.Handler` for OAuth `.well-known` endpoints | | `RequireBearerAuth` | `http.Handler` middleware for bearer token verification | | `AuthInfoFromContext` | Retrieve auth info from `context.Context` (for tool handlers) | | `AccessContextFromContext` | Retrieve access context from `context.Context` (for tool handlers) | | `ClientSecretCredential` | Application credential using client ID + secret (`NewClientSecret`) | | `WebIdentityCredential` | Application credential using private key JWT (`NewWebIdentity`) | | `EKSWorkloadIdentity` | Application credential for EKS environments | **Ruby:** `keycardai-mcp`. Composable Rack middleware for MCP server auth | Export | Description | | ------------------------------- | -------------------------------------------------------------- | | `AuthProvider` | Core auth provider with `grant` middleware | | `AccessContext` | Token exchange result. Check errors, access tokens per resource | | `MetadataApp` | Rack app for OAuth `.well-known` endpoints | | `RequireBearerAuth` | Rack middleware for bearer token verification | | `Keycardai::MCP.auth_info` | Read the verified token from the Rack env (for handlers) | | `Keycardai::MCP.access_context` | Read the `AccessContext` from the Rack env (for handlers) | ## Quickstart ### Hello World: Authenticated MCP Server **Python:** ```python from mcp.server.mcpserver import MCPServer from keycardai.mcp.server.auth import AuthProvider mcp = MCPServer("Hello World Server") auth_provider = AuthProvider( zone_id="", mcp_server_name="Hello World Server", mcp_server_url="http://localhost:8000/", ) @mcp.tool() async def hello() -> str: return "Hello from a protected MCP server!" # Wrap the MCP app with authentication app = auth_provider.app(mcp) ``` **TypeScript:** ```typescript import express from "express"; import { mcpAuthMetadataRouter } from "@keycardai/mcp/server/auth/router"; import { requireBearerAuth } from "@keycardai/mcp/server/auth/middleware/bearerAuth"; const ISSUER = process.env.KEYCARD_ISSUER!; const app = express(); // Serve OAuth metadata app.use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: ISSUER }, scopesSupported: ["mcp:tools"], resourceName: "Hello World MCP Server", }), ); // Protect routes with bearer token verification. `issuers` is required. // It pins the verifier to your zone so forged tokens from any other // issuer are rejected before any JWKS lookup. app.use( "/api", requireBearerAuth({ issuers: ISSUER, requiredScopes: ["mcp:tools"], }), ); app.get("/api/whoami", (req, res) => { res.json({ message: "Hello from a protected MCP server!" }); }); app.listen(8000); ``` **Go:** ```go package main import ( "context" "fmt" "log" "net/http" "os" "github.com/keycardai/go-sdk/mcp" mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) func main() { issuer := os.Getenv("KEYCARD_ISSUER") // Create MCP server with your preferred library s := server.NewMCPServer("Hello World Server", "1.0.0") s.AddTool( mcpgo.NewTool("hello", mcpgo.WithDescription("Say hello."), mcpgo.WithString("name", mcpgo.Required()), ), func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { name, _ := req.GetArguments()["name"].(string) authInfo := mcp.AuthInfoFromContext(ctx) return mcpgo.NewToolResultText( fmt.Sprintf("Hello, %s! Client: %s", name, authInfo.ClientID), ), nil }, ) // Mount Keycard auth on your own mux httpMux := http.NewServeMux() httpMux.Handle("/.well-known/", mcp.AuthMetadataHandler( mcp.WithIssuer(issuer), mcp.WithScopesSupported([]string{"mcp:tools"}), mcp.WithResourceName("Hello World Server"), )) verifier, _ := mcp.NewZoneTokenVerifier(issuer) httpMux.Handle("/mcp", mcp.RequireBearerAuth( verifier, mcp.WithRequiredScopes("mcp:tools"), )(server.NewStreamableHTTPServer(s))) log.Fatal(http.ListenAndServe(":8080", httpMux)) } ``` **Ruby:** ```ruby # config.ru. Run with: bundle exec rackup -p 8000 require "keycardai/mcp" require "mcp" issuer = ENV.fetch("KEYCARD_ISSUER") # Create the MCP server with the official mcp gem mcp_server = MCP::Server.new(name: "Hello World Server", version: "1.0.0") mcp_server.define_tool( name: "hello", description: "Say hello.", input_schema: { properties: { name: { type: "string" } }, required: [] }, ) do |name: "world", server_context: nil| MCP::Tool::Response.new([{ type: "text", text: "Hello, #{name}!" }]) end # Serve OAuth metadata and require a bearer token on /mcp verifier = Keycardai::OAuth::TokenVerifier.new(issuers: issuer) metadata = Keycardai::MCP::MetadataApp.new( issuer: issuer, resource_name: "Hello World Server", scopes_supported: ["mcp:tools"], ) mcp_endpoint = lambda do |env| body = env["rack.input"].read [200, { "content-type" => "application/json" }, [mcp_server.handle_json(body)]] end protected_mcp = Keycardai::MCP::RequireBearerAuth.new( mcp_endpoint, verifier: verifier, required_scopes: ["mcp:tools"], ) run lambda { |env| case env["PATH_INFO"] when %r{\A/\.well-known/} then metadata.call(env) when "/mcp" then protected_mcp.call(env) else [404, { "content-type" => "application/json" }, ['{"error":"not_found"}']] end } ``` > **Tip: Go and Ruby: MCP-library agnostic** The Go and Ruby SDKs are composable middleware and don't bundle or depend on any MCP library. In Go, use [mcp-go](https://github.com/mark3labs/mcp-go), the [official MCP Go SDK](https://github.com/modelcontextprotocol/go-sdk), or any library that produces an `http.Handler`; in Ruby, any Rack app works, including servers built on the official [mcp gem](https://github.com/modelcontextprotocol/ruby-sdk). The auth pattern is always the same. See examples: [mcp-go](https://github.com/keycardai/go-sdk/tree/main/examples/mcp-server-mark3labs) | [official Go SDK](https://github.com/keycardai/go-sdk/tree/main/examples/mcp-server-official) | [Ruby](https://github.com/keycardai/ruby-sdk/tree/main/examples/mcp-server). ### Delegated Access: Call External APIs **Python:** ```python import httpx from mcp.server.mcpserver import Context, MCPServer from keycardai.mcp.server.auth import AccessContext, AuthProvider, ClientSecret auth_provider = AuthProvider( zone_id="", mcp_server_name="GitHub Server", mcp_server_url="http://localhost:8000/", application_credential=ClientSecret(("client_id", "client_secret")), ) mcp = MCPServer("GitHub Server") @mcp.tool() @auth_provider.grant("https://api.github.com") async def get_repos(access_ctx: AccessContext, ctx: Context) -> dict: if access_ctx.has_errors(): return {"error": access_ctx.get_errors()} token = access_ctx.access("https://api.github.com").access_token async with httpx.AsyncClient() as client: resp = await client.get( "https://api.github.com/user/repos", headers={"Authorization": f"Bearer {token}"}, ) return resp.json() app = auth_provider.app(mcp) ``` `@grant` injects the `AccessContext` parameter and strips it from the tool schema, so the model never sees it. The decorated function needs both an `AccessContext`-annotated and a `Context`-annotated parameter. **TypeScript:** ```typescript import express from "express"; import { AuthProvider } from "@keycardai/mcp/server/auth/provider"; import { ClientSecret } from "@keycardai/mcp/server/auth/credentials"; import type { DelegatedRequest } from "@keycardai/mcp/server/auth/provider"; const authProvider = new AuthProvider({ zoneUrl: process.env.KEYCARD_ISSUER!, applicationCredential: new ClientSecret( process.env.KEYCARD_CLIENT_ID!, process.env.KEYCARD_CLIENT_SECRET!, ), }); const app = express(); app.get( "/api/repos", authProvider.grant("https://api.github.com"), async (req, res) => { const { accessContext } = req as DelegatedRequest; if (accessContext.hasErrors()) { res.status(502).json({ error: accessContext.getErrors() }); return; } const token = accessContext.access("https://api.github.com").accessToken; const resp = await fetch("https://api.github.com/user/repos", { headers: { Authorization: `Bearer ${token}` }, }); res.json(await resp.json()); }, ); app.listen(8000); ``` **Go:** ```go package main import ( "context" "io" "log" "net/http" "os" "github.com/keycardai/go-sdk/mcp" mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) func main() { s := server.NewMCPServer("GitHub Server", "1.0.0") s.AddTool( mcpgo.NewTool("get_repos", mcpgo.WithDescription("List user repos")), func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { ac := mcp.AccessContextFromContext(ctx) if ac.HasErrors() { return mcpgo.NewToolResultError("Token exchange failed"), nil } token, _ := ac.Access("https://api.github.com") ghReq, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user/repos", nil) ghReq.Header.Set("Authorization", "Bearer "+token.AccessToken) resp, _ := http.DefaultClient.Do(ghReq) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return mcpgo.NewToolResultText(string(body)), nil }, ) // Set up auth provider for token exchange credential, _ := mcp.NewClientSecret( os.Getenv("KEYCARD_CLIENT_ID"), os.Getenv("KEYCARD_CLIENT_SECRET"), ) authProvider, _ := mcp.NewAuthProvider( mcp.WithZoneURL(os.Getenv("KEYCARD_ISSUER")), mcp.WithApplicationCredential(credential), ) httpMux := http.NewServeMux() httpMux.Handle("/.well-known/", mcp.AuthMetadataHandler( mcp.WithIssuer(os.Getenv("KEYCARD_ISSUER")), mcp.WithScopesSupported([]string{"mcp:tools"}), mcp.WithResourceName("GitHub Server"), )) verifier, _ := mcp.NewZoneTokenVerifier(os.Getenv("KEYCARD_ISSUER")) httpMux.Handle("/mcp", mcp.RequireBearerAuth( verifier, mcp.WithRequiredScopes("mcp:tools"), )(authProvider.Grant([]string{"https://api.github.com"})( server.NewStreamableHTTPServer(s), ))) log.Fatal(http.ListenAndServe(":8080", httpMux)) } ``` **Ruby:** ```ruby # config.ru require "net/http" require "keycardai/mcp" issuer = ENV.fetch("KEYCARD_ISSUER") # Set up the auth provider for token exchange provider = Keycardai::MCP::AuthProvider.new( zone_url: issuer, client_id: ENV.fetch("KEYCARD_CLIENT_ID"), client_secret: ENV.fetch("KEYCARD_CLIENT_SECRET"), ) metadata = Keycardai::MCP::MetadataApp.new( issuer: issuer, resource_name: "GitHub Server", scopes_supported: ["mcp:tools"], ) get_repos = lambda do |env| context = Keycardai::MCP.access_context(env) if context.errors? return [502, { "content-type" => "application/json" }, ['{"error":"token_exchange_failed"}']] end token = context.access("https://api.github.com").access_token uri = URI("https://api.github.com/user/repos") request = Net::HTTP::Get.new(uri, "authorization" => "Bearer #{token}") response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) } [200, { "content-type" => "application/json" }, [response.body]] end # Verify the bearer token, then exchange it once per granted resource protected_repos = Keycardai::MCP::RequireBearerAuth.new( provider.grant("https://api.github.com").new(get_repos), verifier: provider.token_verifier, required_scopes: ["mcp:tools"], ) run lambda { |env| case env["PATH_INFO"] when %r{\A/\.well-known/} then metadata.call(env) when "/api/repos" then protected_repos.call(env) else [404, { "content-type" => "application/json" }, ['{"error":"not_found"}']] end } ``` `grant` returns a Rack middleware class: the Ruby equivalent of Python's `@grant` decorator and TypeScript's route middleware. A failing resource never aborts the request; the error lands on the `AccessContext`. ### Impersonation: Act as a Specific User By default, `grant()` exchanges the caller's bearer token — the tool acts as whoever is calling. When the server must act as a *named* user instead — a scheduled job, a queue worker, or a tool that operates on another user's data — supply a user-identifier resolver and the grant impersonates that user via a substitute-user token exchange. The server's own application credential authenticates the exchange; no user token is needed. See [Act on Behalf of Absent Users](/guides/act-on-behalf-of-absent-users/) for the consent setup and policy requirements. **Python:** ```python @mcp.tool() @auth_provider.grant( "https://api.github.com", user_identifier=lambda **kwargs: kwargs["user_email"], ) async def get_repos(access_ctx: AccessContext, ctx: Context, user_email: str) -> dict: if access_ctx.has_errors(): return {"error": access_ctx.get_errors()} token = access_ctx.access("https://api.github.com").access_token # Call the API as user_email... ``` The `user_identifier` callable receives the tool's keyword arguments and returns the identifier string. **TypeScript:** ```typescript app.get( "/api/repos", authProvider.grant("https://api.github.com", { userIdentifier: (req) => resolveUserIdentifier(req), }), async (req, res) => { const { accessContext } = req as DelegatedRequest; const token = accessContext.access("https://api.github.com").accessToken; // Call the API as the resolved user... }, ); ``` The resolver runs once per request and may be async. **Go:** ```go httpMux.Handle("/mcp", mcp.RequireBearerAuth( verifier, mcp.WithRequiredScopes("mcp:tools"), )(authProvider.Grant([]string{"https://api.github.com"}, mcp.WithUserIdentifier(func(r *http.Request) (string, error) { return mcp.AuthInfoFromRequest(r).Subject, nil }), )(server.NewStreamableHTTPServer(s)))) ``` If the resolver returns an error, the grant fails closed with a global error on the `AccessContext`. **Ruby:** ```ruby protected_repos = Keycardai::MCP::RequireBearerAuth.new( provider.grant( "https://api.github.com", user_identifier: ->(env) { Keycardai::MCP.auth_info(env).subject }, ).new(get_repos), verifier: provider.token_verifier, required_scopes: ["mcp:tools"], ) ``` `user_identifier:` accepts a string, or a callable that receives the Rack env and returns the identifier. > **Caution: Derive the identifier from verified data** The resolved identifier becomes the subject of a real token minted with the server's own credential. Derive it from verified auth state — the bearer token's subject, or a server-side lookup — never from unverified request data such as a header, query parameter, or body field. ## FastMCP Integration The `keycardai-fastmcp` package provides a dedicated integration for Python's [FastMCP](https://github.com/jlowin/fastmcp) framework. It wraps the same auth primitives with FastMCP-specific APIs. ```bash pip install keycardai-fastmcp ``` > **Note:** `keycardai-fastmcp` replaces `keycardai-mcp-fastmcp`, which is retired. Version 0.21.0 stays on PyPI so existing pins keep resolving, but it gets no further releases. Import from `keycardai.fastmcp`. ### Key Differences from `keycardai-mcp` | Feature | `keycardai-mcp` | `keycardai-fastmcp` | | -------------------- | -------------------------------------- | --------------------------------------------- | | Framework | Standard MCP SDK (`mcp` 2.x) | FastMCP 3.x (`mcp` 1.x) | | Auth setup | `auth_provider.app(mcp)` | `auth_provider.get_remote_auth_provider()` | | Grant decorator | `@auth_provider.grant(resource)` | Same | | Access context | Injected `AccessContext` parameter | `await ctx.get_state("keycardai")` | | Testing | N/A | `mock_access_context` test utility | The two packages track different upstream majors on purpose: `keycardai-mcp` follows the standard MCP SDK 2.x line, while `keycardai-fastmcp` follows FastMCP 3.x, which still builds on `mcp` 1.x. Pick the one that matches your framework; neither replaces the other. ### FastMCP Example ```python from fastmcp import Context, FastMCP from keycardai.fastmcp import AuthProvider, ClientSecret, AccessContext auth_provider = AuthProvider( zone_id="", mcp_server_name="GitHub API Server", mcp_server_url="http://localhost:8000/", application_credential=ClientSecret(("client_id", "client_secret")), ) auth = auth_provider.get_remote_auth_provider() mcp = FastMCP("GitHub API Server", auth=auth) @mcp.tool() @auth_provider.grant("https://api.github.com") async def get_github_user(ctx: Context) -> dict: access_context: AccessContext = await ctx.get_state("keycardai") if access_context.has_errors(): return {"error": access_context.get_errors()} token = access_context.access("https://api.github.com").access_token # Use token to call GitHub API... ``` ## Related ## Source ## https://docs.keycard.ai/sdk/oauth # OAuth Primitives The OAuth package provides pure OAuth 2.0 primitives with no MCP dependency. Use it when you need direct control over OAuth flows or are building outside the MCP framework. ## When to Use - Custom OAuth flows outside of MCP - Token exchange (RFC 8693) for delegated access - Authorization server discovery - JWT signing and verification - Building your own auth middleware > **Tip:** If you're building an MCP server, use the [`keycardai-mcp`](/sdk/mcp/) package instead. It wraps these primitives with MCP-specific middleware. ## Installation **Python:** ```bash pip install keycardai-oauth ``` **TypeScript:** ```bash npm install @keycardai/oauth ``` **Go:** ```bash go get github.com/keycardai/go-sdk/oauth ``` **Ruby:** ```bash bundle add keycardai-oauth ``` ## Key Exports **Python:** `keycardai.oauth` | Export | Description | | ------------------------------- | ---------------------------------------------------- | | `Client` / `AsyncClient` | OAuth client for discovery, registration, token exchange, and impersonation | | `TokenResponse` | Parsed token endpoint response | | `AuthorizationServerMetadata` | Parsed `.well-known` metadata | | `pkce.authenticate` | High-level PKCE login flow with browser launch and loopback callback | | `pkce.OAuthCallbackServer` | Local callback receiver for custom PKCE flows | | `utils.pkce` | Low-level PKCE code verifier and challenge utilities | | `GrantType`, `TokenType` | OAuth enum constants | | `BearerAuth`, `BasicAuth` | HTTP auth strategies for outbound requests | | `extract_bearer_token` | Utility to extract bearer tokens from headers | | `OAuthError`, `TokenExchangeError` | Exception types | **TypeScript:** `@keycardai/oauth` | Export | Description | | ----------------------------------- | ----------------------------------------------- | | `fetchAuthorizationServerMetadata` | Discover OAuth server configuration | | `TokenExchangeClient` | RFC 8693 token exchange client, with `impersonate()` for substitute-user exchange | | `registerClient` | RFC 7591 dynamic client registration | | `authenticate` | High-level PKCE login flow (Node.js only) | | `generatePkcePair` | Generate a PKCE code verifier and challenge pair | | `JWKSOAuthKeyring` | JWKS key management for signing and verification | | `JWTSigner` / `JWTVerifier` | JWT creation and validation | | `base64url` | URL-safe base64 encoding utilities | | `HTTPError`, `OAuthError` | Error types | **Go:** `go-sdk/oauth` | Export | Description | | ---------------------------------- | ---------------------------------------------------- | | `FetchAuthorizationServerMetadata` | Discover OAuth server configuration | | `TokenExchangeClient` | RFC 8693 token exchange client, with `Impersonate()` for substitute-user exchange | | `JWKSOAuthKeyring` | JWKS key management with two-level caching | | `JWTSigner` / `JWTVerifier` | JWT creation and validation | | `PEMPrivateKeyring` | Private key management from PEM-encoded keys | | `AuthorizationServerMetadata` | Parsed `.well-known` metadata | **Ruby:** `Keycardai::OAuth` | Export | Description | | -------------------------------------- | ---------------------------------------------------- | | `TokenExchangeClient` | RFC 8693 token exchange client, with `impersonate` for substitute-user exchange | | `TokenVerifier` | Access token verification backed by a caching JWKS keyring | | `fetch_authorization_server_metadata` | Discover OAuth server configuration | | `register_client` | RFC 7591 dynamic client registration | | `authenticate` | High-level PKCE login flow with browser launch and loopback callback | | `exchange_tokens_for_resources` | Exchange one subject token for several Resources into an `AccessContext` | | `AccessContext` | Non-raising container for per-resource delegated tokens | | `PKCE.generate_pair` | Generate a PKCE code verifier and challenge pair | | `JWKSKeyring` | JWKS key management with per-issuer caching | | `JWTSigner` / `JWTVerifier` | JWT creation and validation | | `TokenResponse` | Parsed token endpoint response | | `OAuthError`, `InvalidTokenError` | Error types | ## Quickstart ### Discover Authorization Server Metadata **Python:** ```python from keycardai.oauth import Client zone_url = "https://.keycard.cloud" with Client(zone_url) as client: metadata = client.discover_server_metadata() print(metadata.issuer) print(metadata.token_endpoint) ``` **TypeScript:** ```typescript import { fetchAuthorizationServerMetadata } from "@keycardai/oauth"; const zoneUrl = "https://.keycard.cloud"; const metadata = await fetchAuthorizationServerMetadata(zoneUrl); console.log(metadata.issuer); console.log(metadata.token_endpoint); ``` **Go:** ```go import "github.com/keycardai/go-sdk/oauth" zoneURL := "https://.keycard.cloud" metadata, err := oauth.FetchAuthorizationServerMetadata(ctx, zoneURL) if err != nil { log.Fatal(err) } fmt.Println(metadata.Issuer) fmt.Println(metadata.TokenEndpoint) ``` **Ruby:** ```ruby require "keycardai/oauth" zone_url = "https://.keycard.cloud" metadata = Keycardai::OAuth.fetch_authorization_server_metadata(zone_url) puts metadata.issuer puts metadata.token_endpoint ``` ### Token Exchange **Python:** ```python from keycardai.oauth import BasicAuth, Client with Client(zone_url, auth=BasicAuth("", "")) as client: response = client.exchange_token( subject_token="", resource="https://api.github.com", ) print(response.access_token) ``` **TypeScript:** ```typescript import { TokenExchangeClient } from "@keycardai/oauth"; const client = new TokenExchangeClient(zoneUrl, { clientId: "", clientSecret: "", }); const response = await client.exchangeToken({ subjectToken: "", resource: "https://api.github.com", }); console.log(response.accessToken); ``` The client discovers the token endpoint from the zone URL on first use, so there is no need to call `fetchAuthorizationServerMetadata` yourself. **Go:** ```go import "github.com/keycardai/go-sdk/oauth" client := oauth.NewTokenExchangeClient( zoneURL, oauth.WithClientCredentials("", ""), ) response, err := client.ExchangeToken(ctx, oauth.TokenExchangeRequest{ SubjectToken: "", Resource: "https://api.github.com", }) if err != nil { log.Fatal(err) } fmt.Println(response.AccessToken) ``` **Ruby:** ```ruby require "keycardai/oauth" client = Keycardai::OAuth::TokenExchangeClient.new( issuer: zone_url, client_id: "", client_secret: "", ) response = client.exchange_token( subject_token: "", resource: "https://api.github.com", ) puts response.access_token ``` ### Impersonation Mint a token that acts as a specific user who isn't present. The client authenticates with its own confidential credential and names the user; the SDK builds the substitute-user token and runs the [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) exchange. See [Act on Behalf of Absent Users](/guides/act-on-behalf-of-absent-users/) for the setup and policy requirements. **Python:** ```python from keycardai.oauth import Client from keycardai.oauth.http.auth import BasicAuth with Client(zone_url, auth=BasicAuth("", "")) as client: response = client.impersonate( user_identifier="troy.barnes@greendale.edu", resource="https://api.github.com", ) print(response.access_token) ``` **TypeScript:** ```typescript import { TokenExchangeClient } from "@keycardai/oauth"; const client = new TokenExchangeClient(zoneUrl, { clientId: "", clientSecret: "", }); const response = await client.impersonate({ userIdentifier: "troy.barnes@greendale.edu", resource: "https://api.github.com", }); console.log(response.accessToken); ``` **Go:** ```go import "github.com/keycardai/go-sdk/oauth" client := oauth.NewTokenExchangeClient( zoneURL, oauth.WithClientCredentials("", ""), ) response, err := client.Impersonate(ctx, oauth.ImpersonateRequest{ UserIdentifier: "troy.barnes@greendale.edu", Resource: "https://api.github.com", }) if err != nil { log.Fatal(err) } fmt.Println(response.AccessToken) ``` **Ruby:** ```ruby require "keycardai/oauth" client = Keycardai::OAuth::TokenExchangeClient.new( issuer: zone_url, client_id: "", client_secret: "", ) response = client.impersonate( user_identifier: "troy.barnes@greendale.edu", resource: "https://api.github.com", ) puts response.access_token ``` ### User Login with PKCE Use this flow when a CLI tool, desktop app, or MCP client needs to log a user in through their browser. The `authenticate()` helper opens the user's browser at the authorize endpoint, runs a local callback server, and exchanges the authorization code for tokens. **Python:** ```python import httpx from keycardai.oauth.pkce import authenticate # Hit the protected resource without a token to get the WWW-Authenticate challenge async with httpx.AsyncClient() as http: response = await http.get("") challenge = response.headers["www-authenticate"] token = await authenticate( client_id="", resource_url="", www_authenticate_header=challenge, scopes=["read:data"], ) print(token.access_token) ``` **TypeScript:** ```typescript import { authenticate } from "@keycardai/oauth"; const token = await authenticate("", { // from Settings → Connection clientId: "", scopes: ["read:data"], }); console.log(token.accessToken); ``` `authenticate()` requires Node.js. It uses `node:http` and `node:child_process` to run the local callback server and open the browser. **Go:** ```go import "github.com/keycardai/go-sdk/oauth" token, err := oauth.Authenticate(ctx, "", oauth.AuthenticateRequest{ ClientID: "", Scopes: []string{"read:data"}, }) if err != nil { log.Fatal(err) } fmt.Println(token.AccessToken) ``` To start from a resource's `WWW-Authenticate` challenge instead of a known issuer, use `oauth.AuthenticateFromChallenge`. **Ruby:** ```ruby require "keycardai/oauth" token = Keycardai::OAuth.authenticate( issuer: "", client_id: "", scope: "read:data", ) puts token.access_token ``` To start from a resource's `WWW-Authenticate` challenge instead of a known issuer, use `Keycardai::OAuth.authenticate_from_challenge`. ## Related ## Source ## https://docs.keycard.ai/reference/activity-events # Activity Events Each **Activity** feed in the Console shows the events registered for its entity type, listed below. An event can appear in more than one feed: `credentials:issue` shows on both the Resource and the Provider it involves. For how to open and read a feed, see [Read an Activity Feed](/admin/activities/). ## Resource activity | Action | Name | Description | | --- | --- | --- | | `credentials:issue` | Credential issued | Keycard issued a credential to an actor for the target Resource. | | `delegated-grants:create` | Delegated grant created | A delegated grant was created. | | `delegated-grants:revoke` | Delegated grant revoked | A delegated grant was revoked. | | `resources:create` | Resource created | The target Resource was created. | | `resources:create_action` | Action created | A tool was recorded on the target Resource, by Catalog seeding or by hand. | | `resources:update` | Resource updated | The target Resource was updated. | ## Provider activity | Action | Name | Description | | --- | --- | --- | | `credentials:issue` | Credential issued | Keycard issued a credential to an actor for the target Resource. | | `providers:create` | Provider created | A Provider was created. | | `providers:update` | Provider updated | A Provider was updated. | | `providers:validate` | Provider validated | Keycard validated a Provider's configuration. | | `users:authenticate` | User authenticated | A User completed authentication against Keycard. | | `users:authorize` | User authorized | A User was granted or denied authorization to access the target Resource. | ## Application activity | Action | Name | Description | | --- | --- | --- | | `applications:create` | Application created | An Application was created. | | `applications:update` | Application updated | An Application was updated. | | `applications:add_curation` | Curation added | An MCP server attached to the target Application moved to **Selected actions**. | | `applications:remove_curation` | Curation removed | An MCP server attached to the target Application returned to **All actions**. | | `applications:enable_action` | Action enabled | A tool was turned on for the target Application. | | `applications:disable_action` | Action disabled | A tool was turned off for the target Application. | | `application-credentials:create` | Application credential created | An Application credential was created. | | `application-credentials:update` | Application credential updated | An Application credential was updated. | ## User activity The User feed matches events on two axes: events the person performed (directly or through an Application acting on their behalf) and events affecting their account. Anything the person performed can appear here, including actions from the other feeds; the events below are the ones specific to this feed. | Action | Name | Description | | --- | --- | --- | | `credentials:issue` | Credential issued | Keycard issued a credential to an actor for the target Resource. | | `delegated-grants:create` | Delegated grant created | A delegated grant was created. | | `delegated-grants:revoke` | Delegated grant revoked | A delegated grant was revoked. | | `users:authenticate` | User authenticated | A User completed authentication against Keycard. | | `users:authorize` | User authorized | A User was granted or denied authorization to access the target Resource. | | `users:create` | User created | A User was created. | | `users:update` | User updated | A User was updated. | ## Policy activity | Action | Name | Description | | --- | --- | --- | | `policies:evaluate` | Policy evaluated | Keycard evaluated policy for an actor's access to the target Resource. | | `policies:create` | Policy created | A policy was created. | | `policies:update` | Policy updated | A policy was updated. | | `policy_versions:create` | Policy version created | A policy version was created. | | `policy_sets:create` | Policy set created | A policy set was created. | | `policy_sets:update` | Policy set updated | A policy set was updated. | | `policy_set_versions:create` | Policy set version created | A policy set version was created. | | `policy_set_versions:activate` | Policy set version activated | A policy set version was activated. | | `policy_schema:set_default` | Policy schema set as default | A policy schema was set as the default. | | `policy_schema:update` | Policy schema updated | A policy schema was updated. | > **Note: Beyond the Activity feeds** Activity feeds only show the events listed above. Deletions, archivals, and lower-level operations still land in the [Audit Log](/admin/audit-log-and-sessions/) and in [Audit Log Export](/admin/audit-log-export/). ## Related - [Read an Activity Feed](/admin/activities/) - [Audit Log & Sessions](/admin/audit-log-and-sessions/) - [Audit Log Export](/admin/audit-log-export/) ## https://docs.keycard.ai/reference/security-architecture # Security Architecture Keycard is built on security-first principles, providing defense-in-depth protection for sensitive credentials and authentication data. ## Security Principles ### Zero Credential Storage Your applications **never** handle or store user credentials: | | | | ------------------------ | ----------------------------------------------------------------------------- | | **User Authentication** | Users authenticate directly with their identity provider (Okta, Google, etc.) | | **Authorization Grants** | Keycard receives authorization grants, not passwords | | **Time-Limited Tokens** | Applications receive time-limited access tokens, not long-lived credentials | | **Encryption** | All sensitive data is encrypted at rest and in transit | ### Least Privilege Access Applications only receive tokens for explicitly declared dependencies: | | | | -------------------------- | ------------------------------------------------- | | **Dependency Declaration** | Applications must pre-declare required resources | | **Scope Minimization** | Request only the minimum scopes needed | | **User Consent** | Users authorize each application-resource pairing | | **Time-Limited Tokens** | Tokens expire and require refresh | ### Defense in Depth Multiple layers of security protection: | | | | --------------------- | -------------------------------------------- | | **Network Layer** | TLS 1.3 for all communications | | **Application Layer** | OAuth 2.1 authorization checks | | **Data Layer** | Envelope encryption with per-zone keys | | **Audit Layer** | Comprehensive logging of all security events | ### Credential Isolation Each zone maintains independent credentials: | | | | ------------------------ | ------------------------------------------------- | | **Per-Zone Encryption** | Separate encryption keys per zone | | **User Isolation** | User credentials scoped to specific zones | | **No Cross-Zone Access** | Tokens from Zone A cannot access Zone B resources | | **Independent Audit** | Separate audit logs per zone | ### Comprehensive Audit Trail Every security event is logged and traceable: - User authentication attempts - Token issuance and exchange - Resource access requests - Administrative configuration changes - Encryption key operations --- ## Data Encryption Keycard stores encrypted data using envelope encryption. The Key Encryption Key (KEK) is managed by AWS (through KMS) and used to create a Data Encryption Key (DEK). As per the Keycard Cloud reference architecture, for each dataplane, we have a dedicated KEK. Whenever you create a zone in the Keycard platform, we create a dedicated DEK. This DEK will be securely stored, encrypted through the KEK. Any secret materials that are associated with the zone will be encrypted with the DEK, using the ChaCha20 algorithm. ### Envelope Encryption Model ### How it works 1. **KEK (Key Encryption Key)** - Managed by AWS KMS - One KEK per Keycard data plane (unless CMEK) - Never leaves AWS KMS - Used to encrypt/decrypt DEKs only 2. **DEK (Data Encryption Key)** - Created when a zone is provisioned - Encrypted with the KEK and stored in Keycard's database - Cached in memory for performance (15 minutes, 10,000 operations max) - Used to encrypt/decrypt actual secret materials 3. **Secret Materials** - Encrypted with the zone's DEK using **ChaCha20** algorithm - Stored in Keycard's secure database - Decrypted on-demand when needed by applications ### Secret Material Types Keycard encrypts and protects several types of sensitive data: | Material Type | Description | Example | | ---------------------- | ------------------------------------------------- | --------------------------------------------- | | **OAuth Tokens** | Access and refresh tokens from external providers | GitHub access token for user's repositories | | **Static Credentials** | Customer-provided secrets for non-OAuth resources | Database passwords, API keys | | **Signing Keys** | Per-zone private keys for JWT signing | Zone's RSA private key for token signatures | | **Provider Secrets** | OAuth client secrets for provider integrations | Google OAuth app client secret | --- ## Customer Managed Encryption Keys (CMEK) Keycard provides the functionality for customers to provide their own Customer Managed Encryption Key (CMEK). CMEK is configured on a per zone basis and follows the same pattern as the standard Keycard Cloud envelope encryption, but uses the customer provided encryption key to encrypt the data encryption key. The customer's provided KMS Instance will be used to encrypt and decrypt the data encryption key, which in turn will be used to encrypt and decrypt secret materials. CMEK provides customers with cryptographic control over their data. Customers control the permissions for Keycard to access their KMS instance, and can revoke access at any time. When access is revoked, Keycard will not be able to decrypt any stored data. ### What is CMEK? CMEK allows you to provide your own AWS KMS key instead of using Keycard's managed KEK: - **Your KMS Key**: You create and manage the KMS key in your AWS account that acts as your Key Encryption Key. - **Your Permissions**: You control who can use the key via IAM and KMS policies - **Your Control**: Revoke access at any time to make data unreadable ### Architecture ### CMEK Configuration CMEK is configured **per zone**: 1. Create a KMS key in your AWS account 2. Configure the key policy to grant Keycard access (see below) 3. Register the KMS key ARN with your Keycard zone 4. All secret materials for that zone use your KMS key ### Benefits - **Cryptographic Control**: You own the root encryption key - **Revocation**: Disable the key to make data unreadable instantly - **Compliance**: Meet regulatory requirements for key management - **Audit Trail**: AWS CloudTrail logs all KMS operations - **Key Rotation**: Control your own key rotation schedule ### AWS KMS Permissions Keycard requires minimal KMS permissions: ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "Allow admin access to the key", "Effect": "Allow", "Action": "kms:*", "Principal": { "AWS": "arn:aws:iam:::root" }, "Resource": "*" }, { "Sid": "Allow describing the key", "Effect": "Allow", "Action": "kms:DescribeKey", "Principal": "*", "Resource": "*", "Condition": { "ForAnyValue:StringLike": { "aws:PrincipalOrgPaths": [ "o-c7hznpvsin/r-6n6x/ou-6n6x-bcvsfrlg/ou-6n6x-oox2udyt/*" ] } } }, { "Sid": "Allow access for the Keycard OU and its descendants", "Effect": "Allow", "Action": ["kms:Encrypt", "kms:Decrypt"], "Principal": "*", "Resource": "*", "Condition": { "StringEquals": { "kms:EncryptionContext:keycardOrganizationID": "" }, "ForAnyValue:StringLike": { "aws:PrincipalOrgPaths": [ "o-c7hznpvsin/r-6n6x/ou-6n6x-bcvsfrlg/ou-6n6x-oox2udyt/*" ] } } } ] } ``` > **Note:** To configure this via Terraform, see the keycard_aws_kms_key_policy data source. ### Encryption Context Every KMS operation includes **encryption context** for auditability and verification: ```json { "KeycardZoneId": "z-abc123def456", "KeycardOrgId": "org-xyz789", "KeycardKeyId": "dek-unique-id-12345" } ``` This allows you to: - Correlate Keycard audit logs with AWS CloudTrail events - Restrict KMS usage to specific zones via policy conditions - Track which data encryption keys are being used ### Revocation To revoke Keycard's access to your data: 1. **Disable the KMS key** in your AWS account, or 2. **Remove Keycard's permissions** from the KMS key policy **Result:** Keycard cannot decrypt any secret materials for that zone. All operations requiring secrets will fail until access is restored. > **Caution:** **Revocation is immediate and cannot be undone** until you restore KMS access. Plan revocation carefully to avoid service disruption. --- ## Private Connectivity Keycard uses AWS PrivateLink to connect to AWS KMS, meaning that communication between Keycard's VPCs and AWS KMS is conducted entirely within the AWS Network. The AWS VPC PrivateLink configuration is managed entirely by Keycard and transparent to customers. ### PrivateLink for KMS All communication between Keycard's infrastructure and AWS KMS occurs over AWS PrivateLink: - **No Public Internet**: Traffic never leaves AWS's private network backbone - **Reduced Attack Surface**: No exposure to internet-based threats - **Transparent to Customers**: Automatically configured by Keycard - **Low Latency**: Direct connectivity within AWS infrastructure ### PrivateLink for Enterprise Cloud Enterprise Cloud customers can establish PrivateLink connections to their dedicated cell: - **Unidirectional**: From your VPC to Keycard only - **No Ingress**: Keycard cannot initiate connections to your network - **IP Allowlisting**: Optionally restrict to specific CIDR blocks - **VPC Endpoint**: Connect without internet gateway or NAT [Learn more about Enterprise Cloud deployment →](/admin/deployment/#enterprise-cloud-dedicated-cell) --- ## Token Security ### Token Types Keycard issues and manages multiple token types: | Token Type | Purpose | Lifetime | Refresh | | ------------------- | ------------------------- | -------- | --------------------------- | | **Access Token** | Application authorization | 1 hour | Via refresh token | | **Refresh Token** | Obtain new access tokens | 30 days | Rotated on use | | **ID Token** | User identity information | 1 hour | N/A (identity only) | | **Exchanged Token** | Resource-specific access | 1 hour | Via refresh of parent token | ### Token Protection - **TLS 1.3**: All token transmission encrypted - **Short Lifetimes**: Access tokens expire quickly (1 hour) - **Rotation**: Refresh tokens rotate on each use - **Binding**: Tokens bound to specific clients via PKCE - **Revocation**: Revoking through the Keycard APIs stops the next issuance and stops refresh. A credential already issued remains valid until it expires, so containment is bounded by its remaining lifetime. See [Revoke a Grant](/admin/revoke-a-grant/#what-happens-after-you-revoke). ### Token Validation Applications validate tokens using: 1. **JWKS Endpoint**: Retrieve the Keycard public signing keys ``` /.well-known/jwks.json ``` 2. **Standard JWT Validation**: - Verify signature with public key - Check `iss` (issuer) matches zone - Check `aud` (audience) matches application - Check `exp` (expiration) is valid - Validate required claims 3. **Scope Verification**: - Check `scope` claim contains required scopes - Enforce least-privilege access --- ## Local Cache For performance and cost optimization, Keycard caches decrypted data encryption keys (DEKs) in memory. ### Cache Policy - **Duration**: 15 minutes maximum - **Operation Limit**: 10,000 cryptographic operations per cached key - **Automatic Refresh**: Cache refreshes automatically when limits are reached - **Memory Only**: Never persisted to disk ### Why Caching? - **Performance**: Avoid KMS API call for every secret access - **Cost**: Reduce AWS KMS API charges - **Reliability**: Reduce dependency on KMS for routine operations ### Security Considerations - **Memory Protection**
Cached keys protected by OS memory isolation. - **Bounded Lifetime**
Keys expire after 15 minutes. - **Operation Limits**
Maximum 10,000 uses per cached key. - **Revocation**
Cache invalidated immediately on zone key rotation. --- ## Threat Model Keycard is designed to protect against common threats: ### Token Replay Attacks **Threat**: Attacker intercepts and reuses access tokens **Mitigation**: - Short token lifetimes (1 hour) - TLS 1.3 encryption for all traffic - Token binding via PKCE - Replay detection via nonce validation ### Credential Leakage **Threat**: User credentials exposed through application **Mitigation**: - Zero credential storage model (no passwords stored) - OAuth delegation (apps never see user passwords) - Encrypted storage of all tokens - Per-zone encryption key isolation ### Man-in-the-Middle **Threat**: Attacker intercepts traffic between components **Mitigation**: - Mandatory TLS 1.3 for all connections - Certificate pinning for critical connections - AWS PrivateLink for internal traffic - Mutual TLS for sensitive endpoints ### Privilege Escalation **Threat**: Application accesses unauthorized resources **Mitigation**: - Explicit dependency declaration - User consent for each resource - Scope-based access control - Token audience validation ### Data Breach **Threat**: Unauthorized access to stored data **Mitigation**: - Envelope encryption with AWS KMS - Per-zone encryption keys - CMEK support for customer control - Audit logging of all access --- ## Compliance & Standards Keycard implements industry-standard security protocols: - **OAuth 2.1** - Authorization framework - **OpenID Connect** - Identity layer - **RFC 8693** - Token exchange - **PKCE** (RFC 7636) - Authorization code protection - **JWT** (RFC 7519) - Token format - **JWK** (RFC 7517) - Key distribution ### Encryption Standards - **TLS 1.3** - Transport encryption - **ChaCha20** - Symmetric encryption - **RS256** - JWT signing - **AES-256** - AWS KMS encryption --- ## Best Practices ### For Application Developers
Validate all tokens Always validate JWT signatures, expiration, audience, and issuer. Never trust tokens without verification.
Use short-lived tokens Request tokens with the shortest lifetime acceptable for your use case. Refresh frequently.
Implement token rotation Rotate refresh tokens on each use. Detect and reject token reuse.
Log security events Log all authentication failures, suspicious token usage, and authorization errors.
Handle revocation Implement token revocation detection and graceful degradation when tokens are revoked.
### For Platform Operators
Enable CMEK for production Use Customer Managed Encryption Keys for production zones to maintain cryptographic control.
Monitor audit logs Set up alerts for suspicious patterns: unusual token issuance, repeated failures, unauthorized access attempts.
Implement IP allowlisting For Enterprise Cloud, restrict access to known networks via IP allowlisting.
Rotate credentials regularly Rotate OAuth client secrets and static credentials on a regular schedule (90 days recommended).
Review access grants Regularly audit which applications have access to which resources. Revoke unused grants.
## https://docs.keycard.ai/reference/standards # Supported Standards & Protocols Every protocol listed here is available on every plan. Nothing is gated by tier. ## Token and authorization **OAuth 2.1** is the core authorization framework. Keycard supports [Dynamic Client Registration](/sdk/oauth/) (agents and applications register as OAuth clients at runtime), Registered Clients (pre-configured with known credentials), and Client ID Metadata (attach metadata to identities for policy evaluation). **Token Exchange** (RFC 8693) swaps one token for another with different permissions, scope, or audience. This is how agents move between applications with [least-privilege access](/concepts/credentials/#delegation-chaining) - trading a broad token for one scoped to what they actually need. **JWT Access Tokens** (RFC 9068) define the shape of the access tokens Keycard issues. See [Token Claims](/reference/token-claims/) for the claims and their meanings. **OpenID Connect** (Core) issues ID tokens and exposes user identity through the userinfo endpoint, on top of OAuth 2.1. **Authorization Server Metadata** (RFC 8414) publishes a Zone's OAuth endpoints and signing keys at a well-known URL, so clients and resource servers can discover them automatically. **Token Brokering** translates tokens from external identity providers into Keycard-managed tokens. Brokered tokens let agents access resources where Keycard handles the initial authorization but stays out of the ongoing request path. ## Workload identity **SPIFFE** (Secure Production Identity Framework for Everyone) gives workloads cryptographic identity. Keycard issues and validates SPIFFE IDs (SVIDs) so workloads can authenticate to each other without shared secrets. **WIMSE** (Workload Identity in Multi System Environments) extends this across organizational and cloud boundaries - for cases where agents need to authenticate across trust domains. ## Agent and tool protocols **MCP** (Model Context Protocol) handles agent-to-tool communication. Keycard protects [MCP tool calls](/sdk/mcp/) by issuing and validating tokens per interaction and enforcing policy on which tools an agent can reach. **A2A** (Agent-to-Agent) handles agent-to-agent communication. Keycard authenticates both sides and enforces policy on what each agent can do to the other. ## Developer interfaces | Interface | | | --- | --- | | REST API | Programmatic access to zones, applications, policy, identity providers, and telemetry. API Reference | | CLI | Manage configuration, deploy policy, and debug token flows from the terminal. | | SDKs | Client libraries for [OAuth](/sdk/oauth/), [MCP](/sdk/mcp/), and [agent-to-agent](/sdk/agent-to-agent/) integration. | | Terraform | Manage Keycard resources as code. Provider docs | | Agent Built Tools | Agents can register their own tools in Keycard, subject to the same identity, policy, and telemetry controls as anything else. | ## Identity provider compatibility Keycard works with any identity provider that speaks a supported standard: OAuth 2.1, OIDC, SAML, SPIFFE, or WIMSE. | | Examples | | --- | --- | | Cloud | AWS IAM, Azure Entra ID, Google Cloud IAM | | Enterprise | Okta, Auth0, Ping Identity, OneLogin | | On-prem | Active Directory, LDAP | | Custom | Anything that implements a supported standard | The [catalog](/admin/catalog/) has pre-built configurations for common providers. For everything else, configure using the standards above or the [SDKs](/sdk/). ## Encryption and transport See [Security Architecture](/reference/security-architecture/) for the full encryption model and key management details. | Standard | Use | | --- | --- | | TLS 1.3 | Transit encryption | | ChaCha20 | Secret materials at rest | | RS256 | JWT signing | | AES-256 | AWS KMS envelope encryption | | JWT (RFC 7519) | Token format | | JWK (RFC 7517) | Public key distribution via JWKS endpoints | | OIDC Front-Channel Logout | `sid` session identifier claim | ## https://docs.keycard.ai/reference/token-claims # Token Claims Keycard issues signed JSON Web Tokens (JWTs) for its access tokens, ID tokens, and refresh tokens. Each token carries a subset of the following claims. ## Claims | Claim | Description | | --- | --- | | [`iss`](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1) | The Keycard Zone issuer of the token. | | [`sub`](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2) | The subject of the token, the identity it represents: the [User identifier](/concepts/users/#identifier) for user tokens, or the [Application identifier](/concepts/applications/#identifier) for application tokens. Use `sub_profile` to tell them apart. | | `sub_profile` | Classifies the subject: `user` when a person authorized access, `app` when an Application acts on its own behalf. | | `keycard_app_id` | The Application the token was issued for. Its value is the [Application identifier](/concepts/applications/#identifier). | | [`client_id`](https://datatracker.ietf.org/doc/html/rfc8693#section-4.3) | The Application credential used as an OAuth client to obtain the token. Its value is the credential identifier. | | [`aud`](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3) | The Resource the token is intended for. Its value is the [Resource identifier](/concepts/resources/#identifier). Per [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068#section-4), a resource server must reject a token whose `aud` is not an identifier it expects for itself. | | [`scope`](https://datatracker.ietf.org/doc/html/rfc8693#section-4.2) | The permissions granted to the caller, as defined by [access policy](/admin/access-policies). | | [`sid`](https://openid.net/specs/openid-connect-frontchannel-1_0.html#OPLogout) | The session identifier, shared across an authentication session. | | `target` | The [Resource identifiers](/concepts/resources/#identifier) granted as downstream exchange targets, as an array that can name Resources other than `aud`. Only access tokens carry this claim, and never as an empty array; a [Unified Access Gateway](/admin/unified-access-gateway/) resolves the upstreams it proxies from these entries. | | [`exp`](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4) | The time at which the token expires. | | [`iat`](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6) | The time at which the token was issued. | | [`jti`](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7) | A unique identifier for the token, used for audit correlation. | ## Customizing claims `sub`, `keycard_app_id`, and `aud` are the three claims that can be configured. - User tokens: `sub` is the [user identifier](/concepts/users/#identifier). Set it on the user, or have it auto-populated on first login via the provider's [user identifier claim](/concepts/providers/#user-identifier-claim). - Application tokens: `sub` and `keycard_app_id` are the [Application identifier](/concepts/applications/). - `aud` is the [Resource identifier](/concepts/resources/#identifier) of the target Resource. ## Examples A user token, issued when a user authorizes a client. `sub` is the user identifier and `sub_profile` is `user`: ```json { "iss": "https://.keycard.cloud", "sub": "y93oo77cug7p7oaekhda90mcy2", "sub_profile": "user", "keycard_app_id": "orders-service", "client_id": "oxf9xokpfuzojrpc1lyw0uu440", "aud": "http://localhost:9090", "scope": "orders:read", "sid": "nr3hb6dx0a228kscyasis6uuwp", "exp": 1774137902, "iat": 1774137302, "jti": "019d12d2-ddec-7b0c-b293-52af0ca5a2f0" } ``` An application token, issued when an Application acts on its own behalf. `sub_profile` is `app`, and `sub` equals `keycard_app_id`: ```json { "iss": "https://.keycard.cloud", "sub": "orders-service", "sub_profile": "app", "keycard_app_id": "orders-service", "client_id": "oxf9xokpfuzojrpc1lyw0uu440", "aud": "http://localhost:9090", "scope": "orders:read", "sid": "nr3hb6dx0a228kscyasis6uuwp", "exp": 1774137902, "iat": 1774137302, "jti": "019d12d2-ddec-7b0c-b293-52af0ca5a2f0" } ``` ## Verifying tokens Each Zone publishes an OpenID Connect discovery document at `/.well-known/openid-configuration` and OAuth Authorization Server metadata at `/.well-known/oauth-authorization-server`. Both advertise the signing keys at `/openidconnect/jwks`. Verify a token's signature against these keys and check the `iss` and `aud` claims before trusting any other claim. The Keycard SDKs ensure tokens are verified and used securely.