Skip to main content

Whose Fingerprint Is on This Deploy? Separate Kubernetes Identities for Humans, CI, and AI Agents

11 min readDora NodaDora Noda
Share
On this page

It is 2 a.m., a production deployment just went out that nobody on the on-call rotation remembers approving, and the only fingerprint in the audit log is system:serviceaccount:prod:deployer — the same shared token the CI pipeline, the midnight auto-scaler script, and the new AI coding agent all use. Was it a teammate running a manual retry, a GitHub Actions job firing on a stale branch, or an MCP agent that misread "redeploy the staging service" as "redeploy every service"? With one identity for three very different deployers, the log cannot tell you, and neither can anyone else.

This is no longer a hypothetical failure mode. ClearVector's 2026 Identity Intelligence Report found that 91% of identities active in production are non-human, and Sophos's State of Identity Security 2026 attributes 41% of successful identity breaches to weak non-human-identity management — static credentials, orphaned service accounts, shared keys. The CNCF's March 2026 cloud-native agentic standards now treat AI agents as first-class platform consumers alongside developers and CI, with a concrete test for when an agent needs its own workload identity: when it acts autonomously, persists beyond a session, or operates outside explicit session scope. A deploy agent clears that bar on all three counts.

The short version: give each deployer its own credential, its own approval path, and its own audit username — humans get short-lived SSO-backed access, CI gets per-job OIDC tokens with nothing stored, and MCP agents get OAuth 2.1 authorization in front of a dedicated per-agent ServiceAccount with a least-privilege Role. The whole design fits in one table, the git-push workflow survives untouched, and "the agent did it" becomes a queryable fact instead of a shrug.

The three-lane identity matrix​

Here is the complete design up front. Every row is a deployer, every column is a decision you can no longer share between them:

Human operatorCI pipeline (GitHub Actions)MCP agent
CredentialOIDC SSO → short-lived kubeconfig (hours)GitHub OIDC id-token → per-job cluster token (minutes)MCP OAuth 2.1 grant → dedicated per-agent ServiceAccount token (minutes)
ScopeNamespaced roles + break-glass ClusterRole by groupExactly the namespaces and verbs the deploy step needsRead-mostly Role; write verbs only via approved tool subset
Approval pathPeer review + SSO group membershipBranch protection + environment reviewersHuman-in-the-loop approval for every write tool call
Audit usernameuser@example.com (OIDC subject)system:serviceaccount:ci:github-<repo> (per-repo)system:serviceaccount:agents:agent-<name> (per-agent)
Stored secretNone (refresh via SSO)None (id-token: write, no kubeconfig in secrets)None long-lived (broker mints tokens per session)

Three properties make this table work. First, nothing long-lived is stored anywhere: every credential expires in minutes to hours, so there is no KUBECONFIG_BASE64 secret to leak and no API key to rotate. Second, every lane has a distinct audit username, so the answer to "who deployed this?" is a log query, not an interrogation. Third, the lanes converge on one deploy path — the same manifests, the same pipeline — so the boundary adds accountability without adding a second way to ship.

The rest of this post walks each lane, then the audit trail that ties them together.

Lane 1: humans keep SSO and break-glass​

Humans are the lane most teams already have half right. The pattern is OIDC-backed authentication to the API server — the cluster trusts your SSO provider, kubectl refreshes a short-lived token, and RBAC binds groups, not individuals, to roles:

yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: web-team-deployer
  namespace: prod-web
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: edit
subjects:
- apiGroup: rbac.authorization.k8s.io
  kind: Group
  name: sso:team-web

Two details matter for the three-lane design. First, human access should be the only lane with a break-glass path — an emergency ClusterRole binding that is heavily logged, time-boxed, and reviewed afterward. CI and agents never get break-glass; if the pipeline cannot do something, that is a bug in the pipeline's Role, not a reason to hand automation the master key. Second, humans own the approval steps the other two lanes escalate to: environment reviewers on the CI side, write-tool approvers on the agent side. Keeping both approval queues where humans already work (the PR, the deploy dashboard) is what keeps the boundary usable instead of ornamental.

