Skip to main content

SPIFFE/SPIRE for AI Agents: Cryptographic Workload Identity Instead of Long-Lived Service Account Tokens

9 min readDora NodaDora Noda
Share
On this page

Ask a self-hosted platform's audit log who triggered last night's rollback, and there's a decent chance the honest answer is "a token, not a who." If the AI deploy-agent and the on-call engineer both authenticate with the same Vault-issued service credential — because that's what got wired up first and nobody circled back — the log can tell you what API call happened, not which actor made it. That's the gap SPIFFE and SPIRE are built to close: not "rotate the credential faster," but "give the agent a cryptographic identity a human never holds."


The ad hoc baseline most self-hosted platforms already run

Before getting to SPIRE, it's worth being precise about what it's replacing, because the replacement only makes sense next to the thing it fixes. A typical self-hosted PaaS wires up something like this: a Vault AppRole or Kubernetes-auth role backs a credential, an External Secrets Operator (ESO) polls Vault on an interval and syncs the result into a Kubernetes Secret, and that Secret gets mounted into whatever pod needs it — the deploy-agent's container included.

Two things are true about that setup, and both matter:

  • The token itself is often long-lived. ESO's poll interval controls how fast a new secret propagates, not how long the underlying credential is valid. A Vault AppRole secret ID or a static database credential with a 30-90 day TTL is common, and ESO faithfully re-syncs the same long-lived value until it's manually rotated.
  • The credential doesn't encode who's using it. If the agent's container and a human's kubectl exec session both end up authenticating with a credential pulled from the same Vault path, the platform's API server sees identical bearer material either way. There's no cryptographic distinction between "the agent called /rollback" and "an engineer with kubectl access called /rollback" — you're relying on side-channel evidence (which pod's logs show the call) to reconstruct that after the fact, not a signed claim that was true at request time.

This isn't a strawman — it's the pattern most self-hosted platforms duct-tape together because Vault + ESO is a genuinely reasonable first step up from a static Secret checked into a values file. It's just not the same claim as "this specific workload, and only this workload, made this specific request."


What a SPIFFE ID actually is, and how to issue an agent one that isn't a human's

SPIFFE (Secure Production Identity Framework For Everyone) defines the identity format; SPIRE (the SPIFFE Runtime Environment) is the CNCF-graduated implementation that issues it. The unit of identity is an SVID — a SPIFFE Verifiable Identity Document, either an X.509 certificate or a signed JWT, bound to a URI like spiffe://bex.internal/agent/deploy-bot. The document doesn't just assert "this process is who it claims to be" — it asserts "this specific workload, attested against this specific node and namespace, has this identity, and the assertion expires in about an hour."

That last part is the mechanical fix for the long-lived-token problem above. SPIRE issues SVIDs with short TTLs — one hour is a common default — and rotates them automatically: the SPIRE Agent fetches an SVID, and at roughly 50% of its TTL it generates a fresh keypair, sends a certificate signing request to the SPIRE Server, and receives a new SVID before the old one expires. No cron job, no manual rotation script, no expiry cliff where a forgotten renewal takes down a workload.

The two SVID formats aren't interchangeable, and picking the wrong one is a common early mistake. X.509-SVIDs carry the identity in a certificate and are what you want for mutual TLS between long-running services — an agent's container and bex's API server both present X.509-SVIDs and authenticate each other during the TLS handshake itself, before any application data moves. JWT-SVIDs carry the identity in a signed token instead, and fit the cases X.509 can't reach cleanly: a single HTTP call through a gateway that doesn't terminate mTLS, or an identity that needs to be forwarded on to a downstream service that isn't part of the same mesh. An agent calling bex's deploy API directly wants X.509; the same agent's request getting proxied through an API gateway to a second internal service is a more natural fit for a JWT-SVID riding in the Authorization header.

Getting an agent a distinct identity from a human is a registration-entry problem, not a rotation problem, and it's where the concrete difference from the Vault/ESO baseline shows up. Registering the deploy-agent and a human operator as separate SPIRE entries looks like this:

bash
# The AI deploy-agent: scoped to its own namespace + service account
spire-server entry create \
  -spiffeID spiffe://bex.internal/agent/deploy-bot \
  -parentID spiffe://bex.internal/ns/spire/sa/spire-agent \
  -selector k8s:ns:bex-agents \
  -selector k8s:sa:deploy-bot
 
