Preview — not for production reliance
Documentation

Docs For relying parties

mTLS with go-spiffe

Authenticate both ends of an mTLS connection through the directory with go-spiffe, following the reference SPIRE demo.

The repository's demo/ runs the whole flow on one machine: a bank agent calls a relying party ("Plaid") over mTLS, and each side authenticates the other through the directory. Neither side holds the other's SPIRE bundle, so nothing but the directory-approved bundle can vouch for a peer's chain. This page explains the pattern; the code is in demo/relying-party.

The shape of it

Both ends get their own X.509-SVID from their local SPIRE agent through the Workload API, and replace standard chain building with one hook that runs the verification steps for the peer:

go
src, err := workloadapi.NewX509Source(ctx, workloadapi.WithClientOptions(workloadapi.WithAddr(cfg.WorkloadAPI)))

// Server: a client certificate is required, and Hook decides whether to accept it.
server := &tls.Config{
	MinVersion:            tls.VersionTLS13,
	ClientAuth:            tls.RequireAnyClientCert,
	GetCertificate:        tlsconfig.GetCertificate(src),
	VerifyPeerCertificate: verifier.Hook(),
}

// Client: hostname verification is replaced by the same hook, so the server is authenticated by its SPIFFE ID
// through the directory exactly as the server authenticates the client.
client := &tls.Config{
	MinVersion:            tls.VersionTLS13,
	InsecureSkipVerify:    true, // replaced by VerifyPeerCertificate, never skipped
	GetClientCertificate:  tlsconfig.GetClientCertificate(src),
	VerifyPeerCertificate: verifier.Hook(),
}

InsecureSkipVerify only turns off Go's built-in hostname and chain verification. The hook still verifies the chain, against the directory-approved bundle, and the handshake fails if it returns an error.

What the hook does

For every handshake, in order, stopping at the first failure:

  1. Parse the peer's certificates and read the leaf's SPIFFE ID with x509svid.IDFromCert. It only selects which trust domain to look up.
  2. Look up the expected organization ID for that trust domain in local configuration. No entry: refuse (peer_trust_domain_not_configured).
  3. GET /v1/resolve?trust_domain=. A 404 refuses (not_in_directory); a non-active status refuses (binding_not_active:<status>).
  4. Verify the assertion against the pinned JWKS file, which is read once at startup and never fetched per request.
  5. Fetch bundle_url, at most 64 KiB, and check its digest.
  6. Validate the peer's chain against that bundle with x509svid.Verify(certs, bundle.X509Bundle(), x509svid.WithTime(now)).
  7. Check a static local allow-list for the peer's SPIFFE ID (not_in_local_allow_list).

The relying party's configuration holds exactly the things that must not come from the peer:

json
{
  "directory_url": "https://svid.io",
  "issuer": "https://svid.io",
  "jwks_file": "/etc/art/jwks.json",
  "peers": { "agents.bank.example.com": { "organization_id": "org_…" } },
  "allow": { "spiffe://agents.bank.example.com/payments/agent-1": ["GET /v1/accounts", "POST /v1/transfers"] },
  "workload_api": "unix:///run/spire/sockets/agent.sock"
}

Re-check on every request

A TLS connection can outlive an assertion. The demo server therefore re-runs the check inside every HTTP handler, then checks the allow-list for that specific operation:

go
id, err := verifier.Verify(r.Context(), rawPeerCerts, false)
if err != nil {
	writeJSON(w, http.StatusForbidden, map[string]any{"ok": false, "error": "peer no longer verified", "reason": rp.ReasonOf(err)})
	return
}
if !verifier.Allowed(id, op) {
	writeJSON(w, http.StatusForbidden, map[string]any{"ok": false, "error": "operation not permitted by local policy"})
	return
}

A validated assertion is kept only while it still verifies (until exp plus skew), so the re-check is cheap, and a withdrawn binding stops working within 330 seconds even on a long-lived connection.

What the demo proves

bash demo/run.sh starts three SPIRE servers (pinned 1.15.3), the directory, signer and review Workers under wrangler dev, onboards two organizations through the real API, and runs these cases:

Case Expected
A bank agent calls the relying party 200, and the agent logs peer_verified for the server (mutual)
An allowed peer calls an operation outside its allow-list 403: directory trust is not authorization
The relying party expects a different organization Refused: wrong_organization
A trust domain that was never registered Refused: not_in_directory
A workload outside the binding's scope (/admin/agent-9 vs [/payments]) Refused: path_out_of_scope
A proxy alters the bundle bytes Refused: bundle_digest_mismatch
The relying party's clock is 400 s ahead Refused: expired
A real SVID chain with the wrong private key The TLS handshake fails
No client certificate, assertion sent as a bearer header The TLS handshake fails: a lookup alone grants nothing
The binding is withdrawn while an assertion is retained Accepted at most until exp + 30 s, then binding_not_active:withdrawn

The demo stubs two things and says so: DNS answers for the claim challenge and the scanner's federation-endpoint fetch. Everything else is the real code. It uses .example.com names because the directory refuses special-use names such as .example, and it runs over plain http, so its relying parties set allow_http_issuer, which is for the local demo only.