SPIFFE IDs & SVIDs
Trust domains, X.509 vs JWT identity documents.
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.
# 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
Entry ID : 8c0e0a6d-3f2b-4c1a-9d77-2b5e6f0a1c33SPIFFE ID : spiffe://acme.internal/ns/prod/sa/paymentsParent ID : spiffe://acme.internal/spire/agent/k8s_psat/prod-euw1/2a1b7f9c-3e4d-4a55-9b21-0c7d6e5f4a3bRevision : 0X509-SVID TTL : 3600JWT-SVID TTL : 300Selector : k8s:ns:prodSelector : 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.
spire-agent api fetch x509 \-socketPath /run/spire/sockets/agent.sock \-write /run/spire/svids
Received 1 svid after 4.213474msSPIFFE ID: spiffe://acme.internal/ns/prod/sa/paymentsSVID Valid After: 2026-07-21 09:00:14 +0000 UTCSVID Valid Until: 2026-07-21 10:00:24 +0000 UTCCA #1 Valid After: 2026-07-21 02:11:07 +0000 UTCCA #1 Valid Until: 2026-07-22 02:11:17 +0000 UTCWriting 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.
openssl x509 -in /run/spire/svids/svid.0.pem -noout \-subject -dates -ext subjectAltName,keyUsage,extendedKeyUsage,basicConstraints
subject=C = US, O = SPIREnotBefore=Jul 21 09:00:14 2026 GMTnotAfter=Jul 21 10:00:24 2026 GMTX509v3 Key Usage: criticalDigital Signature, Key Encipherment, Key AgreementX509v3 Extended Key Usage:TLS Web Server Authentication, TLS Web Client AuthenticationX509v3 Basic Constraints: criticalCA:FALSEX509v3 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.
spire-agent api fetch jwt \-audience spiffe://acme.internal/ns/prod/sa/ledger \-socketPath /run/spire/sockets/agent.sock
token(spiffe://acme.internal/ns/prod/sa/payments):eyJhbGciOiJFUzI1NiIsImtpZCI6IkxYVTlmM1pxNm1KMm44WXZRZEsxUnQ1d1MwYUJjRGVGIiwidHlwIjoiSldUIn0.<snip>.FT38xvruhLof613UJ7J_H5toCwBU_KK2KpoUPGIzs8PjWvAeLOvAKk1ba75kADOKm2qyRyGfCW08qdtcFe2Ctgbundle(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.
# TOKEN holds the value printed abovestep crypto jwt inspect --insecure <<< "$TOKEN"
{"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.
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.
spire-server bundle show -format spiffe
{"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.
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.
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
subject=notBefore=Jul 21 09:12:33 2026 GMTnotAfter=Jul 22 09:12:33 2026 GMTX509v3 Subject Alternative Name: criticalURI: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.
apiVersion: security.istio.io/v1kind: PeerAuthenticationmetadata:name: prod-strictnamespace: prodspec:mtls:mode: STRICT # plaintext to any workload in prod is refused---apiVersion: security.istio.io/v1kind: AuthorizationPolicymetadata:name: ledger-allow-paymentsnamespace: prodspec:selector:matchLabels:app: ledgeraction: ALLOWrules:- 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.
# a workload with a different identitykubectl 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 policykubectl exec -n prod deploy/payments -- \curl -sS -w '\nHTTP %{http_code}\n' -X POST http://ledger.prod:8080/v1/transfers
RBAC: access deniedHTTP 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.
<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.C = US, O = SPIRE and no Common Name at all, so there is nothing there that names a workload.principals: ["spiffe://cluster.local/ns/prod/sa/payments"] to a DENY AuthorizationPolicy and apply it. What actually happens?cluster.local/ns/prod/sa/payments, so a value carrying the scheme never matches.spiffe:// prefix from the peer identity, so the rule is dead on arrival and a DENY that never fires fails open.-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?-ttl is the deprecated flag, and -x509SVIDTTL and -jwtSVIDTTL are the current pair.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.