Lane 2: CI trades stored kubeconfigs for per-job OIDC tokens​

The CI lane is the easiest win and the one to do first, because the shared deploy token almost always lives in CI secrets today. GitHub Actions has supported OIDC federation for years, and the 2026 consensus is blunt: long-lived secrets in repository settings are gone, and every cloud or cluster action authenticates with short-lived OIDC tokens. The workflow declares id-token: write, mints an identity token per job, and exchanges it for a cluster-scoped token that dies with the job:

yaml
permissions:
  contents: read
  id-token: write   # mint OIDC identity; no kubeconfig in secrets
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Exchange OIDC token for cluster access
        uses: azure/k8s-set-context@v4  # or your platform's broker
        with:
          method: service-account
          k8s-url: ${{ vars.CLUSTER_URL }}
      - run: kubectl apply -f manifests/

On the cluster side, the exchange lands on a per-repository ServiceAccount — system:serviceaccount:ci:github-<repo> — bound to a Role that covers exactly the namespaces and verbs that repo's deploys need. Per-repo matters: when every repository shares one ci-deployer identity, a compromised workflow in a docs repo can redeploy the payments API, and the audit log shows the same username for both. Scope the identity to the blast radius you can tolerate.

One more CI habit pays off in the audit trail: stamp every applied manifest with the commit SHA and workflow run URL as annotations. The Kubernetes audit event tells you which identity applied the change; the annotation tells you which code it carried. Together they close the loop from "who" to "what" without opening a second system.

Lane 3: MCP agents get their own identity, tools, and leash​

The agent lane is the new one, and it needs two identities, not one: the identity the user or orchestrator presents to the MCP server, and the identity the agent workload presents to the cluster. Confusing them is how agents end up holding the user's full permissions — the exact over-privilege pattern behind this year's agent-security warnings.

The outer layer is MCP authorization, which the 2026-07-28 MCP specification rebuilt around OAuth 2.1 and OpenID Connect: servers publish protected-resource metadata, clients validate the token issuer on every authorization response, tokens are audience-bound, and — critically — token passthrough is explicitly forbidden. An MCP server must never forward a token it receives to a downstream API. For a deploy agent, that rule draws the line the whole design depends on: the user's OAuth grant authorizes talking to the agent, and the agent reaches the cluster with its own credential, never the user's.

The inner layer is a dedicated Kubernetes ServiceAccount per agent, bound to a namespaced, least-privilege Role — the pattern the CNCF's kagent project (a CNCF Sandbox project since May 2025) treats as a core blast-radius control. Concretely:

  • One ServiceAccount per agent (agent-ops-assistant, not agent-shared), so revoking one agent's access never touches another.
  • Read verbs first: get and list on pods, deployments, and events in the agent's namespaces, so diagnosis works by default.
  • No Secrets, no pods/exec, no impersonation in the base Role. Each of those is a privilege-escalation step disguised as a convenience, and none belongs in an identity whose prompts can be influenced by untrusted cluster content.
  • Short-lived tokens (minutes), minted per session by a broker, never a static Secret mounted into the agent pod.
  • Write tools subset per agent: even when the MCP server exposes more tools, each agent sees only the tools its job needs, and every write tool call requires human approval before the cluster call is made.

The approval gate deserves emphasis because it is where agent deployments most often go wrong. A read-only troubleshooting agent can run freely; an agent with a rollout-restart tool must present the exact call — action, target, reason — to a human approver and execute only on approval. Log the prompt, the tool call, and the approval record alongside the cluster audit event, so an incident review can replay not just what the agent did but what it was thinking. And treat everything the agent reads back from the cluster as untrusted input: log text can carry injected instructions, and a least-privilege Role is what keeps an injected "also delete the namespace" from being executable.

For teams further along, SPIFFE/SPIRE is emerging as the substrate underneath this lane: short-lived X.509 or JWT SVIDs issued per agent workload via node attestation, with mTLS between agents. Dapr Agents reached 1.0 in March 2026 with exactly that model. You do not need SPIRE on day one — a broker-minted ServiceAccount token plus a tight Role gets you most of the value — but design the agent lane so the credential can be swapped for an SVID later without changing the RBAC shape.

