CoursesAdvanced secrets managementWorkload identity & SPIFFE/SPIRE

Workload identity & SPIFFE/SPIRE

Kill static tokens — attest workloads and federate cloud IAM.

Expert35 min · lesson 9 of 15

Every pod in your cluster carries the same static API key (application programming interface key, a long password one service uses to prove who it is) sitting in its config. Somebody pasted it in by hand. Nobody has rotated it since. Anyone who can open a shell inside the container can read it. The way out is workload identity: a name a process can prove it owns using cryptography, which no human ever typed into a Secret. SPIFFE (Secure Production Identity Framework For Everyone) is the standard that says what those names look like. SPIRE (the SPIFFE Runtime Environment) is the thing that issues them, handing each process a short-lived SVID (SPIFFE Verifiable Identity Document) once the platform has vouched for it. Once workloads carry one of those, Vault and cloud IAM (Identity and Access Management, the permission system your cloud provider runs) can trust them directly, and the static bootstrap credential has nothing left to do.

In plain terms
SPIFFE is the passport standard. SPIRE is the passport office that checks your birth certificate before it prints anything. You never mail yourself a passport. You show up, prove who you are to the issuer, and walk out with a document that names you precisely and expires soon.

The name, and the document that proves it

A SPIFFE ID is a name written as a URI (Uniform Resource Identifier, the same shape as a web address), like spiffe://acme.internal/ns/prod/sa/payments. Read it left to right and it tells you the trust domain, then the namespace, then the service account. SPIRE hands that name out as an SVID, which is either an X.509 certificate (the certificate format your browser already trusts) or a JWT (JSON Web Token, a signed bundle of claims). Lifetimes are measured in minutes and rotation happens on its own. The workload does nothing to earn the document. The SPIRE agent running on the node inspects what the process actually is, using selectors, and only then hands anything over.

What you end up with is one name that means the same thing on Kubernetes, on a plain virtual machine, and across three different clouds. Policy stops being about an IP address or a namespace and starts being about a SPIFFE ID, which survives a reschedule, a scale-up and a cloud migration, because nothing in the name depends on where the process happens to run. None of this deletes the trust problem. It moves it. You stop trusting a key file that lives forever and start trusting the attestation rules plus the server that enforces them, and the blast radius of any single leak drops from months to minutes.

How a workload gets an identity with SPIRE
1agent attests node
to the SPIRE server
2workload asks agent
over the local API
3agent checks selectors
SA, UID, image digest
4issue SVID
short-lived cert/JWT, auto-rotated
Identity is attested from platform facts. Nothing secret is ever handed to the workload.

Turning that identity into real access

Two things get much easier once workloads hold SVIDs. First, mutual TLS everywhere. TLS (Transport Layer Security) is the encryption behind https, and mutual means both ends prove who they are instead of only the server. Services present their SVIDs to each other, which is exactly what a service mesh has been doing under the hood all along. Second, federation. A SPIFFE JWT-SVID can be traded for cloud credentials through OIDC federation (OpenID Connect, the protocol that lets one system trust tokens another system signed), using AWS IAM Roles Anywhere or GCP Workload Identity Federation, or traded for a Vault token through Vault's JWT auth method.

The static access key and the shipped Vault token both stop existing, because the workload now gets scoped access with nothing but its attested identity. Design your SPIFFE ID hierarchy so it mirrors the way you already carve up policy. The path spiffe://acme.internal/ns/prod/sa/payments should line up with a Vault role and an AWS IAM role binding without anyone having to translate between three naming schemes in their head.

terminal
spire-server entry create \
-spiffeID spiffe://acme.internal/ns/prod/sa/payments \
-parentID spiffe://acme.internal/k8s/node/pool-a \
-selector k8s:ns:prod -selector k8s:sa:payments
spire-server entry show -spiffeID spiffe://acme.internal/ns/prod/sa/payments
output
Entry ID : a1b2c3d4-...
SPIFFE ID : spiffe://acme.internal/ns/prod/sa/payments
Selectors : k8s:ns:prod, k8s:sa:payments
# registration entry defines who can become this identity
terminal
JWT=$(spire-agent api fetch jwt -audience vault -socketPath /run/spire/agent.sock | awk "/token/{print \$2}")
vault write auth/jwt/login role=payments jwt=$JWT
vault token lookup -format=json | jq ".data.meta,.data.ttl"
output
token hvs.CAE... ttl 20m
{"role":"payments"}
1200
# scoped by SPIFFE-ID -> role binding; no static secret in the flow

