Agent Identity
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.
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
- Amazon EKS
- Google GKE
- Azure AKS
- On-prem / kind
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
GKE OIDC issuer follows this format:
https://container.googleapis.com/v1/projects/PROJECT_ID/locations/REGION/clusters/CLUSTER_NAME
# Confirm with:
gcloud container clusters describe YOUR_CLUSTER_NAME \
--region YOUR_REGION \
--format='value(name)'
# Construct: https://container.googleapis.com/v1/projects/$(gcloud config get project)/locations/REGION/clusters/NAME
az aks show \
--name YOUR_CLUSTER_NAME \
--resource-group YOUR_RESOURCE_GROUP \
--query 'oidcIssuerProfile.issuerUrl' \
--output tsv
# → https://eastus.oic.prod-aks.azure.com/TENANT_ID/CLUSTER_ID/
# Note: AKS uses the subdomain "oic" (not "oidc") — this is the correct format.
# For any cluster — the issuer is in the service account token's iss claim:
kubectl get --raw /openid/v1/configuration | jq .issuer
# → https://kubernetes.default.svc.cluster.local (for kind)
# → https://kubernetes.default.svc (for kubeadm)
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
Every agent SA automatically gets a row in Admin UI → Inventory → Identities showing its deny rate, session count, risk score, and behavioral baseline. No extra configuration needed.
If the agent authenticates MCP tool calls with its ServiceAccount token (as above) but authenticates LLM calls with a VirtualKey, set the VirtualKey's optional namespace field to this ServiceAccount's namespace. Otherwise the two call types are tracked as separate rows on the Identities page — one for the ServiceAccount, one for the VirtualKey — instead of converging into a single identity. Alternatively, the agent can present the VirtualKey itself as the bearer token for MCP tool calls instead of a ServiceAccount token — the proxy resolves it to the same identity, so both LLM and MCP traffic converge on one Identities row without needing a ServiceAccount at all.
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:
| Question | Agent-only answer | Dual identity answer |
|---|---|---|
| Who touched production? | ci-agent | sarah@company.com via ci-agent |
| Can developers run write agents? | Cannot express | object.user.groups check |
| Who to notify for HITL? | Generic admin channel | The developer who triggered the action |
| Compliance: "Show all changes by contractor X" | Not answerable | Filterable by user email |
How it works
AIControls reads two tokens from each request:
| Header | Carries | Resolved as |
|---|---|---|
Authorization: Bearer <token> | Agent's projected SA token | object.agent.* |
X-User-Token: <token> | User's OIDC token or developer PAT | object.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
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
| Field | Type | Description | Example |
|---|---|---|---|
object.agent.serviceAccount | string | K8s ServiceAccount name | claude-code-agent |
object.agent.namespace | string | Pod namespace | agents |
object.agent.denyRate24h | float | Fraction of calls denied in last 24h | 0.12 |
object.agent.baselineZScore | float | Deviation from behavioral baseline | 2.1 |
object.user.isPresent | bool | User token was validated | true |
object.user.email | string | User email from OIDC claim | sarah@company.com |
object.user.groups | list | Group memberships from OIDC claim | ["platform-engineering"] |
object.session.toolCallCount | int | Tool calls so far this session | 47 |
object.session.deniedInSession | int | Denied calls this session | 2 |
object.session.percentUsed | float | Budget consumed (0–100) | 62.4 |
object.mcp.tool | string | Tool name; empty for LLM calls | read_file |
object.llm.model | string | Model ID; empty for MCP calls | claude-sonnet-4-6 |
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.
validationMode: passthrough does not require the caller's bearer token to be a JWT. A non-JWT opaque credential — a raw API key, or a cloud provider's own OAuth2 access token (for example Google's ya29..., which a standard Vertex AI client sends by default) — is accepted the same way a JWT-shaped token is: with an unverified synthetic identity, never a hard rejection based on token shape. Passthrough mode's built-in validator never checks a JWT's signature either, so gating on shape alone would not add real security by itself.
Google OAuth2 access tokens are a special case, because they are the credential every native Google SDK client sends by default. See Attributing Google access tokens to a real user below — those are resolved to the caller's actual email rather than left as a synthetic identity, though they remain unverified for the purposes of requireVerifiedIdentity.
validationMode: oidc performs that cryptographic verification automatically, in Go, before a request is admitted. passthrough/cel mode does not verify anything itself, but it preserves the caller's raw bearer token as object.agent.rawToken (and object.user.rawToken for a delegated user) so a policy can verify it explicitly:
variables:
- name: decoded
expression: >
jwt.Decode(object.agent.rawToken, jwks.Fetch("https://your-idp.example.com/.well-known/jwks.json"))
validations:
- expression: "variables.decoded.valid"
message: "Agent bearer token is invalid or expired"
In other words, oidc gets you verification automatically; passthrough/cel lets you write it as a policy — including for opaque, non-JWT tokens where jwt.Decode would simply fail closed via the same validations check.
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 with | Result |
|---|---|
validationMode: none | Startup error. none mode never cryptographically verifies anyone, so every request would be blocked — the proxy refuses to start. |
allowAnonymous: true | Startup 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.
Attributing Google access tokens to a real user
Clients that authenticate with Google Application Default Credentials — the Gemini CLI, gcloud, @langchain/google-vertexai, and most native Vertex AI SDKs — send an opaque ya29... OAuth2 access token. Unlike an ID token it carries no readable claims, so there is nothing in the token itself to attribute the request to.
Under validationMode: passthrough, AIControls introspects these tokens against Google's tokeninfo endpoint and attributes the request to the email it returns. Without this, each such caller would appear as a synthetic opaque:<hash> identity derived from the token — and because Google rotates access tokens roughly hourly, one person's traffic would fragment into a new identity on every refresh: spend split across multiple Cost Attribution rows, an active-user count that climbs without anyone new appearing, per-user budgets that never recognise a returning caller, and audit rows naming a hash rather than a person.
This is enabled by default and requires no configuration. It applies only to tokens carrying Google's ya29. prefix — no other credential (a provider API key, an aic_ PAT, another vendor's opaque token) is ever sent to Google.
Egress implications
This makes an outbound HTTPS call to oauth2.googleapis.com from the proxy pod, so restricted-egress clusters need to account for it:
- The call fires only for
ya29.-prefixed tokens. A deployment that never receives one never makes the call. - Results are cached per token (capped by the token's own remaining lifetime), and failures are cached briefly too, so a busy client costs roughly one call per token rotation rather than one per request.
- It is fail-open: an unreachable endpoint, a timeout, or any error response falls back to the previous
opaque:<hash>identity. Resolution failing never turns into a rejected request.
If your environment requires a guarantee that the identity path makes no outbound call at all, disable it:
# values.yaml
identity:
googleTokenInfo:
disabled: true
Settings
| Setting | Default | Purpose |
|---|---|---|
identity.googleTokenInfo.disabled | false | Turn off online resolution entirely. Callers fall back to the opaque:<hash> identity. |
identity.googleTokenInfo.endpoint | Google's public endpoint | Route introspection through an egress proxy or private mirror. |
identity.googleTokenInfo.timeoutSeconds | 2 | Bounds a single introspection call. This sits on the request path for the first request of each new token, so keep it well below your LLM timeout. |
identity.googleTokenInfo.cacheTTLSeconds | 300 | Caps how long a resolved identity is reused. The effective TTL is the lower of this and the token's own remaining lifetime. |
identity.googleTokenInfo.negativeCacheTTLSeconds | 60 | How long an unresolvable token is remembered as such, so a token Google will never resolve does not re-trigger a call on every request. |
Identity and verification
A resolved caller is attributed to the verified email Google returns. If Google reports the address as unverified, AIControls falls back to a google-sub:<id> identity instead — still stable across token rotation, but never asserting an email address Google itself declines to vouch for.
Resolution does not mark the identity as verified. tokeninfo confirms the token is live and names its owner, but that is not the cryptographic signature verification requireVerifiedIdentity is asking about — so these callers are still subject to the unverified-identity rules described above.
Next steps
- Write a policy — CEL policy authoring guide
- Enable Network Egress Filtering — set up the egress proxy and agent identity for raw HTTP(S) calls
- Policy Context Reference — full list of CEL fields