Skip to content
Control access

Access Policies

Access policies decide which Users and Applications can access which Resources in Keycard.

Each Zone runs exactly one active Policy Set, and Keycard checks it every time it issues a credential. Out of the box that is default-zone-policies, which contains three managed Policies: every authenticated User may reach every Resource, any Application may act on behalf of a User, and any Application acting on its own may reach the Resources it declares as dependencies.

The Policy Sets tab showing default-zone-policies as the active Policy Set

These defaults are useful while you are building out Applications and Resources, but should be restricted in a production setup. This page walks through replacing them with a Policy Set of your own in the Console, then collects the rule patterns you will reach for as that set grows.

Every Policy has an owner, and the owner determines who can change it. There are two types:

Type Who writes it Who can change it
Managed Keycard Keycard only. You can include it in a set but not edit or delete it.
Customer You You, through the Console or the API.

A Policy Set can mix both types.

Keycard creates and maintains the three Policies in default-zone-policies. You can’t edit or delete them, but you can add them to your own set if the set uses the schema their latest version was written for. On a set left on the default schema, 2026-03-16, Add policy… greys them out, and the tooltip names the schema they need (2026-06-18). Choose that schema when you create the set to include them.

default-user-grants : Permits all authenticated Users access to all Resources
permit (
principal is Keycard::User,
action,
resource
);
default-app-delegation : Permits Applications to act on behalf of Users

This is the permit behind OAuth delegation flows (context.on_behalf == true).

permit (
principal is Keycard::Application,
action,
resource
) when {
context.on_behalf == true
};
default-app-direct-access : Permits Applications to access their dependencies directly

An Application acting as a consumer may reach a Resource on its own, without a User, if that Resource is in its dependencies set. The permit applies only to direct access: it does not cover delegation or impersonation, which need their own permit.

permit (
principal is Keycard::Application,
action,
resource
) when {
principal.dependencies.contains(resource) &&
context.on_behalf == false &&
context.impersonate == false
};

Console’s All policies tab labels these Platform. To start from a copy you can edit, open one there and click Duplicate.

Customer Policies are the rules you write. You assemble them into a Policy Set, and when you activate that set it replaces default-zone-policies as the active set for the Zone.

The rest of this page is about Customer Policies: how to write one, test it, and activate it.

  • The Admin organization Role, or the Manager Role on the Zone (see Roles & Permissions). Checking a decision under Policies → Activity takes the organization Admin Role: the tab needs the events:list permission, which custom Zone Roles don’t have
  • The Keycard CLI installed. This guide uses it as the Application, and uses the two platform Resources it requests at sign-in: OpenID Connect UserInfo and Events API

If you built the Quickstart tool, test its hop against the active set first. This changes nothing.

Open Policies → Policy Sets and select default-zone-policies. Select Run test in the right-hand panel and set Actor to My GitHub Tools, Action to any and Resource to GitHub API, turn User context on, and set Subject to your account. Click Run. It returns Allow, and Determining policies names two rules: default-user-grants (you may reach GitHub API) and default-app-delegation (My GitHub Tools may act for you). The Actor list doesn’t include your coding agent, so only your server’s hop can be tested here.