Selectors are the whole security boundary

The document is only the visible part. What decides whether a process gets one is a set of rules SPIRE calls selectors, checked against facts the agent can observe for itself: which service account the pod runs as, which namespace it lives in, which image digest it started from. Pick weak facts and the whole scheme quietly degrades into a shared password with better branding.

Attest on a shared service account or on a mutable image tag, and a hostile pod scheduled onto the same node can ask for another workload's identity and be given it. Treat registration entries the way you treat firewall rules. Every broad selector widens the set of processes allowed to become a given name. A selector on k8s:ns:prod by itself vouches for every pod in prod, including one an attacker scheduled thirty seconds ago. Pair namespace with service account, and add the image digest for anything high-value.

SPIRE selector strength
weak (avoid)
shared default SA
half the namespace qualifies
mutable image tag
attacker pulls same tag
strong (prefer)
k8s:sa + k8s:ns
specific workload identity
image digest selector
immutable attestation fact
Selectors do for SPIRE what auth bindings do for Vault. Loose selectors are loose policy.

Do you actually need SPIRE?

SPIRE is real weight to carry. A server to run, an agent on every node, and an attestation policy somebody has to own and keep current. It earns that at a particular scale: mixed workloads (Kubernetes, plus virtual machines, plus more than one cloud) that all have to answer the same question about who they are, or a zero-trust mandate where every service-to-service call must be mutually authenticated.

One Kubernetes cluster talking to one Vault does not need any of it. Native Kubernetes auth already gives you attested identity with a fraction of the machinery. Reach for SPIFFE and SPIRE when identity has to travel between platforms, and not a day earlier. The tipping point in practice is the second cluster, or the first virtual machine parked beside Kubernetes, rather than day one of a greenfield build.

Wiring it into Vault and the mesh

Map SPIFFE IDs to Vault roles with JWT auth bound_claims on sub. Service meshes such as Istio, which speak SPIFFE-compatible IDs, consume X.509 SVIDs directly for mTLS. Cloud federation maps the same JWT to an IAM role. Configure the OIDC provider once and the same identity chain works for Vault, AWS and GCP, instead of three separate bootstrap secrets living in three separate places.

Rotate the SPIRE trust bundle the way you would rotate a certificate authority root. Publish both, confirm every agent has picked up the new one, then retire the old signing key. Get that order wrong and a stale bundle on your nodes is an outage. Lose the signing key and someone can impersonate every workload in the mesh.

terminal
vault write auth/jwt/config \
jwks_url=https://spire.internal/.well-known/jwks.json \
bound_issuer=spiffe://acme.internal
vault write auth/jwt/role/payments \
role_type=jwt bound_subject="spiffe://acme.internal/ns/prod/sa/payments" \
user_claim=sub policies=payments-read ttl=20m
output
Success! Data written to: auth/jwt/config
Success! Data written to: auth/jwt/role/payments
# only this exact SPIFFE ID can exchange for a Vault token

Node attestation, and identity across trust domains

Before an agent can hand out workload SVIDs, it has to prove to the SPIRE server that it belongs to this cluster. That happens with join tokens, or with the node's own identity on AWS, GCP or Kubernetes. A compromised join token is secret-zero for an entire node pool, so treat it like one. Rotate them per pool, and never commit one to Terraform in the clear.

Federation between SPIRE trust domains lets an acquired company or a walled-off region exchange SVIDs with you without anybody merging PKI (Public Key Infrastructure, the hierarchy of certificates and the keys that sign them) roots on day one. Federated SPIFFE IDs map to Vault roles exactly like local ones do. The binding is on the ID string itself, not on which trust domain signed it.

terminal
kubectl exec -n spire spire-server-0 -- /opt/spire/bin/spire-server federation show
spire-server entry show -spiffeID spiffe://acme.internal/ns/prod/sa/payments
output
Trust Domain spiffe://partner.corp
Bundle Status federated
Entry ID a1b2c3d4-...
Selectors k8s:ns:prod, k8s:sa:payments

