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