Deep dive
How it works
Parts:
- Trust domain and CA — the root of trust; every identity it signs is qualified by its name (SPIRE server, or a cloud IAM service).
- Node agent — runs on each machine, holds a node identity, listens on a local Unix socket, caches documents.
- Node attestor — plugin pair that turns platform evidence into a node identity (
aws_iid,k8s_psat). - Workload attestor — agent-side plugin that turns a caller into selectors (
unix:uid:1000,k8s:ns:pay,k8s:sa:api). - Registration entries — rows
selectors -> SPIFFE ID (+ TTL); all selectors of an entry must hold. - Workload API — streaming endpoint that delivers documents and trust bundles.
- Trust bundle — the public keys of a trust domain; federated domains exchange them.
- Node attestation. The agent starts with no secret and sends evidence: the AWS instance identity document from the metadata service (
aws_iid), or a signed projected service-account token (k8s_psat). The server verifies it with the platform and mints an agent ID such asspiffe://td/spire/agent/k8s_psat/<cluster>/<node UID>. - Workload attestation. A process connects to the agent's socket. The Workload Endpoint spec forbids requiring client credentials (the workload has none yet): the agent identifies the caller out of band, by kernel introspection or orchestrator query.
- Entry selection. The agent compares the caller's selectors with the registered entries. SPIRE entries list selectors that "must be satisfied"; the spec lets the API return the whole authorised set.
- Issuance. The server signs an X.509 or JWT document for the entry's ID. SPIRE defaults: X.509 TTL 1 h, JWT TTL 5 min unless an entry sets one.
- Rotation. The agent re-issues before expiry and pushes the new set down the open stream. Each message carries the full set, so absence means removal; the spec advises jitter against fleet-wide reloads.
- Bundles. The same stream carries the bundles that validate peers. Federation: each domain publishes its bundle at a bundle endpoint URL and the other fetches it.
cloud/k8s ---evidence---> [node attestor] ---> agent ID
process --unix socket--> [AGENT] --pid/uid/pod--> selectors
| match entry (all selectors present)
v
registration entries ---> [SERVER/CA] --sign--> SVID (short TTL)
^ |
rotate at TTL/2 ---+---- stream: SVIDs + bundles -+--> workload
A SPIFFE ID path has non-empty segments from [a-zA-Z0-9.-_] and no trailing slash; its meaning is yours (for example /ns/pay/sa/api). A JWT document must carry sub (the ID), aud and exp; the spec recommends one audience and discourages broad ones such as production. Peer-side checks of X.509 documents are in mutual TLS.
Cloud equivalents. Each trades the pieces above for platform services:
- Kubernetes projected service-account tokens: per-pod, audience-bound,
expirationSecondsdefault 1 h, minimum 10 min. - EKS Pod Identity: an association maps an IAM role to a service account in a namespace; a node agent hands temporary credentials to the SDKs, so no OIDC provider per cluster is needed.
- GCP Workload Identity Federation: an external token is exchanged via OAuth 2.0 token exchange, with attribute mappings deciding the principal.
- GitHub Actions: each job gets an OIDC token; the cloud validates its claims and returns a short-lived token valid for the job.
Code
A toy agent: simulated callers, entries with selector sets, most selectors wins, a tie between different IDs is refused (chosen here; not SPIRE's API behaviour), rotation at 50 % (SPIRE's default) and a trust-domain check. No signatures.
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq)]
struct Svid { id: String, not_before: u64, not_after: u64, serial: u64 }
#[derive(Debug, PartialEq)]
enum Fail { UnknownPid, NoSelectors, NoMatch, Ambiguous, WrongDomain }
struct Caller { uid: u32, ns: &'static str, sa: &'static str } // what kernel/kubelet reported for this pid
struct Entry { id: String, selectors: HashSet<String>, ttl: u64 }
struct Agent { domain: String, entries: Vec<Entry>, callers: HashMap<u32, Caller>, next_serial: u64 }
fn in_domain(domain: &str, id: &str) -> bool { // spiffe://<domain>/<non-empty path>
id.strip_prefix("spiffe://").and_then(|r| r.split_once('/'))
.map_or(false, |(host, path)| host == domain && !path.is_empty())
}
impl Agent {
fn register(&mut self, id: &str, sel: &[&str], ttl: u64) -> Result<(), Fail> {
if !in_domain(&self.domain, id) { return Err(Fail::WrongDomain); }
if sel.is_empty() { return Err(Fail::NoSelectors); } // would match every caller
let selectors = sel.iter().map(|s| s.to_string()).collect();
self.entries.push(Entry { id: id.into(), selectors, ttl });
Ok(())
}
fn attest_issue(&mut self, pid: u32, now: u64) -> Result<Svid, Fail> {
let c = self.callers.get(&pid).ok_or(Fail::UnknownPid)?;
let have: HashSet<String> = [format!("unix:uid:{}", c.uid), format!("k8s:ns:{}", c.ns),
format!("k8s:sa:{}", c.sa)].into();
// an entry applies only if ALL its selectors are present; most selectors wins
let hits: Vec<&Entry> = self.entries.iter().filter(|e| e.selectors.is_subset(&have)).collect();
let top = hits.iter().map(|e| e.selectors.len()).max().ok_or(Fail::NoMatch)?;
let mut best = hits.into_iter().filter(|e| e.selectors.len() == top);
let e = best.next().unwrap();
if best.any(|o| o.id != e.id) { return Err(Fail::Ambiguous); } // tie between different IDs: refuse
let (id, ttl) = (e.id.clone(), e.ttl);
self.next_serial += 1;
Ok(Svid { id, not_before: now, not_after: now + ttl, serial: self.next_serial })
}
fn verify(&self, s: &Svid, now: u64) -> bool {
in_domain(&self.domain, &s.id) && s.not_before <= now && now < s.not_after
}
}
fn rotation_due(s: &Svid, now: u64) -> bool { now >= s.not_before + (s.not_after - s.not_before) / 2 }
fn main() {
let c = |uid, ns, sa| Caller { uid, ns, sa };
let callers = HashMap::from([(10, c(1000, "pay", "api")), (11, c(1000, "pay", "batch")),
(12, c(1000, "dev", "api")), (13, c(0, "pay", "api"))]);
let mut a = Agent { domain: "example.org".into(), entries: vec![], callers, next_serial: 0 };
a.register("spiffe://example.org/pay/api", &["k8s:ns:pay", "k8s:sa:api"], 3600).unwrap();
a.register("spiffe://example.org/pay/api-u1000", &["k8s:ns:pay", "k8s:sa:api", "unix:uid:1000"], 3600).unwrap();
a.register("spiffe://example.org/pay/batch-a", &["k8s:ns:pay", "k8s:sa:batch"], 3600).unwrap();
a.register("spiffe://example.org/pay/batch-b", &["k8s:sa:batch", "k8s:ns:pay"], 3600).unwrap();
assert_eq!(a.register("spiffe://evil.org/x", &["unix:uid:1"], 60), Err(Fail::WrongDomain));
assert_eq!(a.register("spiffe://example.org/x", &[], 60), Err(Fail::NoSelectors));
assert_eq!(a.attest_issue(10, 1000).unwrap().id, "spiffe://example.org/pay/api-u1000"); // most specific
assert_eq!(a.attest_issue(12, 1000), Err(Fail::NoMatch)); // ns:dev, no entry whose selectors all hold
assert_eq!(a.attest_issue(11, 1000), Err(Fail::Ambiguous)); // two IDs, same selector set
assert_eq!(a.attest_issue(99, 1000), Err(Fail::UnknownPid));
let old = a.attest_issue(13, 1000).unwrap(); // uid 0: only the two-selector entry applies
assert_eq!(old.id, "spiffe://example.org/pay/api");
assert!(!rotation_due(&old, 2799) && rotation_due(&old, 2800)); // 50 % of 3600 s
let new = a.attest_issue(13, 2800).unwrap();
assert!(new.not_before < old.not_after && new.serial != old.serial); // overlap, not a gap
assert!(a.verify(&old, 2800) && a.verify(&new, 2800));
assert!(!a.verify(&old, 4600) && a.verify(&new, 4600));
let foreign = Svid { id: "spiffe://evil.org/pay/api".into(), ..new.clone() };
assert!(!a.verify(&foreign, 3000));
let n = 200_000u64; let t = std::time::Instant::now(); let mut acc = 0;
for i in 0..n { acc += a.attest_issue(10, i).unwrap().serial & 1; }
println!("{} ns/attest+issue ({acc})", t.elapsed().as_nanos() as u64 / n);
}
Complexity and constants
Attestation compares the caller's selectors with every entry: O(entries x selectors). The toy measured about 260-280 ns per attest plus issue (Apple Silicon Mac, release build, no cryptography). The real cost is the signature, not measured here: 10,000 workloads on a 1 h TTL renewed at half life need 10,000 / 1,800 s, about 5.6 signatures per second at the CA (arithmetic). Other drivers: socket round trips (keep one stream open), attestor lookups against the kubelet or API server, and bundle bytes repeated in every pushed message.
Efficiency
Cost: one signature per issuance, one local stream per workload, one node attestation at start. Bound: CA signing rate (workloads x renewals).
Levers:
- Stream and cache at the agent — documents pushed once per renewal instead of fetched per use (Workload API spec) — always.
- Node-level credential agent — EKS Pod Identity assumes roles once per node, not once per pod's SDK (AWS docs) — pod-heavy nodes.
- TTL tuning — longer TTL means fewer signatures, shorter means a smaller leak window; SPIRE offers
availability_targetfor X.509 only (SPIRE agent docs) — when server outages must be survivable. - Jittered pushes — avoid synchronised reloads (Workload API spec) — large fleets.
Naive → standard → state of the art:
- Static API keys in config, rotated by hand · no cost per call, no expiry, wide blast radius
- Per-request token exchange with a central service · one network round trip and one signature per call
- Local agent with attested identity, short TTL, pushed renewal · one signature per workload per half TTL, nothing per call
Implementation notes
- Prefer stable selectors (namespace, service account, image); the
unixattestor's path and hash selectors need the agent to read/proc/<pid>/exe(enough rights, or leave them off). - Node evidence must be verified with the platform, never self-asserted.
- Swap documents atomically for new connections; keep the old one for open ones.
- Reject entries with no selectors: they would match everyone.
Pitfalls
- A broad entry (
k8s:ns:payalone) gives every pod in the namespace one identity. - Pid reuse: never cache an attestation result by pid beyond one connection.
- A JWT is a bearer token: use one audience and a short
exp, or it is replayable at other services. - Containers on one node share a kernel; the AWS docs say Pod Identity does not make them a security boundary.
- Rotation without overlap breaks long-lived connections at expiry; renew at half life.
- Colliding trust domain names cannot federate, and there is no registry to prevent it (SPIFFE ID spec): choose a name you own.
Variants and alternatives
In production
- SPIRE: the SPIFFE Workload API with node and workload attestor plugins.
- Kubernetes: audience-bound service-account tokens projected into pods.
- Amazon EKS Pod Identity: temporary IAM credentials for pods.
- Google Cloud Workload Identity Federation: external tokens exchanged for Google access.
- GitHub Actions: one OIDC token per job for cloud trust.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| SPIRE | Go | spiffe/spire | pre-built releases on the repo's releases page | — | — | — | — | server and agent; node and workload attestor plugins |
| go-spiffe | Go | spiffe/go-spiffe | go get github.com/spiffe/go-spiffe/v2 | — | — | — | — | Workload API client library |
| rust-spiffe | Rust | maxlambrecht/rust-spiffe | cargo add spiffe | — | — | — | — | Workload API client, rustls integration |
Sources
- SPIFFE, The SPIFFE Identity and Verifiable Identity Document. spec
- SPIFFE, The Workload API; The Workload Endpoint. API · endpoint
- SPIFFE, The JWT SVID. spec
- SPIFFE, Trust Domain and Bundle; Federation. bundle · federation
- SPIRE docs, SPIRE Server, SPIRE Agent. server · agent
- SPIRE docs, node attestors
k8s_psat,aws_iid; workload attestorsunix,k8s. k8s_psat · aws_iid · unix · k8s - Kubernetes docs, Projected Volumes. docs
- AWS docs, EKS Pod Identity. docs
- Google Cloud docs, Workload Identity Federation. docs
- GitHub docs, OpenID Connect. docs