mTLS with SPIFFE URIs
How iddio replaces shared secrets with cryptographic identity. Each agent gets a client certificate signed by iddio's own CA, with its name embedded as a SPIFFE URI.
The Problem with Bearer Tokens
Phase 1 of iddio authenticates agents using bearer tokens. The agent sends a token in the Authorization header, and the proxy looks it up in tokens.yaml using constant-time comparison.
This works. But the token is a shared secret that travels on every request. If an attacker intercepts it — even once — they can impersonate that agent indefinitely. The token doesn’t expire, doesn’t prove anything about the caller’s identity, and revocation means editing a YAML file and restarting the proxy.
Phase 2 replaces this with mutual TLS (mTLS). Instead of a shared secret, each agent gets a cryptographic identity — a client certificate signed by iddio’s own CA.
The mTLS Flow
The setup is three commands. The identity model is fundamentally different.
01 — Generate a CA. iddio init generates a certificate authority — a root key pair that can sign other certs. This CA is the root of trust for your entire iddio installation.
02 — Issue an Agent Certificate. iddio agent add claude-code generates a client cert signed by that CA. The agent’s name is embedded in the certificate itself as a SPIFFE URI in the SAN (Subject Alternative Name) field:
URI: spiffe://iddio.local/agent/claude-code
03 — Agent Connects via TLS. The agent’s kubeconfig references the client cert + key instead of a bearer token. When the agent connects, TLS itself does the authentication — the proxy requires a client cert, Go’s TLS library verifies it was signed by the CA, and only then does the request reach ServeHTTP. The proxy reads the SPIFFE URI from the verified cert to get the agent name.
Why SPIFFE URIs
SPIFFE (Secure Production Identity Framework for Everyone) is a standard format for encoding identity in x509 certificates. The URI has two components:
spiffe://iddio.local/agent/claude-code
└── trust domain └── workload path
- iddio.local — the trust domain (your iddio installation)
- claude-code — the workload path (the specific agent)
Iddio doesn’t use SPIRE (the runtime that manages SPIFFE identities at scale). It just borrows the URI format. The benefit: if you later adopt SPIRE in your infrastructure, the agent certs it issues use the same format, so the MTLSAuthenticator code doesn’t change.
In the multi-tenant enterprise version, the URI expands to include the org:
spiffe://iddio.local/org/acme-corp/agent/claude-code
Bearer Token vs mTLS
The comparison is stark across every dimension that matters for production security:
| Bearer Token | mTLS | |
|---|---|---|
| Secret on the wire | Yes, every request | No — TLS uses ephemeral session keys |
| Stolen credential lifetime | Forever (until rotated) | Until cert expires (session keys are ephemeral) |
| Identity verification | String comparison | Cryptographic proof (CA signature chain) |
| Revocation | Edit YAML, restart | Remove cert, restart (Phase 3 adds CRL/OCSP) |
| Agent-side complexity | None (kubectl handles tokens) | None (kubectl handles client certs) |
The Key Insight
The agent side doesn’t change at all.
kubectl already knows how to do mTLS — you just point it at a cert and key in the kubeconfig. The agent (Claude Code, Cursor, Codex, Devin) never has to implement any auth logic. It just uses the kubeconfig that iddio agent add generates.
What the Kubeconfig Looks Like
Phase 1 kubeconfigs use a bearer token. Phase 2 swaps that for client certificate paths. The agent doesn’t notice the difference.
Phase 1 — bearer token:
users:
- name: claude-code
user:
token: "iddio_tk_a1b2c3..."
Phase 2 — client cert:
users:
- name: claude-code
user:
client-certificate: ~/.iddio/agents/claude-code/client.crt
client-key: ~/.iddio/agents/claude-code/client.key
Inside MTLSAuthenticator
The authenticator implements the same Authenticator interface as the bearer token version. The proxy doesn’t know or care which one is active.
// MTLSAuthenticator extracts identity
// from a verified client certificate.
type MTLSAuthenticator struct{}
func (m *MTLSAuthenticator) Authenticate(
r *http.Request,
) (string, error) {
// TLS already verified the cert chain.
// We just read the SPIFFE URI.
certs := r.TLS.PeerCertificates
if len(certs) == 0 {
return "", ErrNoCert
}
for _, uri := range certs[0].URIs {
if uri.Scheme == "spiffe" {
// spiffe://iddio.local/agent/claude-code
// → "claude-code"
parts := strings.Split(uri.Path, "/")
return parts[len(parts)-1], nil
}
}
return "", ErrNoSPIFFE
}
Note what’s missing: there’s no token lookup, no YAML parsing, no constant-time comparison. Go’s crypto/tls already verified the certificate chain before the request reaches ServeHTTP. The authenticator just reads the name.
Try It Yourself
Iddio is open source. Deploy a zero-trust command proxy for your AI agents in minutes.