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.
Follow Set up Microsoft Entra agent identity to create native agent identities, configure separate certificate credentials, assign read/write roles, and validate MCP access through AIControls. That flow uses Entra-issued access tokens, not Kubernetes ServiceAccount tokens or human console SSO.
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.
AIControls verifies ServiceAccount tokens against the cluster's OIDC issuer; it doesn't call the Kubernetes TokenReview API. For a verified identity, set identity.validationMode: 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 / DOKS
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, ask the API server for its issuer:
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer
# → https://kubernetes.default.svc.cluster.local (for kind and DigitalOcean DOKS)
# → https://kubernetes.default.svc (for kubeadm)
Managed clusters with a public issuer (Amazon EKS, Google GKE, and Azure AKS created with --enable-oidc-issuer) serve their keys from a public URL. kind, kubeadm, and DigitalOcean (DOKS) clusters use the in-cluster API server as the issuer instead; see In-cluster API server issuer.
In-cluster API server issuer
When AIControls runs in the same cluster and clusterOIDCIssuer is that cluster's API server, AIControls fetches the issuer's discovery document and signing keys (JWKS) as an in-cluster client:
- It verifies the API server's certificate against the cluster CA that Kubernetes mounts into the pod at
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt. TLS verification stays on; the public trust store isn't used for this issuer. - It authenticates with its own ServiceAccount token. The default
system:service-account-issuer-discoveryClusterRoleBinding lets every ServiceAccount read the keys, so you don't add any RBAC. - It connects only to the address in
KUBERNETES_SERVICE_HOSTandKUBERNETES_SERVICE_PORT, and reads the keys from the API server's/openid/v1/jwkspath.
AIControls treats the issuer as the in-cluster API server only when all of the following are true:
- AIControls runs in a pod, so
KUBERNETES_SERVICE_HOSTandKUBERNETES_SERVICE_PORTare set. - The issuer is an
httpsURL with no path, and its host iskubernetes.default.svc,kubernetes.default.svc.<cluster domain>(for example,kubernetes.default.svc.cluster.local), or theKUBERNETES_SERVICE_HOSTaddress. - The issuer's port,
443when omitted, matchesKUBERNETES_SERVICE_PORT. - The issuer is spelled exactly as Kubernetes publishes it: lowercase, with no trailing slash and no explicit
:443. Copy theissuervalue fromkubectl get --raw /.well-known/openid-configuration.
OIDC issuer checks are exact string comparisons, so a spelling such as https://kubernetes.default.svc.cluster.local/ or https://kubernetes.default.svc.cluster.local:443 can never verify a token. Save & verify rejects it and names the value to use; a value set in values.yaml shows the same message in the startup check and in the proxy log. For any issuer, Save & verify and the startup check also fail when the discovery document's issuer differs from the configured value, and the message shows both values.
The issuer host resolves to the kubernetes Service's private ClusterIP, which the outbound address check normally blocks (see Private identity endpoints). AIControls exempts only this issuer from that check, because the connection can reach only the cluster's own API server and must present a certificate signed by the cluster CA. You don't add the ClusterIP to proxy.ssrfAllowlist. Any other private issuer, including another Service in the cluster, still needs an allowlist entry.
Keep the ServiceAccount token mounted in the AIControls pods (the chart default). If you set automountServiceAccountToken: false, Save & verify and the startup check report that AIControls can't load the cluster CA bundle. If the API server answers the key request with HTTP 401 or 403, check that the system:service-account-issuer-discovery ClusterRoleBinding still includes the group system:serviceaccounts.
If AIControls runs outside the cluster, it can't reach the in-cluster issuer. Use the cluster's public issuer, or add an oidcProviders entry with a reachable jwksURL for the same issuer.
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.
Trusting AI Gateway routing headers
Some deployments route both MCP tool-call traffic and LLM traffic through the same AIControls authorization check, fronted by an Envoy-based AI gateway (for example Envoy AI Gateway, sometimes called "Agent Router") that inspects request bodies and adds routing headers before forwarding to AIControls. That gateway can set x-ai-eg-model, x-ai-eg-backend-schema, and x-ai-eg-backend-name on any request it has identified as a model/LLM call.
By default, AIControls ignores these headers and evaluates every request through this authorization path as an ordinary MCP tool call. Set identity.trustAIGatewayHeaders: true to have AIControls trust them instead — once trusted, a request carrying x-ai-eg-model is classified as LLM traffic (populating object.llm.* and leaving object.mcp.* empty) rather than an MCP tool call.
# values.yaml
identity:
trustAIGatewayHeaders: true
These are ordinary HTTP headers on the incoming request. AIControls has no way to tell the difference between a value your gateway's own routing logic set and one an agent simply included in its own request, and it treats any non-empty x-ai-eg-model as proof the request is LLM traffic.
That means the required guarantee is not "every route has these headers set or overwritten" — overwriting an MCP route's header with any non-empty value, even a placeholder, still misclassifies it as LLM traffic. Your gateway's trusted routing logic must instead, on every route, before AIControls ever sees the request:
- Strip any caller-supplied value for
x-ai-eg-model,x-ai-eg-backend-schema, andx-ai-eg-backend-nameunconditionally. - Emit
x-ai-eg-modelonly on a request it has positively classified as LLM traffic.
An ordinary MCP tool-call route must reach AIControls with this header entirely absent, not present-but-overwritten. If any route — for example, one your gateway's model-routing logic never touches — can let a caller-supplied x-ai-eg-model through unstripped, a caller with a valid agent token could set it on an ordinary MCP request itself. AIControls would then treat the call as LLM traffic: object.mcp.* fields would read empty, and any MCP-scoped policy that should have applied to that call would be silently skipped.
Leave this at its default (false) unless you have verified this guarantee holds for your specific gateway configuration.
| Setting | Default | Purpose |
|---|---|---|
identity.trustAIGatewayHeaders | false | Trust x-ai-eg-model/x-ai-eg-backend-schema/x-ai-eg-backend-name as an authoritative signal that a request is LLM traffic, rather than an MCP tool call. |
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.
Private identity endpoints
AIControls checks the resolved destination addresses when it probes a cluster OIDC issuer, discovers MCP or OAuth metadata, fetches a dynamically registered client's JWKS, or exchanges tokens with a Cross-App Access endpoint. Private, loopback, link-local, and other internal destinations are blocked unless you explicitly allow their IP addresses or CIDR ranges in proxy.ssrfAllowlist. The one exception is the cluster's own API server as the cluster OIDC issuer, described in In-cluster API server issuer.
For a private issuer or JWKS host, add the narrowest required addresses to your Helm values.yaml. For example:
proxy:
ssrfAllowlist:
- "10.20.30.40/32" # Private issuer or JWKS service
- "fd00:1234::40/128" # Its IPv6 address, if used
Apply the updated values with your usual Helm upgrade and restart the AIControls pods so they load the configuration. The allowlist accepts IP addresses and CIDRs, not hostnames. If an endpoint resolves to several addresses, every otherwise-blocked address must be permitted. DNS resolution is checked again when a new connection is opened, and the client connects directly to a checked address.
If the first checked address is unreachable, the client reserves time to try the remaining checked addresses within its connection timeout. This also permits fallback from an unreachable IPv6 address to a reachable IPv4 address.
These metadata, probe, JWKS, and token requests do not follow HTTP redirects or use HTTP_PROXY/HTTPS_PROXY environment settings. Configure the final endpoint URL and allow direct connectivity from the AIControls pods. An allowlist entry permits the destination address; HTTPS certificate validation still applies.
If an endpoint fails with outbound host resolves to a blocked address, check its DNS results from the AIControls pod's network and add only the intended service addresses. A redirect response requires correcting the configured endpoint URL; adding more CIDRs does not enable redirects.
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