The audit trail: making "the agent did it" a query​

Separate credentials are only half the design; the other half is proving, after the fact, which lane acted. Kubernetes audit logging already records the username on every API request, so with three distinct usernames the 2 a.m. question becomes a query instead of a mystery:

bash
# Who touched the prod-web deployments in the last hour?
jq 'select(.objectRef.namespace == "prod-web"
        and (.verb == "update" or .verb == "patch" or .verb == "create")) |
      {time: .requestReceivedTimestamp,
       user: .user.username,
       verb: .verb,
       object: .objectRef.name}' /var/log/kubernetes/audit.log

With the three-lane design, .user.username comes back as one of three unmistakable shapes: user@example.com for a human SSO session, system:serviceaccount:ci:github-<repo> for a pipeline run, or system:serviceaccount:agents:agent-<name> for an agent session. Set the audit policy to log request metadata (or full bodies for deploy verbs) on the namespaces that matter, ship the log somewhere queryable, and keep agent-side records — session ID, tool calls, approval receipts — keyed to the same request window so you can join "the cluster saw agent X patch deployment Y" with "agent X's session shows tool call Z approved by human W."

Two Kubernetes features sharpen this further. Impersonation headers let a broker act as a named user while the audit event records both the impersonator and the impersonated identity — useful when an approval flow needs to show "agent X acting on behalf of human W." And constrained impersonation (alpha since v1.35) adds a second authorization check so the impersonator can be limited to specific actions, closing the hole where standard impersonation hands over all of the impersonated user's rights. Neither replaces separate identities; both make the trail between them explicit.

Keeping git-push usable​

The objection to all of this is always the same: three identities, three approval paths, and the simple git push that made the platform pleasant is now a bureaucratic obstacle course. The answer is that the lanes converge below the user experience. There is still one deploy path — push to a branch, PR, review, merge, pipeline applies the manifests — and the identity boundary lives inside that path, not alongside it:

  • Humans push and review exactly as before; their SSO identity is what the PR and the approval record carry.
  • CI still applies manifests on merge; it just authenticates with a per-job OIDC token instead of a stored kubeconfig, invisibly to the developer.
  • Agents participate as PR authors, not direct pushers: the agent drafts the manifest change, opens the PR with its reasoning attached, and a human review plus the standard pipeline does the rest. The rare agent allowed to act directly (an incident-response agent restarting a wedged rollout) does so through an approved write tool whose audit event names the agent, the approver, and the reason.

Nothing about this requires developers to learn a new workflow. The boundary is visible exactly where it should be — in the audit log and the approval queue — and invisible everywhere else.

An adoption checklist for small teams​

You do not need to build all three lanes in a weekend. The sequence that pays off fastest, in order:

  1. Split CI off the shared token. Add id-token: write, stand up per-repo ServiceAccounts with least-privilege Roles, delete the stored kubeconfig. This alone removes the most-leaked credential class.
  2. Give humans SSO and take away the shared kubeconfig. Short-lived tokens via your identity provider, group bindings, and a logged break-glass role.
  3. Stand up the agent lane before the first agent needs it. MCP OAuth 2.1 on the server, one ServiceAccount per agent, read-only Roles, write tools behind human approval.
  4. Turn on audit logging for deploy verbs in tenant namespaces and verify all three usernames appear as expected.
  5. Expire the shared token. The best test of the design is deleting the credential it replaces.

Each step is independently shippable, each one narrows the blast radius of the credentials that remain, and step five is the one that turns "we have three lanes" from an aspiration into a fact.


Running your platform on machines you own means the identity boundary is yours to draw — no managed console decides which token can touch production. Bex.co is the open-source, AI-native Render alternative: push a git repo, get a running HTTPS service on your own hardware, with first-class room for the agents that operate alongside you. Star the repo on GitHub or deploy your first app today.

Related articles

Give your agents a chain backend

Autonomous agents hit RPC endpoints very differently than people do. See what bex router handles on their behalf.

Read the agents guide