SPIFFE/SPIRE: Cryptographic Workload Identity
Kubernetes gives every pod a ServiceAccount JWT, but that token is a bearer credential: copy it out of a compromised pod and you are that service until it expires. Network ACLs authenticate even less – they authenticate an IP address. The gap is a first-class answer to “which workload is on the other end of this connection, proven cryptographically and refreshed constantly,” independent of network location, cloud, or scheduler. SPIFFE (Secure Production Identity Framework For Everyone) is the identity standard; SPIRE (SPIFFE Runtime Environment) is the reference implementation that issues the credentials. This page covers the ID format, the attestation chain that makes it trustworthy, rotation, federation, and where SPIFFE beats – and loses to – the alternatives covered in mTLS, Zero Trust and Vault.
Why Ambient Credentials Fail
Three properties make k8s service accounts, cloud metadata credentials, and source-IP rules fragile as identity:
- Exfiltrable. A bearer token read from disk, env, or memory is fully usable by the thief. Identity must be bound to a private key that never leaves the process boundary.
- Long-lived. Static credentials live for hours to years. A leaked identity should die in minutes, making revocation unnecessary.
- Location- or platform-bound. An AWS role authenticates only inside AWS; an IP rule dies under NAT or container rescheduling. Identity must survive migration across clusters, clouds, and VMs.
SPIFFE’s answer: a stable URI naming a workload, delivered as a short-lived X.509 certificate whose private key is generated by (and stays inside) the workload’s agent delivery path.
The SPIFFE ID Format
A SPIFFE ID is an RFC 3986 URI: spiffe://trust-domain/path. The trust domain
is the trust root (e.g., one cluster or one environment); the path is the
workload. The spec is strict, and validators should be too:
spiffe://bank-prod.example.org/ns/payments/sa/ledger
| | |
scheme trust domain path (convention: namespace / service account)
scheme : must be "spiffe"
trust domain : lowercase, [a-z0-9._-], no port, no userinfo,
no percent-encoding; IPv4 dotted-quad allowed, IPv6 excluded
path : optional; no query, no fragment
Trust domain names are self-registered: nothing asserts that
bank-prod.example.org belongs to your bank. Two systems that must not talk
must not share a trust domain name, because identity equivalence is string
equality on the whole URI. A validator against those rules:
import re
HOST = re.compile(r"^[a-z0-9._-]+$")
def validate(uri):
if not uri.startswith("spiffe://"):
return ["scheme must be spiffe"]
if "?" in uri or "#" in uri:
return ["query/fragment not allowed"]
auth, _, path = uri[9:].partition("/")
if ":" in auth:
return ["port not allowed (excludes bare IPv6)"]
if not HOST.match(auth):
return ["trust domain not lowercase [a-z0-9._-]"]
return []
CASES = [
"spiffe://prod.example.org/ns/payments/sa/ledger",
"spiffe://Prod.example.org/sa/ledger",
"spiffe://prod.example.org:8443/sa/ledger",
"spiffe://prod.example.org/path?q=1",
"spiffe://2001:db8::1/sa/agent",
]
for uri in CASES:
print(f"{uri:<50} -> {validate(uri) or 'ACCEPT'}")
# policy check: allow only the ledger workload of prod.example.org
peer = "spiffe://prod.example.org-evil/ns/payments/sa/ledger"
print("naive prefix match :", peer.startswith("spiffe://prod.example.org"))
auth = peer[9:].partition("/")[0]
print("trust-domain exact :", validate(peer) == [] and auth == "prod.example.org")
Output (note the prod.example.org-evil authority passes a naive
startswith policy – parse the trust domain, never prefix-match):
spiffe://prod.example.org/ns/payments/sa/ledger -> ACCEPT
spiffe://Prod.example.org/sa/ledger -> ['trust domain not lowercase [a-z0-9._-]']
spiffe://prod.example.org:8443/sa/ledger -> ['port not allowed (excludes bare IPv6)']
spiffe://prod.example.org/path?q=1 -> ['query/fragment not allowed']
spiffe://2001:db8::1/sa/agent -> ['port not allowed (excludes bare IPv6)']
naive prefix match : True
trust-domain exact : False
SVIDs: The Verifiable Credential
A SPIFFE Verifiable Identity Document (SVID) carries the SPIFFE ID in a format existing infrastructure already parses.
| Property | X.509-SVID | JWT-SVID |
|---|---|---|
| Carries ID in | URI SAN (spiffe://...) | sub claim |
| Verification | X.509 chain to trust bundle | JWS signature via JWKS bundle |
| Default SPIRE TTL | 1 hour | 5 minutes |
| Primary use | mTLS transport identity | Authorization, cross-boundary calls |
| Revocation model | Expiry (short TTL) | Expiry + audience scoping |
X.509-SVIDs are the workhorse: standard TLS handshakes validate them, so no
application changes are needed – the SAN just happens to be a URI instead of
a DNS name. JWT-SVIDs exist for hops that terminate TLS elsewhere (load
balancers, HTTP intermediaries). SPIRE defaults (default_x509_svid_ttl = 1h,
default_jwt_svid_ttl = 5m, CA TTL 24h) come straight from the SPIRE server
config reference.
The Attestation Chain
Identity is only as good as the proof binding a key to a workload. SPIRE builds it in two hops, each bootstrapped by the one above:
SPIRE Server (trust domain root, holds signing CA)
| signs the AGENT identity, not workload keys
|
[node attestation] how does the server trust this machine?
| join token (pre-distributed, single use)
| cloud provider instance identity (AWS/GCP/Azure metadata doc)
| k8s PSAT: signed projected ServiceAccount token of the agent pod
| -> agent SPIFFE ID: spiffe://<td>/spire/agent/k8s_psat/<cluster>/<node-uid>
v
SPIRE Agent (one per node, holds agent key)
| countersigns WORKLOAD keys via CSRs
|
[workload attestation] which process is on this socket?
| workload calls the Workload API over a Unix domain socket
| agent reads kernel peer credentials (PID, UID, cgroups)
| maps process -> selectors: k8s:ns, k8s:sa, k8s:pod-uid,
| k8s:pod-label, k8s:node-name ...
v
Workload (key generated locally, never crosses the socket
as a secret; SVIDs arrive signed)
Node attestation answers “is this machine enrolled?” – the PSAT route is the
usual Kubernetes answer, since the kubelet-projected token embeds pod and node
claims the server can verify against the API. Workload attestation answers
“which workload on this node is asking?” – the agent attests from kernel
facts (peer PID on the UDS, cgroup membership, pod metadata via kubelet),
never from anything the workload self-reports. Registration entries on the
server pair a parent ID (the agent) with a selector set and the SPIFFE ID to
mint; a workload’s selectors must match an entry exactly for an SVID to
appear. Trusting pod labels as selectors means anyone who can create pods
with spiffe.io/role: payments labels can impersonate payments – prefer
ServiceAccount + namespace, or pod UID.
Rotation
With a 1-hour default TTL, every workload silently re-keys every hour: the agent rotates SVIDs well before expiry and pushes updated bundles to the socket; connections in flight complete on the old chain, new handshakes use the new cert. Three operational consequences:
- Revocation collapses into expiry. “Revoke” = delete the registration entry; worst case exposure is the remaining TTL, not a CRL propagation window.
- Clock skew matters: agents and servers must agree on time (NTP is now security infrastructure), or 1-hour certs intermittently fail validation.
- Nothing long-lived exists to protect: there is no workload private key older than an hour on any disk.
Federation
Two trust domains interoperate by exchanging root bundles, not by trusting
each other’s CAs directly. Each SPIRE server exposes a bundle endpoint
(a URL serving its public trust bundle); the peer config declares the
relationship (federates_with) and periodically refreshes the bundle.
Federated SVIDs are validated by the foreign bundle, so authorization can
distinguish local from federated callers. Federation is also the seam where
misconfiguration bites: a trust domain that spans environments (one domain
for “the company”) turns a compromise of any attestation path into a
company-wide identity forgery. Shape trust domains like blast-radius
boundaries.
SPIFFE vs the Alternatives
| Approach | Identity anchor | Credential lifetime | Key rotation | Portability |
|---|---|---|---|---|
| SPIRE X.509-SVID | Workload attestation | ~1 h | Automatic | Any runtime with a UDS |
| Vault PKI (see Vault) | Vault auth (AppRole, k8s JWT) | Configurable (min 1s) | Client-driven | Anywhere Vault is reachable |
| Cloud IAM for SAs (IRSA / Workload Identity) | Cloud metadata + JWT | 1 h tokens | Automatic | One cloud only |
| Mesh-embedded CA (Istio, Linkerd) | Platform attestation via mesh CRI | ~24 h | Automatic | Anywhere the mesh runs |
The honest trade: SPIRE’s attestation is the strictest identity proof but
adds two running components and registration management. Vault PKI issues
fine certificates but its identity is only as strong as the auth method –
a stolen AppRole secret still mints certs. Mesh CAs are operationally easiest
but their attestation is platform-specific; notably Istio’s own identity is
SPIFFE-format (spiffe://cluster.local/ns/.../sa/...), and Envoy consumes
SPIRE via the xDS Secret Discovery Service (SDS), letting a sidecar-less
Envoy pull SVIDs from the node agent instead of running its own CA path.
For how these identities get enforced in policy, see
Zero Trust and service mesh;
for the TLS layer underneath, see mTLS.
Failure Modes
- Trust domain reuse across environments – staging and prod sharing one domain means one attestation compromise forges prod identities.
- Selector forgery via weak attestation – pod-label selectors are attacker-influenced wherever users can create pods; kernel-fact selectors (UID, SA, namespace) are not.
- Shared host PID namespace – the agent attests by process facts; a sidecar or attacker process in another container can masquerade if PID isolation is broken.
- Prefix-matching policies –
spiffe://tdstring-prefix checks acceptspiffe://td-evil; compare trust domains as parsed components. - Federation bundle staleness – an expired/rotated foreign bundle that fails to refresh turns into an outage with confusing TLS errors; monitor bundle refresh, don’t debug it during an incident.