# A human operator, authenticating through a completely separate entry
spire-server entry create \
  -spiffeID spiffe://bex.internal/human/oncall \
  -parentID spiffe://bex.internal/ns/spire/sa/spire-agent \
  -selector k8s:ns:bex-humans \
  -selector k8s:sa:oncall-session

The -selector flags are what make this attestable rather than declared: SPIRE's Kubernetes workload attestor plugin inspects the actual pod a process is running in — its namespace, its service account, its labels — and only issues the SVID if those attributes match the registration entry. A process can't just claim to be deploy-bot; it has to be a workload running under that service account in that namespace before the SPIRE Agent will hand it that identity over the Workload API (a Unix domain socket local to the node, not a network-reachable credential store).

That gives an authorizer on bex's own API server something a shared Vault token never could: a SPIFFE ID it can branch policy on. A request presenting spiffe://bex.internal/agent/deploy-bot can be scoped to /rollback and /deploy and nothing else; a request presenting spiffe://bex.internal/human/oncall can carry a broader human-facing scope. Revoking the agent's access is deleting one registration entry — it doesn't touch the human's, because they were never the same credential to begin with.

Node attestation is the other half of the trust chain: before a SPIRE Agent can vouch for workloads on a node, the node itself has to prove what it is, typically via the k8s_psat plugin, which validates a projected service account token against the live Kubernetes API — the same "reject anything not currently live" property that makes bound service account tokens harder to replay than static ones.


Federation across a Cluster-API-managed fleet

A single-cluster registration entry is straightforward; a multi-region fleet managed through Cluster API is where SPIRE's answer gets more involved. Each CAPI-managed workload cluster can run its own SPIRE Server with its own trust domain — say spiffe://region-us.bex.internal and spiffe://region-eu.bex.internal — and SPIRE Federation lets those trust domains recognize each other's SVIDs without merging into one server.

Mechanically, federation works through bundle endpoints: each trust domain exposes an HTTPS endpoint serving its SPIFFE bundle (the public key material needed to verify SVIDs from that domain), and each SPIRE Server is configured with the federated trust domains it should trust, pulling their bundles on a refresh interval. A deploy-agent identity issued in region-us can then present its SVID to an API server in region-eu and be verified without either region's SPIRE Server ever holding the other's private signing key.

For a Cluster-API operator, that maps onto the same boundary CAPI already draws: one trust domain per managed cluster (or per management-cluster/workload-cluster pair), federated at the edges where an agent genuinely needs to act across regions — not a single flat trust domain spanning the whole fleet, which would turn one compromised cluster's SPIRE Server into a skeleton key for all of them.


The honest cost, because this isn't free

None of the above is a weekend project, and treating it like one is how these rollouts stall six months in. Reporting on production SPIRE deployments puts the shape of the cost plainly: SPIRE is 8-10 interdependent components (server, agents, node and workload attestor plugins, upstream CA integration, federation bundle management, and more) to assemble, configure, and keep patched — not a single binary you drop into a cluster. Deployment timelines commonly run six months for a simple environment and 12-24 months for a complex, multi-cluster one, and organizations running it at scale typically dedicate one to three platform engineers to just keeping SPIRE itself healthy: version upgrades, plugin compatibility, scaling the server as registration entries grow, federation bundle rotation.

It's also worth being precise about what SPIRE doesn't solve, because the scope of the win matters as much as the win itself. SPIRE issues identity for workloads it can attest — processes running on nodes and in namespaces it controls. It has nothing to say about a SaaS API credential your agent also needs, a legacy application that can't run a SPIRE Agent sidecar, or a CI/CD pipeline's identity in a hosted runner you don't operate. Those still need the Vault/ESO pattern from the first section, or something like it — SPIRE replaces the workload-to-platform leg of the credential story, not the whole map.

That's the actual trade a self-hosted operator is weighing: the Vault/ESO baseline is running today, costs an afternoon to extend to a new workload, and produces a credential that's short-lived but not distinguishable by actor. SPIRE produces a cryptographically distinct, per-workload identity with automatic rotation and revocation — at the cost of a multi-month rollout and a standing operational commitment.

For a fleet small enough that "which token did this" is answerable by grepping pod logs, that trade probably isn't worth making yet. For a fleet where an AI agent's deploy/rollback authority needs to be provably separate from every human who can also reach that API — and independently revocable the moment that agent looks compromised — it's the difference between an audit log that infers who acted and one that can prove it.


Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with AI agents as first-class operators instead of a human-shaped API they borrow credentials to reach. Star the repo on GitHub and see what an agent-native identity layer looks like when it's designed in from the start.

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