Skip to main content
Workloads in a cluster that runs SPIRE or another SPIFFE issuer already hold a short lived identity document, an SVID. The SPIFFE bridge takes that document, checks it against a trust bundle you configure, and maps the SPIFFE ID to a device or agent you registered. The workload never needs a pre shared key from you. The bridge is a plain function over the pieces theAuth already has. It does not open sockets and it does not create principals: a SPIFFE ID that nobody mapped is refused.

What it accepts

Rules come from the SPIFFE specifications: SPIFFE ID, X509-SVID, JWT-SVID and trust domain and bundle. The checks fail closed. X.509-SVID refusals (X509-SVID sections 2, 3.1, 4.1, 4.3, 5):
  • no URI SAN, or more than one URI SAN (code no_uri_san, multiple_uri_sans)
  • a URI that is not a valid SPIFFE ID, or has no path (invalid_spiffe_id)
  • a trust domain without a configured bundle (untrusted_domain), or a chain that does not end at a CA of that same trust domain (untrusted_chain)
  • expired or not yet valid, with 30 seconds of skew (expired, not_yet_valid)
  • a leaf with the CA flag set (ca_leaf)
  • key usage missing, not critical, without digitalSignature, or with keyCertSign or cRLSign (key_usage)
  • extended key usage that does not allow clientAuth, an unknown critical extension, or a non critical SAN on an empty Subject (key_usage, extension)
  • an intermediate that is not a CA, is expired, or breaks a pathLenConstraint (untrusted_chain)
JWT-SVID refusals (JWT-SVID sections 2 to 4): an algorithm outside RS, ES and PS 256/384/512, headers other than alg, kid and typ, typ other than JWT or JOSE, no kid, a missing or wrong aud, a missing or past exp, exp more than maxJwtTtlSeconds (default 3600) ahead, a bad signature, an unknown kid.

Set it up

Map a SPIFFE ID to a device. Only the owner side can do this, enrollment refuses it, otherwise whoever holds a bulk token could claim any identity:
Then authenticate. For an X.509-SVID pass the chain from the TLS handshake your server verified (leaf first, PEM or DER). Never read it from a header the client can set: the bridge cannot tell whether the peer holds the private key, only your TLS layer can.
bridge.authenticateRequest(request) does the same for a Request: it reads the certificate from the clientCertificate callback you pass to createSpiffeBridge, and falls back to Authorization: Bearer <JWT-SVID>. Two things to know about issueAgentToken. It rotates the agent token, so the previous token stops working: use one agent per workload identity, not one agent for a replicated deployment. And a device has no bearer token, it acts through device.authorize, so there is nothing to bind.

Trust bundles and rotation

A bundle is a JWK Set (use: "x509-svid" entries hold a CA certificate in x5c, use: "jwt-svid" entries hold a public key and kid), with spiffe_sequence and spiffe_refresh_hint. Give a trust domain any mix of bundle (object or JSON text), roots (PEM) and endpoint. With an endpoint the bundle is refreshed lazily, on the first call after spiffe_refresh_hint has passed. There is no timer, so it works on serverless runtimes. Rules:
  • the URL must be https, redirects are refused, the body is capped (1 MiB) and the request has a timeout (5 seconds)
  • polling is never faster than minRefreshSeconds (60), whatever the hint says
  • a spiffe_sequence that goes backwards, or disappears from a bundle that had one, is refused (rollback)
  • an empty keys array is accepted, it means the trust domain revoked everything
  • a failed refresh keeps the last good bundle until maxStaleSeconds (86400), then that trust domain fails closed
A plain fetch works for an https_web bundle endpoint (public web PKI). An https_spiffe endpoint authenticates itself with an SVID, so pass your own fetch that does mutual TLS against your bootstrap bundle.

HTTP routes

