Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. 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.
  2. Long-lived. Static credentials live for hours to years. A leaked identity should die in minutes, making revocation unnecessary.
  3. 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.

PropertyX.509-SVIDJWT-SVID
Carries ID inURI SAN (spiffe://...)sub claim
VerificationX.509 chain to trust bundleJWS signature via JWKS bundle
Default SPIRE TTL1 hour5 minutes
Primary usemTLS transport identityAuthorization, cross-boundary calls
Revocation modelExpiry (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

ApproachIdentity anchorCredential lifetimeKey rotationPortability
SPIRE X.509-SVIDWorkload attestation~1 hAutomaticAny runtime with a UDS
Vault PKI (see Vault)Vault auth (AppRole, k8s JWT)Configurable (min 1s)Client-drivenAnywhere Vault is reachable
Cloud IAM for SAs (IRSA / Workload Identity)Cloud metadata + JWT1 h tokensAutomaticOne cloud only
Mesh-embedded CA (Istio, Linkerd)Platform attestation via mesh CRI~24 hAutomaticAnywhere 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

  1. Trust domain reuse across environments – staging and prod sharing one domain means one attestation compromise forges prod identities.
  2. Selector forgery via weak attestation – pod-label selectors are attacker-influenced wherever users can create pods; kernel-fact selectors (UID, SA, namespace) are not.
  3. 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.
  4. Prefix-matching policies – spiffe://td string-prefix checks accept spiffe://td-evil; compare trust domains as parsed components.
  5. 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.

References