Policy-Based Access Control for Machine Identities
How KeyZero's PDP server uses JWT verification and CEL policy evaluation to enforce fine-grained access control for AI agents, CI runners, and service workloads
KeyZero's Policy Decision Point (PDP) server provides fine-grained access control specifically designed for machine identities: AI agents, CI runners, and service workloads. This article explains how JWT verification and CEL policy evaluation work together to enforce least-privilege secret access at machine speed.
Machine Identities Are Not Human Identities
Human access control is built around interactive authentication: passwords, MFA prompts, session cookies. Machine identities -- CI runners, Kubernetes pods, AI agents, serverless functions -- authenticate with JWTs, service account tokens, and OIDC assertions. They operate at machine speed, run thousands of concurrent sessions, and never respond to an MFA challenge.
Traditional secrets management treats machine access as a special case of human access. KeyZero treats it as the primary case. Every resolve request passes through a three-stage pipeline: JWT verification, CEL policy evaluation, and secret resolution. No policy match means no access -- implicit deny is the default.
How the PDP Server Works
KeyZero's Policy Decision Point (PDP) server is the kz server start process. It receives resolve requests over HTTP (or MCP for AI agents), and evaluates each one through three sequential stages.
Stage 1: JWT Verification and Identity Normalization
The caller presents a JWT in the Authorization: Bearer <token> header. The PDP validates the token signature against configured issuers, checks expiration and audience claims, and normalizes the verified claims into standard identity fields.
issuers:
- name: github-actions
type: github-actions
issuer_url: "https://token.actions.githubusercontent.com"
audience: "keyzero"
- name: k8s-cluster
type: kubernetes
issuer_url: "https://kubernetes.default.svc"
audience: "keyzero"
- name: ai-agents
type: custom
issuer_url: "https://auth.internal"
audience: "keyzero"
map:
org: "claims.tenant_id"
service: "claims.agent_name"
env: "claims.environment"
Each issuer type has a built-in profile that maps JWT claims to standard variables (org, service, env, action, branch, actor, groups). GitHub Actions tokens automatically map repository_owner to org and repository to service. Kubernetes tokens map namespace to org and serviceaccount to service. Custom issuers use explicit map overrides.
The PDP tries issuers in declaration order. The first issuer whose key validates the token wins. Supported algorithms include RS256, RS384, RS512, ES256, ES384, PS256, PS384, PS512, HS256, HS384, and HS512.
Stage 2: CEL Policy Evaluation
After JWT verification, policies written in CEL (Common Expression Language) determine whether the request is allowed. Policies are evaluated top-down, first-match-wins.
policies:
- name: allow-ci-deploy
rule: "org == 'myorg' && action == 'deploy.yml' && ref.matches('secret/data/prod/**')"
effect: allow
- name: allow-dev-access
rule: "env == 'dev' && ref.matches('secret/data/dev/**')"
effect: allow
- name: default-deny
rule: "true"
effect: deny
CEL expressions receive verified identity fields from the JWT, the requested secret path as ref, raw JWT claims via claims.*, and untrusted caller-declared context via context.*.
Stage 3: Secret Resolution
If the policy allows the request, KeyZero resolves the secret from the matching backend (HashiCorp Vault, AWS Secrets Manager, 1Password, or others) and returns the result.
CEL Policy Examples for Machine Identities
Restrict by Agent Name
Assign each AI agent a unique service claim in its JWT. Scope its access to only the secrets it needs:
- name: allow-code-agent
description: "Code generation agent can only access code-related API keys"
rule: "service == 'code-agent' && ref.matches('secret/data/api/github/**')"
effect: allow
- name: allow-deploy-agent
description: "Deploy agent can access cloud credentials"
rule: "service == 'deploy-agent' && ref.matches('secret/data/cloud/**')"
effect: allow
Restrict by Secret Path
Use ref.matches() with glob patterns to enforce path-based scoping. This ensures that even a broadly-permissioned identity cannot access secrets outside its designated path hierarchy:
- name: staging-only
rule: "env == 'staging' && ref.matches('secret/data/staging/**')"
effect: allow
Restrict by Time Window (Context-Based)
Use caller-declared context fields for additional gating. Since context.* is untrusted, always combine it with verified identity checks:
- name: maintenance-window
description: "Allow prod access only when maintenance flag is set by verified deploy service"
rule: "service == 'deploy-bot' && context.maintenance == 'true' && ref.matches('secret/data/prod/**')"
effect: allow
Block Compromised Identities
Place deny rules before allow rules to immediately block a compromised workload:
- name: block-compromised-agent
rule: "service == 'compromised-agent'"
effect: deny
Group-Based Break-Glass Access
Use JWT group claims for emergency access patterns:
- name: oncall-break-glass
rule: "'oncall-sre' in groups && ref.matches('secret/data/break-glass/**')"
effect: allow
Integration with Existing Identity Providers
KeyZero does not replace your identity provider. It consumes JWTs from any OIDC-compliant issuer. In practice, this means:
- GitHub Actions: Use the built-in OIDC token. Set
type: github-actionsand KeyZero automatically maps repository, owner, environment, and workflow claims. - Kubernetes: Use projected service account tokens with a custom audience. Set
type: kubernetesand namespace/service-account map automatically. - AWS: IAM roles provide OIDC tokens. Set
type: awsfor account and role ARN mapping. - GCP: Workload identity federation tokens. Set
type: gcpfor project and email mapping. - Custom IdPs: Any OIDC provider (Auth0, Okta, Keycloak) works with
type: customand explicitmapoverrides.
Multiple issuers can coexist in a single bundle. A GitHub Actions runner, a Kubernetes pod, and a custom AI agent platform can all authenticate to the same PDP server, each with their own issuer configuration and policy rules.
Policy Design Guidelines
Order matters. Deny rules go first, then specific allow rules, then broader allow rules, then the catch-all deny. This prevents accidental over-permissioning.
One condition per rule. Compose access control through multiple simple policies rather than one complex expression. This makes audit easier.
Use verified fields for decisions. The org, service, env, action, branch, actor, and groups variables come from the verified JWT. The context.* fields are caller-declared and untrusted -- never use them as the sole basis for an allow decision.
Validate with kz server check. Run kz server check --bundle ./bundle.yaml to validate your bundle configuration before deployment. This catches syntax errors in CEL expressions and missing backend references.
Policy-based access control is one of the five patterns for secret-safe AI deployments. For a deeper look at how the PDP fits into KeyZero's overall resolution pipeline, see the architecture deep dive.