Set devices.spiffe and the handler serves the bridge. The same trust bundle options as createSpiffeBridge apply, plus the pieces only an HTTP service needs:
GET /devices/:id already lists the SPIFFE credentials, and the dashboard Devices page shows them with their trust domain and lets you link an id. Where the SVID of /device/spiffe/verify comes from is a security decision, so the route is strict about it:
  • A JWT-SVID is read from Authorization: Bearer.
  • An X.509 chain is read only from the clientCertificate callback, or from the one header you name in trustedProxyHeader. A chain in the request body is never accepted on this route, because a certificate is public and carries no proof that the sender holds its key. Only the TLS handshake proves that.
  • trustedProxyHeader is off by default. Turn it on only when a reverse proxy verifies the handshake and overwrites that header on every request, and when the service cannot be reached around the proxy. nginx $ssl_client_escaped_cert (URL encoded PEM) and plain PEM both work. The header is capped at 32 KB.
  • The two admin routes that take a chain in the body are for operators. The token a chain produces is bound to the certificate (cnf x5t#S256), so a leaked chain alone cannot use it.
The admin routes only act on principals you own. A device or agent that belongs to another user is answered as not found by POST /devices/:id/spiffe and POST /devices/spiffe/token, and POST /devices/spiffe/verify reports it as unknown_workload with the same text as an id nobody mapped, so the answer does not tell you whether the mapping exists. A mapped agent that is revoked or expired is hidden the same way from anyone but its owner. The Python and Go clients wrap all four routes: client.devices.spiffe_verify, spiffe_token, link_spiffe and client.device.spiffe_verify in Python, Devices.SPIFFEVerify, SPIFFEToken, LinkSPIFFEID and Device.SPIFFEVerify in Go. See the READMEs in sdks/python and sdks/go. Everything else fails closed: an unconfigured bridge answers 404, an unmapped or inactive principal is refused, and a storage error counts as over the rate limit. POST /devices/spiffe/token refuses a device (devices have no bearer token) and a JWT-SVID (no key to bind). Every SPIFFE route is limited per client IP (default 60 per 60 seconds, answer 429 with Retry-After). The rateLimit() plugin also knows these paths under the key deviceSpiffe.

Audit

Every outcome goes to onOutcome of createSpiffeBridge (success, each failure code, unknown_workload, principal_inactive), and devices.spiffe writes it to the audit log as device.spiffe.authenticate or device.spiffe.verify. A row has the SPIFFE ID, kind, code, verified and the x5t#S256 thumbprint of an X.509 leaf. It never holds the certificate or the token. An audit row belongs to an agent, and a device is an agent underneath, so a row exists only when the outcome can be tied to a device or an agent:
  • a success, or a refusal after the SVID verified (unknown workload excepted, nobody is mapped), is attributed to the mapped principal
  • a failed SVID (expired, bad signature, wrong audience) is attributed to the device that owns the SPIFFE ID the document names, with verified: false. That ID was read before the signature check, so it is a claim: someone can present a bad certificate that names your robot and write a denied row on it. Treat those rows as signals, and rely on the rate limit for volume.
  • a document with no readable SPIFFE ID, or an ID nobody owns, has no row. Use onOutcome if you need to count those.

Verify an SVID from the shell

The first three run offline with the same code the bridge uses and exit 1 when the SVID is refused.

Test SVIDs with openssl

The test suite makes real certificates with the openssl CLI, so you can reproduce a case by hand. A root and an SVID, with the extensions the specification requires:
-noenc is -nodes on OpenSSL 1.1. Change one line of leaf.cnf to see a refusal: CA:TRUE gives ca_leaf, dropping keyUsage gives key_usage, a second URI: gives multiple_uri_sans.

Limits

  • Not implemented: name constraints, and RSASSA-PSS or Ed25519 certificate signatures. A chain that uses them is refused, never accepted.
  • Outside devices.spiffe the bridge is a plain function: call it from your own handler or gateway.
  • A JWT-SVID is a bearer credential, keep it on TLS and use one audience per service. The bridge accepts a token with several audiences if yours is one of them.
  • Audit rows exist only for outcomes that map to a device or an agent, see Audit above. Actions that follow (device.authorize, agent calls) are audited as usual.
  • The spiffe credential of a device does not go through device.authenticate, only through the bridge.
The Go twin lives in the iot/spiffe package of theauth-go and replays the same test vectors.
Last modified on October 19, 2026