Back to Blog
Phase 2 2026-02-07

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 TokenmTLS
Secret on the wireYes, every requestNo — TLS uses ephemeral session keys
Stolen credential lifetimeForever (until rotated)Until cert expires (session keys are ephemeral)
Identity verificationString comparisonCryptographic proof (CA signature chain)
RevocationEdit YAML, restartRemove cert, restart (Phase 3 adds CRL/OCSP)
Agent-side complexityNone (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.