Skip to main content

Agent Identity

Self-Hosted Kubernetes

Identity patterns for AI agents in Kubernetes: a cryptographic workload identity unique to each agent class, a compound dual identity that ties the agent to the human who launched it, and identity for the raw HTTP(S) calls an agent process makes outside the MCP protocol.

Why identity matters

Without verified identity, every agent looks identical in the audit log and every policy applies to all agents equally. With identity, AIControls can:

  • Attribute every tool call and LLM request to a specific agent class in the audit log
  • Enforce per-agent policies: tool allowlists, budget limits, HITL approval rules
  • Detect behavioral anomalies per workload: deny-rate spikes, tool-call frequency deviations
  • Answer compliance questions like "Which agent made this production change and who authorized it?"

Scenario 1: Unique agent identity via ServiceAccount + OIDC

Each agent Deployment gets a dedicated Kubernetes ServiceAccount. AIControls validates the projected SA token against the cluster's OIDC issuer and resolves namespace/serviceaccount as the agent's identity in every audit event and CEL policy.

k8s-tokenreview is roadmap

identity.validationMode: k8s-tokenreview — which validates tokens directly via the Kubernetes TokenReview API without OIDC configuration — is a planned feature (gap G3). Today, use oidc with clusterOIDCIssuer as shown below.

Step 1 — Create a dedicated ServiceAccount per agent class

# agents/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: agents
labels:
aicontrols.nirmata.io/govern-agents: "true"
---
# One ServiceAccount per logical agent type
apiVersion: v1
kind: ServiceAccount
metadata:
name: claude-code-agent
namespace: agents
labels:
aicontrols.nirmata.io/governed: "true"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-agent
namespace: agents
labels:
aicontrols.nirmata.io/governed: "true"

Step 2 — Find your cluster's OIDC issuer

aws eks describe-cluster \
--name YOUR_CLUSTER_NAME \
--query 'cluster.identity.oidc.issuer' \
--output text
# → https://oidc.eks.us-east-1.amazonaws.com/id/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

Step 3 — Configure AIControls to validate SA tokens

# values.yaml (Helm)
identity:
validationMode: oidc
clusterOIDCIssuer: "https://oidc.eks.us-east-1.amazonaws.com/id/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
clusterOIDCAudience: "aicontrols" # must match audience in projected SA tokens; required when clusterOIDCIssuer is set
jwksCacheTTLSeconds: 300
agentCacheTTLSeconds: 60

Step 4 — Project a short-lived token into agent pods

The agent pod requests a projected ServiceAccount token bound to the aicontrols audience with a 1-hour TTL. Kubernetes rotates it automatically before expiry.

# agents/claude-code-agent-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: claude-code-agent
namespace: agents
spec:
template:
spec:
serviceAccountName: claude-code-agent
volumes:
- name: aicontrols-token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600 # 1-hour TTL; auto-rotated
audience: aicontrols
containers:
- name: agent
image: ghcr.io/your-org/claude-code-agent:latest
env:
- name: MCP_SERVER_URL
value: "http://aicontrols.aicontrols.svc.cluster.local:8080"
- name: AICONTROLS_AGENT_TOKEN_PATH
value: "/var/run/aicontrols/token"
volumeMounts:
- name: aicontrols-token
mountPath: /var/run/aicontrols
readOnly: true

Step 5 — Enforce unique SAs with Kyverno

This policy blocks any agent pod that uses the default ServiceAccount, ensuring every agent has a traceable identity.

# kyverno/require-dedicated-agent-sa.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-dedicated-agent-sa
spec:
validationFailureAction: Enforce
background: false
rules:
- name: deny-default-sa
match:
any:
- resources:
kinds: [Pod]
namespaceSelector:
matchLabels:
aicontrols.nirmata.io/govern-agents: "true"
validate:
message: "Agent pods must use a dedicated ServiceAccount — not 'default'."
deny:
conditions:
any:
- key: "{{request.object.spec.serviceAccountName}}"
operator: AnyIn
value: ["default", ""]

