Originally published at webofmike.com on 2026-09-02. The demo repo and every command in it were run before publishing.
I built a demo where an AI agent calls a model and nothing in the path holds a certificate file. Not the agent, not the gateway, not the model upstream. Every identity is issued at runtime by SPIRE, rotates on its own, and is verified on the TLS handshake rather than read out of a header. The gateway's authorization policy is written against a SPIFFE ID. It all runs on docker compose. The code is at themsquared/agent-identity-spiffe.
Yesterday I wrote about agents that hold no LLM credential, where the gateway holds the provider key and the agent authenticates with a short-lived JWT from a local issuer. That issuer was the weak part of the design. I wrote it myself, it signed whatever it was asked to sign, and the claim it put in the token (team: research) was an assertion nobody checked. This post replaces it with the real thing.
A bearer token is the wrong primitive for agent identity
The normal way to authenticate an agent to a gateway is a bearer token: an API key, a static JWT, something the agent presents and the gateway believes. The trouble is structural. A bearer token is a thing that can be copied, so it has to be stored, and wherever it is stored is what an attacker goes for. It lands in an environment variable, gets logged by an HTTP client with verbose tracing on, and shows up in a crash dump. The compromised dependency that reads it gets everything that token can do, for as long as the token lives.
Agent workloads make this worse in two specific ways. They run a lot of third-party code by design, since the whole value proposition is calling tools and libraries on your behalf. And they are increasingly ephemeral, which means the operational pressure is toward long-lived credentials baked into an image, because nobody wants to rotate a secret across a fleet that recreates itself constantly.
SPIFFE takes a different position: stop giving workloads secrets. A workload asks the local Workload API who it is, and gets back an X.509 SVID whose issuance was conditioned on attested properties of the workload itself. There is nothing to copy into a config file because nothing was ever written down. The certificate lives minutes, and renewal is a background stream rather than an operational event.
What agentgateway v1.5.0 added
The gateway is the part that was missing. agentgateway could already terminate mTLS, but only from a static cert and key on disk, which reintroduces exactly the file you were trying to eliminate. v1.5.0 added the Workload API as an identity source, and three things follow from it:
- The gateway fetches its own SVID and trust bundle from the Workload API and rotates them automatically.
- The same identity terminates the frontend listener and authenticates outbound connections to backends.
- The peer's verified SPIFFE ID is exposed to CEL policy as
source.spiffeId.
If SPIFFE is enabled and the socket cannot be reached, the gateway fails to start rather than serving without an identity. That default is the right one and it is worth knowing before you deploy it.
The whole SPIFFE surface is three stanzas
Here is the config the demo runs. There is no certificate path in it, which is the point:
config:
spiffe:
endpoint: unix:///run/spire/sockets/agent.sock
binds:
- port: 3000
listeners:
- name: agents
protocol: HTTPS
tls:
spiffe: {}
routes:
- policies:
authorization:
rules:
- allow: 'source.spiffeId == "spiffe://example.org/ns/demo/sa/agent-alpha"'
backendAuth:
key: $UPSTREAM_API_KEY
backendTLS:
spiffe: {}
subjectAltNames:
- spiffe://example.org/ns/demo/sa/mock-llm
backends:
- host: mock-llm:8443
tls.spiffe terminates the listener with the SVID from the Workload API. Client certificates are mandatory in this mode and are verified against the trust domain bundle, so by the time the authorization rule reads source.spiffeId, it is a verified fact and not a client assertion.
backendTLS.spiffe handles the other leg. The gateway presents its own SVID to the upstream, and subjectAltNames pins which upstream identity it will accept. SVIDs carry a spiffe:// URI SAN and no DNS SAN, so ordinary hostname verification does not apply and this pin is how you narrow it.
backendAuth.key attaches the provider credential outbound. That is the part the agent never sees.
Identity is not authorization
The demo runs two agents that differ in exactly one respect: their SPIFFE ID. Both are attested by the same SPIRE agent, both hold valid SVIDs from example.org, both complete the TLS handshake with the gateway.
agent-alpha is in the CEL rule:
agent: my SPIFFE ID is spiffe://example.org/ns/demo/sa/agent-alpha
agent: provider credentials I hold: {'env': 'none', 'key_files': 'none'}
agent: HTTP 200
agent: Upstream saw client SPIFFE ID spiffe://example.org/ns/demo/sa/agentgateway and a valid provider credential.
agent-beta is not:
agent: my SPIFFE ID is spiffe://example.org/ns/demo/sa/agent-beta
agent: provider credentials I hold: {'env': 'none', 'key_files': 'none'}
agent: HTTP 403 authorization failed
Two things in the first output are worth reading carefully. The agent reports holding no provider credential, and it checks honestly: it walks its own environment for anything shaped like an API key and its own filesystem for anything shaped like key material, and finds neither. Yet it gets a 200 back. The upstream refuses to answer without the provider credential, so if a completion came back, the gateway attached one.
The second is that the upstream reports seeing sa/agentgateway, not sa/agent-alpha. The gateway authenticated to the model with its own identity over its own mTLS connection. The agent's identity terminated at the gateway, which is what you want: the blast radius of a compromised agent is a 403, not a set of upstream credentials.
The demo's whoami route makes the verification explicit. It is a directResponse whose body is built from a CEL expression:
directResponse:
status: 200
bodyExpression: '"verified client SPIFFE ID: " + source.spiffeId + "\n"'
client says: spiffe://example.org/ns/demo/sa/agent-alpha
gateway says: verified client SPIFFE ID: spiffe://example.org/ns/demo/sa/agent-alpha
The client cannot influence the second line. There is no header to set.
Watching the certificate rotate underneath a running process
The demo issues five-minute SVIDs. SPIRE renews at roughly half the lifetime, and the SPIFFE client library swaps the certificate in place without the workload restarting, reconnecting, or asking:
[ 0s] serial=ad928b7fcb3992f3 expires=15:58:34Z
[ 30s] serial=ad928b7fcb3992f3 expires=15:58:34Z
[ 60s] serial=38e419281166908e expires=16:01:02Z <-- rotated
This is the operational argument for SPIFFE, separate from the security one. Short credential lifetimes are usually a tradeoff against operational pain, because something has to redistribute the new secret. Here nothing does. The five minutes is a number in a config file that could be one minute, and no runbook changes.
Three things that cost me time
unknown field 'spiffe'. The config surface is new in v1.5.0, and an older binary rejects it with a message that lists every field it does know:
Error: config.spiffe: unknown field `spiffe`, expected one of `enableIpv6`, `dns`, `localXdsPath`, ...
I hit this because the agentgateway binary on my machine was v1.0.1 while the demo runs the v1.5.0 image. Validate against the version you will actually run:
docker run --rm -v "$PWD/config:/config:ro" \
cr.agentgateway.dev/agentgateway:v1.5.0 --validate-only -f /config/agentgateway.yaml
Environment substitution is $VAR, not %VAR%. I wrote key: "%UPSTREAM_API_KEY%". The config validated cleanly, the gateway started, mTLS worked in both directions, the CEL policy passed, and then the upstream returned:
agent: HTTP 401 {"error": {"message": "missing or invalid provider credential", "type": "invalid_request_error"}}
The gateway had faithfully sent the literal string %UPSTREAM_API_KEY% as the credential. A wrong-syntax placeholder is not a config error, it is a valid string, so this surfaces as an authentication failure several hops away from its cause. Worth noting that --validate-only resolves the variable too, so validate with it set or you get error looking key 'UPSTREAM_API_KEY' up: environment variable not found.
The SPIRE server needs a writable data directory. The SPIRE images are distroless and run as uid 1000, and only /opt/spire and /opt/spire/bin exist inside them. Mount a named volume at /opt/spire/data/server and Docker creates that path root-owned, so the server dies immediately:
level=error msg="Fatal run error" error="datastore-sql: datastore-sql: unable to open database file: no such file or directory"
The message points at the database file, but the file is missing because the directory it would live in is not writable. This demo puts SPIRE's state under /tmp, since the bootstrap script recreates the trust domain from nothing on every run.
Running it
Prerequisites are Docker with Compose v2. There is no cluster, no cloud account, and no provider key to supply.
git clone https://github.com/themsquared/agent-identity-spiffe.git
cd agent-identity-spiffe
./scripts/bootstrap.sh
./scripts/demo.sh
bootstrap.sh starts the SPIRE server, exports its trust bundle, mints a one-time join token, attests the node with it, registers one entry per workload keyed on a docker label, and brings up the gateway, the upstream, and both agents. About a minute on a warm image cache.
The claims are asserted rather than narrated:
./scripts/verify.sh
verifying...
ok no certificate or key files in the repo
ok gateway config contains no cert or key path
ok agent-alpha gets HTTP 200
ok agent-alpha holds no provider credential
ok upstream authenticated the gateway's SVID
ok agent-beta gets HTTP 403
ok gateway echoes the verified SPIFFE ID
7 passed, 0 failed
What changes on Kubernetes
The shape transfers and the moving parts get smaller. SPIRE runs as a DaemonSet, the Workload API socket arrives through a CSI driver instead of a compose volume, and the workload attestor selects on namespace and service account rather than a docker label. The gateway config changes only in the socket path.
I used the ns/<namespace>/sa/<serviceaccount> ID shape in this demo deliberately, because it is what the Kubernetes workload attestor produces. The CEL rules move across without editing, and if you are already running Istio you have most of this infrastructure deployed.
One limit to plan around: v1.5.0 accepts only SVIDs chaining to its own trust domain bundle. SPIFFE federation across trust domains is not supported, so if your agents and your models live in different trust domains, that boundary needs a different answer today.
What I would build next
The obvious extension is dropping the CEL allowlist in favor of policy that reads the SPIFFE ID path structure, so ns/research/sa/* maps to a set of models without naming every agent. The interesting one is tying the SPIFFE ID to per-identity budgets, which would compose this with the per-key spend controls from v1.5.0: an identity that cannot be forged is a much better key to bill against than an API key that can be shared.
The demo, with all four claims and the scripts that check them, is at themsquared/agent-identity-spiffe.
Frequently asked questions
How do you give an AI agent a SPIFFE identity?
The agent does not get handed anything. It connects to the local SPIFFE Workload API socket and asks who it is. SPIRE attests the calling process against a registration entry, in this demo a docker label, and returns a short-lived X.509 SVID. There is no key file to mount and no token to configure, because the identity is derived from properties of the workload rather than from a secret it holds.
How does agentgateway authenticate agents with SPIFFE?
Set config.spiffe.endpoint to the Workload API socket, then put tls.spiffe on an HTTPS listener. agentgateway v1.5.0 sources its serving certificate and trust bundle from that socket, requires client certificates, and verifies them against the trust domain bundle. The peer's verified SPIFFE ID is exposed to CEL policy as source.spiffeId, which a client cannot set, spoof, or omit.
Is a valid SPIFFE SVID enough to authorize an agent?
No. An SVID answers who the caller is, not what it may do. In this demo agent-beta presents a perfectly valid SVID from the same trust domain and receives HTTP 403, because the gateway's CEL rule allows only spiffe://example.org/ns/demo/sa/agent-alpha. Authentication and authorization stay separate: the handshake establishes identity, and policy decides access.
Does agentgateway support SPIFFE federation across trust domains?
Not in v1.5.0. The gateway accepts only SVIDs that chain to its own trust domain bundle, so cross-trust-domain federation needs a different answer at the boundary. You can narrow trust further by pinning upstream identities with backendTLS.subjectAltNames, or on the serving side with a CEL authorization rule on source.spiffeId.
Canonical version, with machine-readable markdown at https://webofmike.com/spiffe-identity-for-ai-agents/index.md: https://webofmike.com/spiffe-identity-for-ai-agents/
Top comments (2)
The bearer-token analysis matches what I keep running into with multi-agent fleets — the credential you rotate least is the one that ends up in a crash dump. What sold me reading this is the failure default: the gateway refuses to start without identity. Most systems degrade to anonymous on socket failure, which is the exact opposite of what you want.
A question on the attestation side: the demo runs on docker compose, so how are the agent workloads actually attested? On k8s the pod identity story is fairly settled, but on a compose host I'd worry about the boundary — agent processes tend to shell out to arbitrary tooling, and if attestation comes down to "a process on this host asked the Workload API", a compromised dependency in any container could potentially walk up to the same socket. Did you have to do anything explicit there, or does SPIRE's unix attestor give you more than I'm assuming?
We run a bridge that hands real browser sessions to headless agents (Lightpanda Session Bridge), so I've spent an unhealthy amount of time thinking about what a leaked session is worth — with cookies it's the whole account, not a scoped API grant. "Nothing was ever written down" is the right end state; the messy middle is that most real integrations still force you through bearer-shaped credentials somewhere.
Curious if you've considered policy at a finer grain than service account — e.g. tying
source.spiffeIdrules to the specific agent binary or per-agent namespace for a fleet where different agents get different tool access. The demo already scopes per SA, which is better than most production setups I've seen.Thanks, this is exactly the right place to push on it.
Attestation on compose. It is not "a process on this host asked the socket." The SPIRE agent runs with
pid: hostand a read-only mount of the docker socket. When a workload connects to the Workload API, the agent takes the caller's PID from the unix socket peer credentials, resolves that PID to a container ID through its cgroup, then asks the docker daemon which container that is and what labels it carries. Each registration entry selects ondocker:label:org.spiffe.workload:<name>, so the SVID a caller gets back is determined by the container it is in, not by what it claims. A compromised dependency inside agent-beta's container can hit the same socket all day and it will only ever receive agent-beta's SVID, which the gateway then 403s. The socket is shared across containers, but the answer is not.The honest limit is the one you are circling: the attestation boundary is the container. Anything running inside agent-alpha's container is agent-alpha as far as SPIRE is concerned, and that is true on Kubernetes too, where a malicious pip package gets the pod's service account identity. SPIFFE fixes "nothing to steal," not "nothing to be." What I did not do in the demo, and would do in anything real, is add a second selector so the entry requires both the label and
docker:image_id:sha256:.... Then the identity is bound to a specific image digest, and a container that carries the right label but runs different code gets no SVID at all. On Kubernetes the k8s attestor gives youk8s:container-imagefor the same purpose, so "specific agent binary" is a selector, not a wish.Finer grain than service account. Yes, and it is cheap. The SPIFFE ID path is yours to shape. The demo already issues one ID per agent (alpha and beta are separate "SAs"), so per-agent rules are the default; the fleet version is a CEL rule on path structure,
source.spiffeId.startsWith("spiffe://example.org/ns/research/"), mapped to a route with its own backend and tool set. For per-tool access, agentgateway's MCP authorization evaluates the samesource.spiffeIdalongside the tool name, so "this identity may call these tools" is one rule set rather than a second system. The image-digest selector above is what makes that rule set trustworthy, because otherwise "per agent" means "per label."The messy middle, and cookies. Agreed that a browser session is the worst credential shape in the building, because it is a bearer token with no audience and no scope. The move I would make with a bridge like yours is the same one the gateway makes with the provider key: the agent authenticates to the bridge with its SVID, the bridge holds the cookie jar, and the agent never receives a cookie. The bridge becomes the boundary where an attested identity is exchanged for a credential the legacy system insists on.
For the hops where mTLS is not available end to end, the IETF WIMSE work is the thing to lean into. The service-to-service draft defines a Workload Identity Token that is JWT-shaped but bound to a key the workload holds, with per-request proof of possession, so it survives the systems that force you through a bearer-shaped slot without becoming a bearer. The architecture draft also covers identity propagation across multiple hops, which is the multi-agent fleet problem exactly. Still drafts as far as I know, but they are the right target for the middle.