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 withkeyCertSignorcRLSign(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)
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: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_sequencethat goes backwards, or disappears from a bundle that had one, is refused (rollback) - an empty
keysarray 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
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
Setdevices.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
clientCertificatecallback, or from the one header you name intrustedProxyHeader. 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. trustedProxyHeaderis 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 (
cnfx5t#S256), so a leaked chain alone cannot use it.
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 toonOutcome 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
onOutcomeif you need to count those.
Verify an SVID from the shell
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.spiffethe 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
spiffecredential of a device does not go throughdevice.authenticate, only through the bridge.
iot/spiffe package of theauth-go and replays the same test vectors.