SPIFFE IDs & SVIDs

Trust domains, X.509 vs JWT identity documents.

Advanced35 min · lesson 4 of 15

Say your name at a border and nothing happens. Hand over a passport and everything does. A passport proves a name to a stranger who has never met you: the name is printed inside, an issuing country vouches for it, and the officer can check it against that country's records without phoning anybody. SPIFFE (Secure Production Identity Framework For Everyone) gives every workload those same three things. A name. A document that proves the name. An issuing authority any verifier can check for itself. A workload here means one running thing: a pod, a virtual machine, a batch job, a daemon on a server. SPIFFE is an open standard rather than a product you buy, a graduated project at the CNCF (Cloud Native Computing Foundation), and it is the vocabulary that Istio, Envoy, Vault and every policy engine in the rest of this course already speak.

What SPIFFE standardises

Before SPIFFE, every platform invented its own answer to *who is this workload*. A Kubernetes ServiceAccount token meant nothing to a virtual machine. An AWS IAM (Identity and Access Management) role meant nothing to a pod in another cluster. A hand-rolled mTLS (mutual Transport Layer Security, where both ends of a connection present a certificate instead of only the server) certificate meant nothing outside the team that issued it. Identity stopped dead at every boundary, so every boundary grew a patch: a shared secret in an environment variable, an IP allow-list, a bespoke token exchange. That patchwork is exactly what the [zero-trust model](/courses/zerotrust/zt-model/) is trying to delete.

SPIFFE fixes it by standardising two things and deliberately refusing to standardise anything else. What a workload identity is: a name, in a fixed URI (Uniform Resource Identifier, the same shape as the web address in your browser bar) format. How a workload proves it: a signed document, in one of two formats. It says nothing about how you decide which workload deserves which name, how private keys are stored, or how policy is written. That restraint is why one identity can be legible to a service mesh, a certificate authority (CA for short, the thing that signs certificates), a secrets manager and a cloud API at the same time. SPIFFE defines the identity. SPIRE (the SPIFFE Runtime Environment) is the reference implementation that issues and rotates it, and it is what every command below drives. The flags shown are current for SPIRE 1.9 and later.

Reading a SPIFFE ID

A SPIFFE ID is that URI, and it looks like this: spiffe://acme.internal/ns/prod/sa/payments. The part after spiffe:// and before the first slash is the trust domain (acme.internal), naming the authority that issues and vouches for identities, the way a passport names its issuing country. Everything after it is the path (/ns/prod/sa/payments), naming one workload inside that domain. The path here encodes a Kubernetes namespace and ServiceAccount because that is the convention SPIRE's Kubernetes integration uses, but SPIFFE treats the path as opaque text. Pick a scheme that matches how you actually reason about workloads, then stop changing it, because it will end up written into policy files across the company.

The format has hard rules worth knowing before you invent a path scheme. No query string, no fragment, no user info, no port number. Path segments cannot be empty, . or .., and they may hold only letters, digits, dots, dashes and underscores. The trust domain uses that same character set, must be lowercase, and must be no longer than 255 bytes. The whole ID must be no longer than 2048 bytes. A SPIFFE ID carries no secret at all. It is a name, so it is safe to log, paste into a ticket, and hard-code in policy. It also stays stable while the document proving it gets replaced every hour underneath, which is why you write policy against the name and never against a certificate serial number or a public key.

You do not type SPIFFE IDs into your services. You register an entry on the SPIRE server that maps selectors to the ID those selectors earn. A selector is a fact the SPIRE agent can check for itself about the process on the other end of the socket: its Kubernetes namespace, its ServiceAccount, the container image it is running. It works like a guest list on a door. Nobody is asked who they are, the door staff check facts, and the list does the rest. The entry below says that any workload the agent on this node attests as running in namespace prod under ServiceAccount payments gets spiffe://acme.internal/ns/prod/sa/payments, with one hour certificates and five minute tokens.

