Workload identity & SPIFFE/SPIRE
Kill static tokens — attest workloads and federate cloud IAM.
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.
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.
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.
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:paymentsspire-server entry show -spiffeID spiffe://acme.internal/ns/prod/sa/payments
Entry ID : a1b2c3d4-...SPIFFE ID : spiffe://acme.internal/ns/prod/sa/paymentsSelectors : k8s:ns:prod, k8s:sa:payments# registration entry defines who can become this identity
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=$JWTvault token lookup -format=json | jq ".data.meta,.data.ttl"
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.
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.
vault write auth/jwt/config \jwks_url=https://spire.internal/.well-known/jwks.json \bound_issuer=spiffe://acme.internalvault 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
Success! Data written to: auth/jwt/configSuccess! 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.
kubectl exec -n spire spire-server-0 -- /opt/spire/bin/spire-server federation showspire-server entry show -spiffeID spiffe://acme.internal/ns/prod/sa/payments
Trust Domain spiffe://partner.corpBundle Status federatedEntry 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.
spire-agent healthcheck -socketPath /run/spire/agent.sockkubectl logs -n spire daemonset/spire-agent --tail=3
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.
vault read auth/jwt/role/gha-paymentsvault write auth/jwt/login role=gha-payments jwt=$OIDC_TOKENvault token lookup -format=json | jq ".data.ttl,.data.meta"
bound_audiences https://vault.acme.examplebound_claims map[repository:acme/payments ref:refs/heads/main]user_claims map[pipeline_id:sub]token hvs.CAESI...token_duration 10mttl: 600meta: {"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.