Watching SPIRE comes down to alerting on three things: attestation that failed, SVIDs that expired without a replacement, and agents that dropped their connection. It is the same SLO (Service Level Objective, the reliability number you promise) mindset you already apply to Vault token renewal. A node whose agent cannot reach the server will quietly run out of valid identities, and the mTLS failures that follow get filed as network flakiness for a week before anyone looks at the agent.

When both the mesh and Vault read SPIFFE IDs, keep the string format identical in registration entries and in Vault JWT role bindings. One typo in a SPIFFE path takes that workload offline, and it will not surface as a tidy auth error you can grep for.

Upgrade SPIRE agents the way you would upgrade a certificate authority. Roll one node pool at a time, watch SVIDs refresh on that pool before you touch the next, and keep the previous trust bundle valid until every agent has caught up. Push the whole fleet in a single step and you will discover which of your services never learned to retry after an identity failure.

terminal
spire-agent healthcheck -socketPath /run/spire/agent.sock
kubectl logs -n spire daemonset/spire-agent --tail=3
output
Agent is healthy.
2026-07-24T10:00:01Z INF SVID refreshed spiffe_id=spiffe://acme.internal/ns/prod/sa/payments
# agent logs show rotation — alert if refresh stops

Workload identity federation and SPIFFE-style attestation close the hole that Vault's AppRole auth method leaves open on modern platforms. The workload shows up holding a platform JWT or an SVID. Vault checks the issuer, the audience and the claims, then returns a short-lived token. Nobody has to deliver a SecretID to the machine first.

When you federate CI (continuous integration, the pipelines that build and ship your code) from GitHub Actions or GitLab into Vault or cloud IAM, scope by repository, environment and branch. If any fork of your repo can mint the token, you have built a public credential printer and labelled it federation.

Try this

In a lab, read back a JWT auth role's bound claims, then log in with a projected service account token or a CI OIDC token and look at what claims came through with it.

terminal
vault read auth/jwt/role/gha-payments
vault write auth/jwt/login role=gha-payments jwt=$OIDC_TOKEN
vault token lookup -format=json | jq ".data.ttl,.data.meta"
output
bound_audiences https://vault.acme.example
bound_claims map[repository:acme/payments ref:refs/heads/main]
user_claims map[pipeline_id:sub]
token hvs.CAESI...
token_duration 10m
ttl: 600
meta: {"pipeline_id":"repo:acme/payments:ref:refs/heads/main"}
# fork PR with different repository claim -> login denied

Takeaway

Workload identity swaps a shipped secret for a name the platform can prove. The bound claims and the selectors behind them are where the security actually lives. Write them loosely and you have rebuilt the shared password, this time wearing a JWT costume.

Next: take one CI pipeline off its static Vault token and move it onto JWT/OIDC auth, bound to a specific repository and a specific environment.

Your selectors decide who is allowed to be whom
An SVID proves only as much as the selector that gated it. If that selector is a shared service account, or an image tag anyone can overwrite, a hostile pod landing on the same node can request another workload's identity and SPIRE will hand it over without complaint. Gate on facts an attacker cannot forge: an exact container image digest, one service account inside one namespace. Then review the SPIRE server's trust bundle and its registration entries as carefully as you review any other root of trust. A pull request that widens a selector deserves the same scrutiny as one that opens a firewall port.
Quick check
01What does SPIRE actually hand a workload?
Correct — That document is the proof of identity, and it expires fast.
Incorrect — SPIRE issues documents that expire. It does not store fixed keys.
Incorrect — The root and the trust bundle are separate from per-workload SVIDs.
Incorrect — That is a different integration pattern altogether.
02Why does a loose attestation selector let one pod wear another pod's identity?
Incorrect — mTLS is a separate concern. Loose selectors widen who passes attestation.
Correct — Broad selectors collapse distinct workloads into one identity.
Incorrect — Vault's JWT auth trusts a SPIRE issuer once you configure it.
Incorrect — Lifetime is configured separately. Selector breadth decides who gets an SVID at all.
03When is plain Kubernetes auth usually enough, with no SPIRE at all?
Incorrect — Plenty of clusters run happily on Kubernetes auth alone.
Incorrect — Which cloud you run on is not the deciding factor.
Correct — SPIRE starts paying for itself when identity has to cross platforms.
Incorrect — Choosing an auth method is independent of static versus dynamic secrets.

Related