Apply it:

kubectl apply -f kyverno/require-dedicated-agent-sa.yaml

Step 6 — Write per-agent CEL policies

With identity resolved, you can write policies scoped to a specific agent:

# policy: claude-code-agent-tool-allowlist.yaml
apiVersion: nirmata.io/v1
kind: AIPolicy
metadata:
name: claude-code-read-only
spec:
matchConditions:
- expression: |
object.mcp.tool != "" &&
object.agent.serviceAccount == "claude-code-agent"
validations:
- expression: |
object.mcp.tool in [
"read_file", "list_directory", "search_files",
"get_file_contents", "search_code"
]
message: "claude-code-agent is restricted to read-only file tools."
action: Deny
---
# Separate budget per agent class
apiVersion: nirmata.io/v1
kind: AIPolicy
metadata:
name: ci-agent-daily-budget
spec:
matchConditions:
- expression: |
object.llm.model != "" &&
object.agent.serviceAccount == "ci-agent"
validations:
- expression: object.session.percentUsed < 100.0
message: "CI agent daily token budget exhausted."
action: Deny
Identity in the Admin UI

Every agent SA automatically gets a row in Admin UI → Identities showing its deny rate, session count, risk score, and behavioral baseline. No extra configuration needed.


Scenario 2: Dual identity — agent + delegating user

When a human user launches an agent that acts on their behalf, you need to know both who ran the agent and for whom it ran. Dual identity captures both in every audit event and makes both available in CEL policies.

The problem with agent-only identity

Without dual identity:

QuestionAgent-only answerDual identity answer
Who touched production?ci-agentsarah@company.com via ci-agent
Can developers run write agents?Cannot expressobject.user.groups check
Who to notify for HITL?Generic admin channelThe developer who triggered the action
Compliance: "Show all changes by contractor X"Not answerableFilterable by user email

How it works

AIControls reads two tokens from each request:

HeaderCarriesResolved as
Authorization: Bearer <token>Agent's projected SA tokenobject.agent.*
X-User-Token: <token>User's OIDC token or developer PATobject.user.*

Both identities appear in every audit event. object.user.isPresent is true only when the user token was successfully validated.

Step 1 — Configure user OIDC providers