terminal
# parentID is the agent's own SPIFFE ID; `spire-server agent list` prints them.
# For the k8s_psat node attestor (projected service account token) the shape
# is /spire/agent/k8s_psat/<cluster name>/<node UID>.
spire-server entry create \
-parentID spiffe://acme.internal/spire/agent/k8s_psat/prod-euw1/2a1b7f9c-3e4d-4a55-9b21-0c7d6e5f4a3b \
-spiffeID spiffe://acme.internal/ns/prod/sa/payments \
-selector k8s:ns:prod \
-selector k8s:sa:payments \
-x509SVIDTTL 3600 \
-jwtSVIDTTL 300
output
Entry ID : 8c0e0a6d-3f2b-4c1a-9d77-2b5e6f0a1c33
SPIFFE ID : spiffe://acme.internal/ns/prod/sa/payments
Parent ID : spiffe://acme.internal/spire/agent/k8s_psat/prod-euw1/2a1b7f9c-3e4d-4a55-9b21-0c7d6e5f4a3b
Revision : 0
X509-SVID TTL : 3600
JWT-SVID TTL : 300
Selector : k8s:ns:prod
Selector : k8s:sa:payments

Two of those flags set your blast radius, meaning how far one compromise reaches. -parentID names the agent allowed to hand this identity out, so an agent on a node in another cluster cannot mint a prod identity even if a pod there matches the selectors perfectly. The selectors themselves are AND-ed: a workload must match every one of them, not any of them. -x509SVIDTTL and -jwtSVIDTTL set the two document lifetimes in seconds (TTL is time to live, how long a document stays valid before it has to be replaced). The older single -ttl flag still works, but it is deprecated, and SPIRE rejects the command outright if you pass it alongside the newer pair.

That pair of selectors is also broader than it looks. k8s:ns:prod plus k8s:sa:payments matches every container in every pod running under that ServiceAccount, including the debug sidecar somebody adds next month and any pod a teammate creates with the same ServiceAccount copied from an old manifest. Selectors are the precise answer to *how much of the world can become this identity*, so when the identity is valuable, add facts that are harder to fake: k8s:container-image ties it to a specific image, k8s:pod-label:app:payments to a label.

The X.509-SVID: a certificate for a workload, not a hostname

