Home › Security › Certificates & PKI
algorithm medium

Workload identity (SPIFFE-style)

Problem A service, job or pipeline step needs credentials to call other systems, but baking a long-lived key into its image or config means one leak is a lasting breach and rotation is a manual chore.

A local agent works out which workload is calling, then hands it a short-lived signed identity that it renews automatically; no secret is shipped in advance.

attest O(entries x selectors) per request; one signature per issuance; renewal once per workload per half lifetime
spiffespiresvidattestationshort-lived-credentialssecretless
Overview

A workload cannot prove who it is with a secret it was given at deploy time, because that secret can leak and never expires. Workload identity inverts this: the platform vouches for the machine, a local agent observes the calling process, and an issuer signs a short-lived identity document for the matching entry. Microservices, CI pipelines, batch and ML jobs and multi-cloud callers all start from the same question: "who is this process, right now?"

Key facts

Use when

  • Services across clusters or clouds need a verifiable name for policy, without per-service API keys (payments, logistics, healthcare).
  • CI/CD must deploy to a cloud without stored cloud keys: GitHub Actions hands each job an OIDC token that the cloud exchanges for a short-lived access token.
  • Batch, analytics or training jobs reach a data store as themselves, not as a shared account.

Avoid when

  • One process talks to one backend and a managed secret store already rotates the key: secrets management is simpler.
  • Callers are people: use OIDC login or passkeys.

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.
  1. 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 as spiffe://td/spire/agent/k8s_psat/<cluster>/<node UID>.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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, expirationSeconds default 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_target for 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 unix attestor'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:pay alone) 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

namelangrepoinstallverdictlicencehealthas_ofnotes
SPIREGospiffe/spirepre-built releases on the repo's releases page————server and agent; node and workload attestor plugins
go-spiffeGospiffe/go-spiffego get github.com/spiffe/go-spiffe/v2————Workload API client library
rust-spiffeRustmaxlambrecht/rust-spiffecargo 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 attestors unix, 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

Builds on

Used by

Systems (10):

Alternatives and related