Both clusterOIDCIssuer/clusterOIDCAudience (used to validate agent ServiceAccount tokens, Scenario 1 above) and oidcProviders (used to validate the user's token for dual identity) are app settings — see Configuration Model — so values.yaml only seeds them on a fresh install. Manage cluster OIDC going forward from Settings → Identity & Access (or the onboarding flow), and user OIDC providers from Settings → Identity & Access → SSO / OIDC in the Admin UI.

# values.yaml — seeds cluster OIDC and oidcProviders once on first boot only
identity:
validationMode: oidc
clusterOIDCIssuer: "https://oidc.eks.us-east-1.amazonaws.com/id/AAAAAAA"
oidcProviders:
- name: okta
issuer: "https://company.okta.com/oauth2/default"
audience: "aicontrols"
groupsClaim: groups # OIDC claim containing group memberships
- name: google-workspace
issuer: "https://accounts.google.com"
audience: "CLIENT_ID.apps.googleusercontent.com"
hostedDomain: company.com
Already have OIDC providers configured?

If you're adding or changing a provider on a cluster that's been running for a while, edit it in Settings → Identity & Access → SSO / OIDC instead of values.yaml — the database already has a value, so values.yaml is no longer consulted for this field.

Step 2 — Pass the user token from the agent

Option A — Developer PAT (easiest for Claude Code users)

Developers create a Personal Access Token in Settings → Personal Access Tokens. The PAT carries both the user's identity and is scoped to their agent session. Set it as the agent's bearer token — no extra header needed.

# In Claude Code's MCP config:
# AICONTROLS_AGENT_TOKEN contains the developer's PAT
export MCP_SERVER_URL=http://aicontrols.aicontrols.svc.cluster.local:8080

Option B — Explicit X-User-Token header

For orchestrator agents that launch sub-agents on a user's behalf, forward the user's OIDC token:

POST /mcp HTTP/1.1
Authorization: Bearer eyJ... (agent SA token)
X-User-Token: eyJ... (user OIDC token from Okta/Google/etc.)

The agent obtains the user's token from the session that triggered it (e.g., from an OAuth flow, from Okta device flow, or from an enterprise SSO cookie).

Step 3 — Write dual-identity policies

# Require platform-engineering group for production write tools
apiVersion: nirmata.io/v1
kind: AIPolicy
metadata:
name: production-writes-require-platform-eng
spec:
matchConditions:
- expression: |
object.mcp.tool in [
"apply_manifest", "delete_resource", "patch_resource",
"scale", "run_command", "write_file"
]
validations:
- expression: |
object.user.isPresent &&
object.user.groups.exists(g, g == "platform-engineering")
message: >
Production write operations require a platform-engineering group member.
Developers outside this group and unauthenticated agents are blocked.
action: Deny
# HITL for any developer-triggered destructive operation
# routes the approval notification to the specific developer
apiVersion: nirmata.io/v1
kind: AIPolicy
metadata:
name: hitl-developer-destructive-ops
spec:
matchConditions:
- expression: |
object.mcp.tool in ["apply_manifest", "delete_resource", "patch_resource"] &&
object.user.isPresent &&
!object.user.email.endsWith("@ci.internal")
action: Approve
approvalConfig:
timeoutMinutes: 30
timeoutAction: deny
channels: ["slack", "email"]
message: >
{{object.user.email}} is requesting {{object.mcp.tool}} via
{{object.agent.serviceAccount}}. Approve or deny within 30 minutes.
# Deny agents acting without user context in production
apiVersion: nirmata.io/v1
kind: AIPolicy
metadata:
name: require-user-context-in-production
spec:
matchConditions:
- expression: |
object.mcp.tool != "" &&
object.agent.namespace == "production-agents"
validations:
- expression: object.user.isPresent
message: "Agents in production-agents namespace must carry a validated user context."
action: Deny

CEL identity fields reference

FieldTypeDescriptionExample
object.agent.serviceAccountstringK8s ServiceAccount nameclaude-code-agent
object.agent.namespacestringPod namespaceagents
object.agent.denyRate24hfloatFraction of calls denied in last 24h0.12
object.agent.baselineZScorefloatDeviation from behavioral baseline2.1
object.user.isPresentboolUser token was validatedtrue
object.user.emailstringUser email from OIDC claimsarah@company.com
object.user.groupslistGroup memberships from OIDC claim["platform-engineering"]
object.session.toolCallCountintTool calls so far this session47
object.session.deniedInSessionintDenied calls this session2
object.session.percentUsedfloatBudget consumed (0–100)62.4
object.mcp.toolstringTool name; empty for LLM callsread_file
object.llm.modelstringModel ID; empty for MCP callsclaude-sonnet-4-6
Complete CEL reference

See Policy Context Reference for all available fields, their types, and example values.


Scenario 3: Agent identity for network egress calls

Network egress filtering governs the raw HTTP(S) calls an agent process makes directly — a curl, a kubectl command, a webhook — outside the MCP protocol entirely. Those calls have no MCP envelope to carry a custom header in, so the Authorization/X-Aicontrols-Agent-Token mechanism from Scenarios 1 and 2 doesn't apply: a kubectl or gh process can't be told to set an arbitrary header, and often there's no agent-side code to modify at all.

The problem with header-based identity for egress

An egress call is just whatever HTTP(S) request the agent process happens to make — there's no client library in the loop that AIControls controls or that the agent developer necessarily wrote. Requiring a custom header would mean patching every tool an agent might shell out to.

How it works

The egress proxy is reached as an HTTP(S) proxy (HTTP_PROXY/HTTPS_PROXY), so it identifies callers using the credential every proxy-aware HTTP client already knows how to send: Proxy-Authorization (RFC 7235), populated automatically when the proxy URL includes a userinfo component. Embed the agent's token there instead of in a custom header:

env:
- name: HTTP_PROXY
value: "http://$(AGENT_TOKEN)@aicontrols.aicontrols.svc.cluster.local:8443"
- name: HTTPS_PROXY
value: "http://$(AGENT_TOKEN)@aicontrols.aicontrols.svc.cluster.local:8443"

From that point on, every request the agent process sends through the proxy carries the credential without any code change: once per TCP connection via the CONNECT request for HTTPS, and on each individual request for plain HTTP. curl, kubectl (a standard Go net/http client), gh, and effectively any HTTP client that honors the proxy environment variables all pick this up for free.

AIControls resolves the Proxy-Authorization credential exactly the way it resolves X-Aicontrols-Agent-Token on the MCP/LLM planes — the same VirtualKey/PAT/ServiceAccount-token lookup — and populates the same object.agent.* CEL fields (serviceAccount, namespace, podName, podUID, nodeName, and so on), so the per-agent policies from Scenario 1 apply unchanged to egress traffic. If a request carries both an explicit X-Aicontrols-Agent-Token header and a Proxy-Authorization credential, the header takes precedence.

Step — Write a policy scoped by agent and destination

# Allow the CI agent to reach GitHub and npm, deny everything else,
# without affecting any other agent's egress
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: ci-agent-egress-allowlist
spec:
matchConditions:
- expression: |
object.http.host != "" &&
object.agent.serviceAccount == "ci-agent"
validations:
- expression: |
object.http.host in ["api.github.com", "registry.npmjs.org"]
message: "ci-agent egress is restricted to GitHub and npm."
validationActions: [Deny]

See Enable Network Egress Filtering for the full setup steps, and Policy Context — object.http.* for every egress-call field.


Requiring verified identity

Both scenarios above produce a verified identity: the ServiceAccount JWT is checked against the cluster's OIDC issuer, and a VirtualKey is resolved via hash lookup. Identities built any other way — anonymous access, or unsigned claims parsed under validationMode: passthrough — are unverified.

By default, unverified identities are allowed through like any other caller. Set identity.requireVerifiedIdentity: true to hard-block them instead: every request from an unverified identity is denied and recorded in the audit log with Policy: unverified-identity.

# values.yaml
identity:
validationMode: oidc
clusterOIDCIssuer: "https://oidc.eks.us-east-1.amazonaws.com/id/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
clusterOIDCAudience: "aicontrols"
requireVerifiedIdentity: true

This is a startup-time setting — it takes effect on the next pod restart (helm upgrade or a rollout restart), not immediately. There is no interactive toggle for it in the admin UI; About shows a read-only "Verified Identity Required" status reflecting the current config value.

requireVerifiedIdentity is validated against the rest of your identity config at startup:

Combined withResult
validationMode: noneStartup error. none mode never cryptographically verifies anyone, so every request would be blocked — the proxy refuses to start.
allowAnonymous: trueStartup error. allowAnonymous substitutes an unverified synthetic identity for callers with no Authorization header, which requireVerifiedIdentity would then reject — the two settings are contradictory.
validationMode: passthrough (alias cel)Startup warning, not an error. Passthrough never checks JWT signatures, so almost all JWT-based callers will be blocked; only VirtualKey, PAT, and session-token callers verify successfully in this mode.

Before enabling the hard block, enable the audit-mode builtin policy require-agent-identity from Policies → Library to see how many current requests would be affected — it evaluates the same object.identity.verified condition but only flags, never denies.


Next steps