A normal web certificate answers a question about *where*: I am api.example.com, and DNS (the Domain Name System, the internet's phone book) says that is here. An X.509-SVID answers a question about *who*: I am the payments service in prod, wherever it happens to be running today. SVID stands for SPIFFE Verifiable Identity Document. Mechanically it is an ordinary X.509 certificate, the same format your browser checks on every HTTPS site, with one rule that matters above all others. The identity lives in a URI SAN (Subject Alternative Name, the extension that lists the names a certificate is valid for), and nowhere else.

A workload gets one by asking the SPIRE agent on its node over a Unix domain socket, which is a file on disk that two processes on the same machine talk through, like a service window in a wall. That socket is the Workload API. No password crosses it, and there is nothing on it to steal, because the agent inspects the calling process (its user id, its container, its pod) and returns only the SVIDs those facts entitle it to. You cannot ask for somebody else's identity. Here a workload fetches its certificate and writes it to disk so openssl can take it apart.

terminal
spire-agent api fetch x509 \
-socketPath /run/spire/sockets/agent.sock \
-write /run/spire/svids
output
Received 1 svid after 4.213474ms
SPIFFE ID: spiffe://acme.internal/ns/prod/sa/payments
SVID Valid After: 2026-07-21 09:00:14 +0000 UTC
SVID Valid Until: 2026-07-21 10:00:24 +0000 UTC
CA #1 Valid After: 2026-07-21 02:11:07 +0000 UTC
CA #1 Valid Until: 2026-07-22 02:11:17 +0000 UTC
Writing SVID #0 to file /run/spire/svids/svid.0.pem.
Writing key #0 to file /run/spire/svids/svid.0.key.
Writing bundle #0 to file /run/spire/svids/bundle.0.pem.

The ten second gap between the hour you asked for and the window that got printed is not a rounding error. SPIRE backdates notBefore, the moment a certificate becomes valid, by ten seconds, so a receiver whose clock runs slightly fast does not reject a certificate that was issued a moment ago. Now open the file itself.

terminal
openssl x509 -in /run/spire/svids/svid.0.pem -noout \
-subject -dates -ext subjectAltName,keyUsage,extendedKeyUsage,basicConstraints
output
subject=C = US, O = SPIRE
notBefore=Jul 21 09:00:14 2026 GMT
notAfter=Jul 21 10:00:24 2026 GMT
X509v3 Key Usage: critical
Digital Signature, Key Encipherment, Key Agreement
X509v3 Extended Key Usage:
TLS Web Server Authentication, TLS Web Client Authentication
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Subject Alternative Name:
URI:spiffe://acme.internal/ns/prod/sa/payments

Four details in that output are worth carrying with you. The certificate lists both server *and* client authentication, because the same document is presented in both directions of an mTLS handshake. CA:FALSE means it can sign nothing, so a stolen SVID cannot be used to mint further identities. There is exactly one URI SAN, which the spec requires: a certificate carrying two SPIFFE IDs is invalid, not ambiguous. And the SPIFFE ID appears nowhere in the Subject. SPIRE stamps the same fixed boilerplate, C = US, O = SPIRE, on every SVID it ever issues, and puts no Common Name in there at all. Any tool that authorizes on the Subject or the CN is reading a field that names no workload. Read the URI SAN. Ignore the rest.

The JWT-SVID: identity that survives a proxy

The gap between the two SVID formats is the gap between a key and a cloakroom ticket. An X.509-SVID is a key: you never hand it over, you prove you hold it by using it, because the TLS handshake makes the peer sign something with the matching private key. A JWT-SVID is a ticket. JWT stands for JSON Web Token, a small blob of signed JSON that anybody can read and anybody can check. You hand it to the other side, and whoever ends up holding it walks through the turnstile. That single difference drives every decision about which one to reach for.

So why ever use a ticket? Because certificates only survive one hop. If the call from payments to ledger passes through an API gateway that terminates TLS and opens a fresh connection onward, ledger sees the gateway's certificate, and the original caller's identity is gone. A JWT-SVID rides inside the request itself, normally in a header, so it crosses that boundary intact. The aud (audience) claim is what stops the ticket working anywhere else: you pin it to the one service it is meant for.

terminal
spire-agent api fetch jwt \
-audience spiffe://acme.internal/ns/prod/sa/ledger \
-socketPath /run/spire/sockets/agent.sock
output
token(spiffe://acme.internal/ns/prod/sa/payments):
eyJhbGciOiJFUzI1NiIsImtpZCI6IkxYVTlmM1pxNm1KMm44WXZRZEsxUnQ1d1MwYUJjRGVGIiwidHlwIjoiSldUIn0.<snip>.FT38xvruhLof613UJ7J_H5toCwBU_KK2KpoUPGIzs8PjWvAeLOvAKk1ba75kADOKm2qyRyGfCW08qdtcFe2Ctg
bundle(acme.internal):
{
"keys": [
{
"kty": "EC",
"kid": "LXU9f3Zq6mJ2n8YvQdK1Rt5wS0aBcDeF",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
}
]
}

The agent hands back the token and, alongside it, the public keys needed to verify tokens from this trust domain. That second half is a JWKS (JSON Web Key Set, a list of public keys written as JSON). Decode the claims with step, the command-line tool from Smallstep, to see what a verifier will actually read.

terminal
# TOKEN holds the value printed above
step crypto jwt inspect --insecure <<< "$TOKEN"
output
{
"header": {
"alg": "ES256",
"kid": "LXU9f3Zq6mJ2n8YvQdK1Rt5wS0aBcDeF",
"typ": "JWT"
},
"payload": {
"aud": [
"spiffe://acme.internal/ns/prod/sa/ledger"
],
"exp": 1784625014,
"iat": 1784624714,
"sub": "spiffe://acme.internal/ns/prod/sa/payments"
},
"signature": "FT38xvruhLof613UJ7J_H5toCwBU_KK2KpoUPGIzs8PjWvAeLOvAKk1ba75kADOKm2qyRyGfCW08qdtcFe2Ctg"
}

sub (subject) is the same SPIFFE ID the certificate carried, which is the whole point: one name, two documents. kid (key ID) names the key in the trust bundle that signed this token, which is how a verifier picks the right one out of several. exp (expiry) is five minutes out, SPIRE's default for JWT-SVIDs, against one hour for X.509-SVIDs, and iat records when it was issued. Notice what is missing. The SPIFFE JWT-SVID spec defines no iss (issuer) claim, so a verifier's entire job is three checks: the signature against the trust domain's JWT keys, exp against the clock, and aud against its own SPIFFE ID. That last check is the one people skip.

A JWT-SVID is a bearer token
Anything that can read the token *is* that workload until it expires. Tokens end up in access logs, error reports and shell history far more easily than private key files do, so never put one in a URL or a query string, and never log the raw value. The aud check is the only thing standing between a token leaked from one service and a valid call to another: a verifier that checks the signature and sub but skips aud will cheerfully accept a token minted for somebody else. Reach for mTLS and X.509-SVIDs whenever both ends can actually reach each other, and keep JWT-SVIDs for the hops that terminate TLS in the middle.

One bundle verifies both formats

The trust bundle is the sheet of authorised signatures a bank keeps behind the counter. Public material only, nothing secret in it, and it is everything a verifier needs to check documents from that trust domain. Ask the server for it in SPIFFE format and both key types show up side by side.

terminal
spire-server bundle show -format spiffe
output
{
"keys": [
{
"use": "x509-svid",
"kty": "EC",
"crv": "P-256",
"x": "kK1lB9dQ2v8sYc7RtN0uWpXe4hZ3mJ6fA1gLbO5cTiU",
"y": "0hZ4rG7yPmS2wKdV9nX1oCbE6tJ8qLzF3aRuY5MhNvQ",
"x5c": [
"MIIB9jCCAZ2gAwIBAgIQ...<snip>...bA1sYr5QG3nQ=="
]
},
{
"use": "jwt-svid",
"kty": "EC",
"kid": "LXU9f3Zq6mJ2n8YvQdK1Rt5wS0aBcDeF",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
}
],
"spiffe_refresh_hint": 300,
"spiffe_sequence": 7
}

The x509-svid entry carries the root certificate itself in x5c, the standard JWKS field for a certificate chain, which is what a chain check needs. The jwt-svid entry carries a bare public key with a kid, which is what a token check needs. Same domain, one bundle, two jobs. Those use labels belong to the SPIFFE bundle format; the JWKS the Workload API handed the workload a moment ago is a plain one, keys only. spiffe_refresh_hint (in seconds) tells verifiers how often to come back for a fresh copy, and it earns its keep during root rotation: SPIRE publishes a new root into the bundle *before* it starts signing with it, so anyone who refreshed inside the hint window already trusts the new root by the time the first certificate signed by it arrives.

Now the honest part. SPIFFE ships no revocation. There is no CRL (certificate revocation list, a published list of certificates to stop trusting) and no OCSP (Online Certificate Status Protocol, a live lookup that answers the same question) responder to operate, which deletes an entire category of outage, and short lifetimes are the replacement. Deleting a registration entry with spire-server entry delete -entryID <id> is not revocation either: it stops renewal, and the SVID already sitting in the workload's memory keeps working until it expires. Your exposure window is whatever TTL you chose. For the one case where waiting is unacceptable, a leaked CA key, SPIRE 1.9 added forced authority rotation: spire-server localauthority x509 taint marks a signing key as compromised and pushes everything it signed to be re-issued, and spire-server localauthority x509 revoke drops that key from the bundle so anything still carrying it stops verifying. Per-workload revocation still does not exist. The price of short TTLs is signing throughput, so at thousands of workloads rotating hourly it is the server's signing rate and datastore write volume, not certificate storage, that sets your capacity ceiling.

A trust domain is one blast radius, and you cannot rename it
Every SVID in a trust domain is verified with, and therefore forgeable from, that domain's root key. One spiffe://company/ domain spanning prod, staging and dev is less to operate, right up to the morning a staging CA key leaks and can mint prod identities. Splitting afterwards hurts, because the trust domain name is baked into every SPIFFE ID, every registration entry, and every policy that references one. Istio at least offers meshConfig.trustDomainAliases to keep old identities valid through a migration; SPIRE has no alias mechanism at all. Draw the boundaries once along real administrative lines, then connect them with [federation](/courses/zerotrust/zt-federation/), which swaps public bundles between domains without merging their roots.

What an SVID handshake proves, and what it does not

When ledger accepts an mTLS connection from payments and both ends present SVIDs, here is precisely what has been established. The peer holds the private key for a certificate whose single URI SAN reads spiffe://acme.internal/ns/prod/sa/payments. That certificate chains to a root in ledger's copy of the trust bundle. And it is inside its validity window. Strong, and narrow.

Here is what has *not* been established. Not that the caller is behaving: a compromised payments pod holds a completely valid SVID, and every check above passes while it drains your ledger. Not that the original caller is payments, because mTLS authenticates the immediate peer, so one proxy in the middle and you are authenticating the proxy. Nothing whatsoever about a human user, because a workload identity says nothing about whose request it happens to be carrying. And not that this call is *allowed*, which is the difference between authentication and authorization, and the reason the next few lessons are about [policy](/courses/zerotrust/zt-authz/). Identity tells you who is on the line. It never tells you what they should be permitted to do.

The same name, in the mesh and in policy

Because SPIFFE is a standard, a service mesh does not invent identities, it consumes them. Istio issues every sidecar proxy an X.509-SVID and defaults its trust domain to cluster.local. Pull one straight out of a running pod and the shape is familiar.

terminal
istioctl proxy-config secret payments-7d8f9c4b6-x2k9m.prod -o json \
| jq -r '.dynamicActiveSecrets[0].secret.tlsCertificate.certificateChain.inlineBytes' \
| base64 -d \
| openssl x509 -noout -subject -dates -ext subjectAltName
output
subject=
notBefore=Jul 21 09:12:33 2026 GMT
notAfter=Jul 22 09:12:33 2026 GMT
X509v3 Subject Alternative Name: critical
URI:spiffe://cluster.local/ns/prod/sa/payments

Two things to take from that. The name is the same spiffe:// URI shape SPIRE issues, which is what lets one policy language cover both. And this certificate's Subject is empty, which is why its SAN is flagged critical: RFC 5280, the standard that defines how X.509 certificates are encoded, demands it when there is no Subject to fall back on. SPIRE fills in a Subject, so its SVIDs leave the SAN non-critical. Both documents are perfectly valid. Criticality is a property of the encoding and never a signal about identity, so do not write a check on it. In production, override the cluster.local default through meshConfig.trustDomain before any policy references it. Every cluster that keeps the default shares a trust domain name with every other cluster on earth that kept it, which is an awkward place to start from when you later want to federate.

Here is the payoff. Turn on strict mTLS for the namespace, then write an allow rule that names the caller by identity instead of by address.

istio/ledger-authz.yaml
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: prod-strict
namespace: prod
spec:
mtls:
mode: STRICT # plaintext to any workload in prod is refused
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: ledger-allow-payments
namespace: prod
spec:
selector:
matchLabels:
app: ledger
action: ALLOW
rules:
- from:
- source:
# No spiffe:// scheme here. Istio compares the stripped form.
principals: ["cluster.local/ns/prod/sa/payments"]
to:
- operation:
methods: ["POST"]
paths: ["/v1/transfers"]

The moment an ALLOW policy selects a workload, everything that fails to match a rule is denied, so that one manifest gives ledger a default-deny posture with a single deliberate hole in it. Verify it instead of believing it. Call the endpoint from a pod carrying a different identity, then from payments.

terminal
# a workload with a different identity
kubectl exec -n prod deploy/reporting -- \
curl -sS -w '\nHTTP %{http_code}\n' -X POST http://ledger.prod:8080/v1/transfers
# the identity named in the policy
kubectl exec -n prod deploy/payments -- \
curl -sS -w '\nHTTP %{http_code}\n' -X POST http://ledger.prod:8080/v1/transfers
output
RBAC: access denied
HTTP 403
{"id":"tr_01K7Q2ZP","status":"accepted"}
HTTP 201

That 403 never reached your application. It came from the sidecar proxy, and RBAC: access denied is Envoy's stock wording (RBAC is role-based access control, the filter Istio compiles your policy into). A refusal you can see is the easy case. The next mistake produces no message at all.

principals carries no spiffe:// scheme
Istio matches the peer identity as <trust-domain>/ns/<namespace>/sa/<name>, with the scheme stripped off. Write principals: ["spiffe://cluster.local/ns/prod/sa/payments"] and the manifest applies without a word of complaint, then the rule matches nothing forever. The failure mode depends on what you were writing: an ALLOW rule that never matches produces a loud 403 on legitimate traffic and somebody pages you, but a DENY rule that never matches silently permits exactly what you were trying to block, and nobody pages anybody. Take the URI SAN from istioctl proxy-config secret, drop the spiffe://, paste the rest, and always test a policy from a workload that is supposed to be refused.
One name, two documents, one bundle
How does this workload prove its SPIFFE ID?
Same ID either way; the transport picks the document
Direct mTLS, both ends reachable
X.509-SVID
ID in the URI SAN; the peer proves it holds the private key
The call crosses a proxy that terminates TLS
JWT-SVID
sub is the ID, aud pins one recipient; a bearer token, so it can be stolen
You are the one doing the verifying
Trust bundle
Root certificate in x5c for certificates, public key plus kid for tokens; public material only
The SPIFFE ID never changes. What you present depends on what the connection has to survive.
Quick check
01A verifier receives a peer's X.509-SVID over mTLS. Which part of that certificate is it allowed to authorize on?
Incorrect — SPIRE stamps every SVID with the same C = US, O = SPIRE and no Common Name at all, so there is nothing there that names a workload.
Correct — the SPIFFE ID lives in the URI SAN and nowhere else, and exactly one is permitted per SVID.
Incorrect — the serial changes on every rotation, hourly by default, so a pinned serial breaks the first time the SVID is renewed.
Incorrect — the issuer tells you which CA signed the document, not which workload is presenting it.
02You add principals: ["spiffe://cluster.local/ns/prod/sa/payments"] to a DENY AuthorizationPolicy and apply it. What actually happens?
Incorrect — Istio does not validate the principal format, so the manifest applies cleanly and gives you no signal that anything is off.
Incorrect — Istio compares against cluster.local/ns/prod/sa/payments, so a value carrying the scheme never matches.
Correct — Istio strips the spiffe:// prefix from the peer identity, so the rule is dead on arrival and a DENY that never fires fails open.
Incorrect — a rule that matches nothing has no effect at all in a DENY policy; it does not escalate into blocking everything.
03Your entry sets -x509SVIDTTL 3600, but spire-agent api fetch x509 reports SVID Valid After: 09:00:14, SVID Valid Until: 09:22:41, and CA #1 Valid Until: 09:22:41. What is going on, and what do you change?
Incorrect — agents re-fetch entries continuously, and a stale entry would not line the SVID's expiry up exactly with the CA's.
Incorrect — it is the other way round. -ttl is the deprecated flag, and -x509SVIDTTL and -jwtSVIDTTL are the current pair.
Incorrect — skew would shift both timestamps together, and it would not make the SVID expire at the same instant as the signing CA.
Correct — notAfter is clamped to the CA's notAfter, so a short-lived CA quietly truncates every SVID it signs. SPIRE wants ca_ttl at least six times your SVID TTL.

One question has been quietly assumed all the way through. The workload in that first spire-agent api fetch x509 call held no password, no token, no secret of any kind, nothing an attacker on the same node could not also hold, and the agent still handed it a signed identity. So what stopped a pod in staging from asking for the payments SVID and getting one? The selectors, and behind them a chain of checks the agent runs against the kubelet, the node, and the process on the other end of that socket. That is attestation, the part of SPIFFE most often misconfigured in a way nobody notices until it matters, and it is the [next lesson](/courses/zerotrust/zt-spire/).

Try this

Run step crypto jwt inspect --insecure <<< "$TOKEN" on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a JWT-SVID is a bearer token. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related