The goal is a Policy that lets the Keycard CLI, acting for a signed-in User, call the OpenID Connect UserInfo endpoint. Everything else stays denied.

  1. Create a Policy Set

    In Keycard Console, open Policies, select the Policy Sets tab, and click New Policy Set. Give the set a name and leave the schema on its default version.

    An empty new Policy Set in the editor, with the Policy list on the left and the Run test panel on the right
  2. Add a Policy

    Click Add policy…, then Create new policy.

    The Add policy menu offering Create new policy and the three managed default Policies

    Enter a name, such as allow-cli-access, and an optional description. Click Add to set. This saves the Policy to the Zone at once: if you cancel the set later, the Policy stays under All policies, so reuse it from there or pick another name.

    The Create policy dialog with the name allow-cli-access and a description
  3. Write the first rule: permit the User

    The new Policy opens with one empty rule. You can build rules in Visual mode or switch to Cedar and write the source directly.

    In Visual mode, set the rule up as follows:

    Field Value
    Rule effect Permit
    principal type is → User
    action any
    resource is → Resource → the ID of the OpenID Connect UserInfo Resource

    After Resource, Visual mode shows a free-text resource identifier box rather than a picker. It takes the Resource’s ID, not its name or URL. To find the ID, open Resources → OpenID Connect UserInfo in another tab and copy it from the page’s URL, where it follows /resources/.

    If you paste the Identifier URL the Resource page shows instead, the rule still publishes but never matches. To match on the Identifier, write the condition in Cedar as resource.identifier == "<resource-identifier>", as in Restrict a Resource to a Group.

    The policy editor in Visual mode with a single Permit rule for any User on the UserInfo Resource

    This permits the User. Keycard also needs a permit for the Application acting on their behalf.

  4. Add a second rule: permit the Application

    Click Create new rule at the bottom of the editor and configure it:

    Field Value
    Rule effect Permit
    principal is → Application → the ID of Keycard CLI
    action any
    resource is → Resource → the ID of the OpenID Connect UserInfo Resource
    when context.on_behalf equals true

    The application identifier box takes an ID as well, and an Identifier URL there never matches either. Open Applications → Keycard CLI and copy the ID from the URL, where it follows /applications/.

    The on_behalf condition restricts the CLI to acting for a signed-in User.

    Switch to the Cedar tab to see the same two rules as source:

    permit(
    principal is Keycard::User,
    action,
    resource == Keycard::Resource::"<userinfo-resource-id>"
    );
    permit(
    principal == Keycard::Application::"<cli-application-id>",
    action,
    resource == Keycard::Resource::"<userinfo-resource-id>"
    )
    when {
    (context has on_behalf && context.on_behalf == true)
    };
  5. Publish a candidate and test it

    Click Publish as Candidate and confirm. Keycard saves a version of the set, but the Zone keeps running on default-zone-policies. Publish before you test: Run test can’t evaluate a Policy you created in the set until the set has a published version.

    The right-hand panel then shows History. Select Run test to evaluate a request against every Policy in the set, and test both halves of the decision.

    First, the User. Set Actor to your own account, Action to any, and Resource to OpenID Connect UserInfo. Leave User context off. Click Run.

    Then, the Application. Set Actor to Keycard CLI, keep the same Resource, and turn User context on. Set Subject to your account. Click Run.

    The Run test panel with Keycard CLI as the actor, User context enabled with a subject set, returning Allow with two determining policies

    Both runs should return Allow. The Determining policies list under the result names the rules that produced the decision. Testing walks through both runs in detail, the denials you should expect, and the request context fields.

  6. Activate

    The set’s header now marks it Candidate and has an Activate button. Before you activate, note the version the Policy Sets list shows for default-zone-policies. Rolling back to the defaults restores that version.

    When you are ready, click Activate in the set’s header. The candidate replaces default-zone-policies as the Zone’s active set. If you built the Quickstart tool, call it once, then open Policies → Activity: a red Deny on GitHub API follows Policy Set Version Activated.

  1. Sign in with the CLI

    Sign out first so the CLI goes through the full browser flow, then sign in against the Zone. Pass your organization ID as well as the Zone ID: signout fails without it, and signin stops to ask for it. Settings → Connection shows both.

    Terminal window
    keycard auth signout --zone <zone-id> --org <org-id>
    keycard auth signin --zone <zone-id> --org <org-id>

    The browser first asks you to let Keycard CLI access your Keycard account. Click Allow access, and the CLI goes on to sign in to the Zone.

  2. Read the denial

    The browser shows an Access denied page rather than the consent screen.

    Access denied page stating that Keycard CLI is not allowed to access Events API on behalf of the User, with both User and Application marked deny

    The CLI requests Events API at sign-in as well as UserInfo, and the Policy only covers UserInfo. The Why panel shows neither the User nor the Application has a permit for it. The same decision is recorded under Policies → Activity; Troubleshooting covers how to read it.

  3. Add the missing Resource

    Open the Policy Set, select allow-cli-access, and add two more rules for Events API, mirroring the two you wrote for UserInfo: one permitting the User, one permitting Keycard CLI when context.on_behalf is true.

    Because the set is active, saving deploys the new version immediately, so the Console asks you to confirm.

    The Save and deploy dialog warning that policy changes take effect immediately and listing allow-cli-access as a new version

    Click Save and deploy.

  4. Sign in again

    The first sign-in keeps waiting after the denial, so stop it with Ctrl-C. Then run keycard auth signin --zone <zone-id> --org <org-id> once more. This time, after the account consent, the browser shows the consent screen listing both Resources. Click Allow access, and the CLI completes sign-in.

    The consent screen for Keycard CLI listing Emit events on your zone and View your profile, with an Allow access button

    The consent lists what the CLI may do with each Resource: Emit events on your zone (Events API) and View your profile (UserInfo). Signing out in step 1 only deleted the CLI’s local session files; it changed nothing in the Zone.

The walkthrough named one Application and one Resource. The patterns below cover what most real Policy Sets need next: granting by membership rather than by name, and conditioning on how an Application authenticates. For every entity and context attribute a rule can read, see the Policy Schema reference.

Keycard uses Cedar as its policy language. A rule either permits or forbids, a forbid always wins over a permit, and when/unless clauses add conditions.

Roles and Groups let a rule match a set of principals; membership is resolved when the request is evaluated. Both use Cedar’s in operator against a membership target.

Membership comes from Role assignments.

The platform-owned Roles are the administrative ones from Roles & Permissions: admin and viewer. The Console shows a Zone-scoped admin assignment as the Manager Zone Role, so a rule on Keycard::Role::"admin" matches your Zone Managers as well as organization Admins.

@id("permit-platform-admin-role")
permit (
principal in Keycard::Role::"admin",
action,
resource
);

A custom Role is one you define in a Zone, such as deployer. Rules match it as Keycard::CustomRole, a separate entity type, so its identifier can’t collide with a platform Role. You create custom Roles and assign them to Users and Applications through the management API, with a Service Account’s token. Console has no screen for them yet.

# Create the Role in the Zone
curl -X POST https://api.keycard.ai/zones/<zone-id>/roles \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"identifier": "deployer"}'
# Assign it to a User. For an Application, use /applications/<application-id>/roles
curl -X POST https://api.keycard.ai/zones/<zone-id>/users/<user-id>/roles \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"role_identifier": "deployer", "owner_type": "customer"}'

A User exists in the Zone only after their first sign-in, so assign their Role after that. Then match the Role in a rule:

@id("permit-deployer-role")
permit (
principal in Keycard::CustomRole::"deployer",
action,
resource
);

Roles resolve for both the actor and the subject in a delegated request. This rule permits members of the viewer Role only when acting on behalf of a User:

@id("permit-viewer-on-behalf")
permit (
principal in Keycard::Role::"viewer",
action,
resource
) when {
context.on_behalf == true
};

in matches exact entity IDs; there are no Role wildcards or negation. To exclude non-members, write a forbid rule or rely on default deny:

@id("restrict-resource-to-admins")
forbid (
principal,
action,
resource == Keycard::Resource::"<resource-identifier>"
) unless {
principal in Keycard::Role::"admin"
};
  • Role assignments are the source of truth, read at evaluation time. Changing an assignment changes decisions without a policy update.
  • Zone-scoped assignments apply only within their Zone. Unscoped assignments apply in every Zone.

Use a Group when the rule is about who someone is on your team (the data analysts, the on-call rotation). Use a Role when the rule is about what someone may administer.

The entity ID is the Group’s identifier:

@id("permit-data-analysts")
permit (
principal in Keycard::Group::"data-analysts",
action,
resource
);

This rule permits the data analysts to reach a Snowflake Resource, and only with a read-write session role:

@id("permit-data-analysts-snowflake")
permit (
principal in Keycard::Group::"data-analysts",
action,
resource
)
when
{
resource.identifier == "<resource-identifier>" &&
context has scopes &&
context.scopes.containsAny(["session:role:READWRITE_ROLE"])
};

In a delegated request, Group membership belongs to the subject (the User being acted for), not to the calling Application. This rule permits any Application acting on behalf of an on-call engineer:

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

There are two ways to match on group membership:

  • principal in Keycard::Group::"<identifier>" reads Keycard Group membership at evaluation time.
  • context.subject_claims.groups.contains("<name>") reads the groups claim the identity provider put on the token at sign-in.

The groups claim is a legacy mechanism and will be deprecated. Use Keycard Groups instead: membership is evaluated on every request rather than frozen into the token at sign-in, and it does not depend on the provider being configured to send the claim.

If you have existing rules that match on the claim, they look like this. Plan to migrate them to Keycard::Group:

@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")
};
  • Membership is read at evaluation time. 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.
  • Groups do not nest. in matches direct membership only.

Rules can also test how an Application was registered and how it authenticates. This forbid rule blocks any Application that does not authenticate with short-lived tokens, so a service holding a static secret cannot get upstream access through Keycard:

@id("require-workload-identity")
forbid (
principal is Keycard::Application,
action,
resource
) unless {
principal has credential_type && principal.credential_type == Keycard::CredentialType::"token"
};

Applications configured with workload identity federation have a credential_type of "token". The attribute can be absent, which is why the rule checks principal has credential_type first. The other values, along with registration_method, traits, and dependencies, are listed under Keycard::Application in the Policy Schema.

Coding agents such as Claude Code register themselves the first time a User signs in with them, and each registration gets a new ID, so there’s no stable name to put in a rule. To policy they’re Applications whose registration_method is dcr, and under the defaults default-app-delegation lets any of them act for a User who approves the consent screen. That consent returns on each new sign-in (a new machine, a new server, a revoked session), not every day. The condition principal.registration_method == Keycard::RegistrationMethod::"dcr" matches every self-registered client at once; the name a client shows, principal.name, is whatever it reported when it registered.

  • Run test returns Deny but the rules look right. Check which half failed. The User test needs a rule whose principal matches a User; the Application test needs a rule whose principal matches the Application, and on_behalf conditions only hold when User context is on. See Testing.
  • The CLI still lands on Access denied after Save and deploy. Open the Activity event and check that the denied Resource is the one your rule names. Reproduce it in Run test with the same Actor, Subject, and Resource.
  • Activate is greyed out. You are viewing a set version that is already active, or you have unsaved changes. Publish first, then activate.