Who, Not Where: Workload Identity with SPIFFE

Background
You may have heard of Secure Production Identity Framework For Everyone (SPIFFE), but be less familiar with what it’s for or exactly how it works. This article takes a high-level look at SPIFFE and how Linkerd uses it to extend identity-based authentication to workloads outside Kubernetes.
Using a simple toy example, we’ll look at how the pieces and parts fit together to build an intuition about what’s going on under the hood. By the end, you should have a firm grasp on the core concepts behind SPIFFE and how they establish trust between workloads across different environments.
Modern infrastructure is messy
If you build modern cloud software in Kubernetes with a service mesh like Linkerd, you’re probably familiar with the built-in mechanisms for workload identity, connection authentication, authorization policy, and enforcement. You may also need to connect to workloads outside Kubernetes like virtual machines, legacy applications, managed cloud services, edge and remote systems and so on. You are probably also familiar with the challenges of extending those same security controls to those outside workloads.
It’s hard to build a consistent trust and security model across all of these environments. How can workloads securely communicate when they don’t all live on the same platform?
Modern infrastructure makes it impractical to rely on traditional trust mechanisms based only on network location, reachability, and perimeter controls such as firewalls and routers. Workloads move between environments, applications span clouds, data centers, and edge locations, and infrastructure is dynamic rather than static. Organizations also tend to accumulate separate identity and trust systems: Kubernetes identities, VM certificates, cloud IAM systems, and custom authentication for legacy applications.
Rather than asking where a workload runs, modern systems need to know who it is.
That’s the challenge SPIFFE was created to solve, and it’s the primary tool Linkerd uses to extend the mesh to workloads across heterogeneous environments.
Introducing SPIFFE
SPIFFE is an open standard for workload identity, designed for the messy, distributed systems actually found in the wild. It gives software a common way to identify itself across different environments and infrastructure, while remaining flexible, extensible, and infrastructure-neutral.
Ok, but what does that actually mean?
By way of analogy, let’s imagine the employee badge system of a large organization. The receptionist or security desk doesn’t need to recognize you personally. They trust the validity of your badge. The badge was issued by corporate security, and, as long as the badge can be validated, the trust of corporate security is extended to the badge holder. Everyone trusts the same authority, so everyone can trust the identity the badge represents.
SPIFFE applies the same principle to software.
Distributed systems are full of workloads that have never “met.” Before they trust each other, they need to establish “who am I talking to?” SPIFFE gives every workload a cryptographically verifiable digital identity issued by a trusted authority. Whenever workloads connect, they present that identity to each other. If both trust the issuer, they can trust the identity.
That identity has to be grounded in something real. SPIFFE calls that initial binding attestation: before issuing an identity, a trusted system verifies evidence that connects the requested identity to the workload requesting it. In Kubernetes, Linkerd’s identity service does this by validating the proxy’s ServiceAccount token through Kubernetes’ TokenReview API; outside Kubernetes, SPIRE can attest local workload attributes instead, as we’ll see in the example below.
Instead of tying trust to facts about the network like IP addresses, hostnames, or network boundaries, SPIFFE ties the identity to the workload itself. The infrastructure is an implementation detail while the identity remains consistent and portable.
This is built on three core concepts: the SPIFFE ID, trust domains, and signed credentials called Verifiable Identity Documents (SVIDs).
SPIFFE IDs are strings that uniquely and specifically identify a workload. They take the form of Uniform Resource Identifiers (URIs):
spiffe://<trust-domain>/<workload-identifier>
The workload identifier can contain multiple path segments, as in
spiffe://acme.com/inventory/feed
SPIFFE standardizes the URI format and the trust domain, but not the internal structure of the workload identifier; different systems, including Linkerd and Istio, may use different conventions.
Trust domains are secure namespaces that act as the root of authority for workload identities. In the SPIFFE id above “acme.com” is the trust domain (although, it’s important to note that using a domain name is merely a convention to avoid naming conflicts; it’s not actually tied to DNS. Instead, it represents the organization that issues and stands behind those workload identities).
SVIDs are short-lived, signed credentials for cryptographic proof, usually in the form of an X.509 certificate. JSON web tokens (JWTs) may also be used, primarily for Layer 7 or HTTP/REST API authentication where mutual TLS is impractical.
To tie these concepts back to the employee badge analogy, the trust domain is like the company (or corporate security organization), the SPIFFE ID is like the identity on your badge, and the SVID is the physical badge itself.
How Linkerd uses SPIFFE to extend trust beyond the cluster
Linkerd already establishes secure identities for Kubernetes workloads. SPIFFE allows the same identity model to extend to workloads outside Kubernetes.
Let’s use a simple, toy demo to build up an intuition for how this works. Imagine a retail system in the cloud that needs to integrate point-of-sale and inventory data from physical stores. The system that ferries that data from the store to the cloud needs to prove its identity and vice versa before they are allowed to talk to each other.
In this example, we will expand the Linkerd mesh connecting the retail system in the Kubernetes cluster to include an external, in-store workload. The demo is deployed on two separate Linux hosts, one running the Kubernetes cluster, the other running the POS/inventory updater app that connects to the cluster. Here’s a sketch of the mental model you’ll need:
- Single root of trust. The external workload’s certificates chain to the same root CA as the mesh.
- A data-plane proxy on the external machine. A standalone linkerd2-proxy runs next to the workload, gets its identity from SPIRE (the SPIFFE reference implementation), and does mTLS on the workload’s behalf, the same as an injected sidecar in the cluster.
- Identity source on the external machine. In the cluster, Linkerd issues certificates trusting a pod’s Kubernetes service account. Off-cluster, SPIRE plays that role: it verifies facts about a local workload (the user it runs as and the binary location) and issues it a short-lived SVID. The workload SPIRE attests is the Linkerd proxy which carries the identity on the in-store app’s behalf.
- The cluster’s awareness of the workload. The cluster needs an ExternalWorkload resource that tells Linkerd about the off-cluster workload so it can be treated like any other mesh endpoint.
Step one: Configure root of trust
To set up the single root of trust, we need two certificates: a long-lived trust anchor, the root CA, and a shorter-lived issuer certificate that signs the proxy certs. Normally, Linkerd does this for you and you never see the root key. In this example, we generate the pair by hand on the cluster machine so the in-cluster SPIRE server can use the root as its UpstreamAuthority:
> step certificate create root.linkerd.cluster.local ca.crt ca.key \ --profile root-ca --no-password --insecure --not-after=87600h>
> step certificate create identity.linkerd.cluster.local issuer.crt issuer.key \ --profile intermediate-ca --not-after 8760h --no-password --insecure \ --ca ca.crt --ca-key ca.key- You would run these commands on the host that runs the Kubernetes cluster.
- root.linkerd.cluster.local is the trust anchor’s common name; the trust domain in every SPIFFE ID is set to it.
- Linkerd requires ECDSA P-256 keys. step uses that profile by default.
- For this toy example, you would keep ca.crt and ca.key on the cluster host machine: the in-cluster SPIRE server mounts them (as a read-only Secret) to sign the external workload’s certificates.
The two certs have distinct jobs. The issuer is what linkerd-identity uses to sign in-cluster pod certs. SPIRE uses the root to mint its own intermediate signing certificate; it doesn’t use the Linkerd issuer. Instead, SPIRE signs the external workload’s SVIDs with the intermediate certificate it mints, which works because both chain back to the same root CA. Separate intermediates are useful here because they can be rotated independently, without changing the root trust anchor distributed to workloads
Step two: Establish an identity server
SPIRE has two roles:
1) a server that issues identities and has signing authority
2) an agent that attests local processes, and hands them SVIDs issued by the server.
In our demo, the SPIRE server runs inside the cluster so the root key stays cluster-local while the SPIRE agent runs on the external host.
The server config, mounted from a ConfigMap, will look something like this:
server {
bind_address = "0.0.0.0"
bind_port = "8081"
trust_domain = "root.linkerd.cluster.local"
data_dir = "/run/spire/data"
ca_ttl = "168h"
default_x509_svid_ttl = "48h"
}
plugins {
DataStore "sql" { plugin_data { database_type = "sqlite3"
connection_string = "/run/spire/data/datastore.sqlite3" } }
KeyManager "disk" { plugin_data { keys_path = "/run/spire/data/keys.json" } }
NodeAttestor "join_token" { plugin_data {} }
UpstreamAuthority "disk" {
plugin_data {
cert_file_path = "/run/spire/secret/ca.crt" # mounted from the Secret
key_file_path = "/run/spire/secret/ca.key"
}
}
}Step 3: Enroll the external (in-store) host’s agent
The in-store host runs the SPIRE agent. The agent and server need to mutually authenticate each other, so the agent authenticates the server with a pinned trust bundle that you hand to the agent ahead of time, out of band. The agent will only trust a server whose certificate validates against that known bundle.
For the purposes of this demo (though a production system would use a more sophisticated method), the server authenticates the agent by way of a one-time “join token” that is minted on the SPIRE server and bound to its SPIFFE ID. You also hand the join token to the agent ahead of time, out of band. The join token is good for exactly one enrollment, after which the agent uses its own node certificate.
The agent config will look something like this:
agent {
data_dir = "/opt/spire/data/agent"
trust_domain = "root.linkerd.cluster.local"
server_address = "<cluster-node-addr>"
server_port = 30081
trust_bundle_path = "/opt/spire/certs/bundle.pem" # pinned; no insecure_bootstrap
}
plugins {
KeyManager "disk" { plugin_data { directory = "/opt/spire/data/agent" } }
NodeAttestor "join_token" { plugin_data {} }
WorkloadAttestor "unix" {
plugin_data {
discover_workload_path = true # required to emit the unix:path selector
workload_size_limit = -1 # we don't use unix:sha256, so skip hashing
}
}
}Step 4: Register the external workload with the SPIRE server
The registration step tells the SPIRE server what identity to give to a particular workload, in this case, the Linkerd proxy fronting our in-store POS/inventory updater process. You perform the registration step on the cluster host like this:
> kubectl -n spire exec spire-server-0 -- /opt/spire/bin/spire-server entry create \
-parentID spiffe://root.linkerd.cluster.local/store/042/agent \
-spiffeID spiffe://root.linkerd.cluster.local/store/042/inventory-sync \
-selector unix:uid:2102 \
-selector unix:path:/opt/linkerd-proxy/linkerd-proxy
-spiffeID …/store/042/inventory-syncis the identity to grant.-parentID …/store/042/agentis the in-store agent that will deliver it.- The selectors constrain this identity to the linkerd2-proxy process running as UID 2102 from that binary path. Any process that doesn’t match those selectors, including those running as root, is not allowed to obtain the SVID. This selector matching is SPIRE’s workload attestation step: it verifies attributes of the requesting process before issuing the identity.
NOTE: For explanatory purposes, each workload identity is registered by hand with spire-server entry create. Real deployments will drive registration with more sophisticated mechanisms, such as the SPIRE Controller Manager (ClusterSPIFFEID CRDs) or a registrar/GitOps pipeline.
Step 5: Run the data plane proxy on the external host
First, you have to install the standalone proxy on the external host. The proxy is the same binary shipped in Linkerd’s sidecar image, so, for convenience, you can extract it from the docker image like this:
> id=$(sudo docker create cr.l5d.io/linkerd/proxy:$LINKERD_VERSION)
> sudo docker cp "$id:/usr/lib/linkerd/linkerd2-proxy" /opt/linkerd-proxy/linkerd-proxy
> sudo docker rm -v "$id"
In this example, we’ll redirect network traffic from the in-store POS/inventory updater app through the proxy with an IPTABLES configuration that redirects traffic based on uid. Traffic from the app’s uid will get routed through the proxy, all other traffic will be left alone:
> sudo iptables -t nat -N PROXY_APP_OUTPUT
> sudo iptables -t nat -A PROXY_APP_OUTPUT -o lo -j RETURN
> sudo iptables -t nat -A PROXY_APP_OUTPUT -p tcp -j REDIRECT --to-port 4140
> sudo iptables -t nat -A OUTPUT -m owner --uid-owner 1000 -p tcp -j PROXY_APP_OUTPUTRun the proxy with configuration set in environment variables (again, this is for explanatory purposes, not how you’d do it in production):
> export LINKERD2_PROXY_IDENTITY_SERVER_ID="spiffe://root.linkerd.cluster.local/store/042/inventory-sync"
> export LINKERD2_PROXY_IDENTITY_SERVER_NAME="inventory-sync.cluster.local"
> export LINKERD2_PROXY_POLICY_WORKLOAD='{"ns":"mixed-env","external_workload":"store-pos"}'
> export LINKERD2_PROXY_DESTINATION_CONTEXT='{"ns":"mixed-env","nodeName":"store","external_workload":"store-pos"}'
> export LINKERD2_PROXY_DESTINATION_SVC_ADDR="linkerd-dst-headless.linkerd.svc.cluster.local.:8086"
> export LINKERD2_PROXY_DESTINATION_SVC_NAME="linkerd-destination.linkerd.serviceaccount.identity.linkerd.cluster.local"
> export LINKERD2_PROXY_POLICY_SVC_ADDR="linkerd-policy.linkerd.svc.cluster.local.:8090"
> export LINKERD2_PROXY_POLICY_SVC_NAME="linkerd-destination.linkerd.serviceaccount.identity.linkerd.cluster.local"
> export LINKERD2_PROXY_IDENTITY_SPIRE_WORKLOAD_API_ADDRESS="unix:///tmp/spire-agent/public/api.sock"
> export LINKERD2_PROXY_IDENTITY_TRUST_ANCHORS="$(cat /opt/spire/certs/ca.crt)"
> sudo useradd -M -u 2102 -s /usr/sbin/nologin linkerd-proxy
> sudo -E setpriv --reuid=2102 --regid=2102 --clear-groups /opt/linkerd-proxy/linkerd-proxy- IDENTITY_SERVER_ID / IDENTITY_SERVER_NAME: the SPIFFE ID this proxy should obtain and the SNI it presents. Must match the registered entry.
- IDENTITY_SPIRE_WORKLOAD_API_ADDRESS: this is the key change: instead of talking to the in-cluster linkerd-identity service, the proxy fetches its SVID from the SPIRE agent’s Workload API socket. SPIRE attests the proxy (uid 2102 and binary path), matches the registration entry, and streams it a certificate, rotating it before expiry, with no restart.
- IDENTITY_TRUST_ANCHORS: the root the proxy validates peers against. It’s the same ca.crt, so it trusts everything else in the mesh.
- DESTINATION_SVC_ADDR / POLICY_SVC_ADDR: where the proxy reaches the control plane: linkerd-destination (service discovery/endpoints) on 8086 and linkerd-policy (authorization policy) on 8090. These resolve via the cluster DNS and routes you set up in iptables.
- POLICY_WORKLOAD / DESTINATION_CONTEXT: how the proxy identifies itself to those controllers: as the external workload store-pos in namespace mixed-env.
Step 6: Onboard the external workload to the mesh
Now, we need to tell Linkerd about the external workload: that it exists, what identity it has, and how to route to it. We’ll create a namespace for it and register it as an ExternalWorkload.
We’ll create the namespace like this:
> kubectl create namespace mixed-env
> kubectl annotate namespace mixed-env linkerd.io/inject=enabledRegister the ExternalWorkload with configuration like this:
apiVersion: workload.linkerd.io/v1beta1
kind: ExternalWorkload
metadata:
name: store-pos
namespace: mixed-env
labels:
app: store-pos # policy selectors match on this
workload_name: store-pos
spec:
meshTLS:
identity: "spiffe://root.linkerd.cluster.local/store/042/inventory-sync"
serverName: "inventory-sync.cluster.local"
workloadIPs:
- ip: "<store-host-ip>"
ports:
- port: 80
name: http
meshTLS.identitymust equal the SPIFFE ID the proxy obtains. This is the identity the mesh attributes to traffic from this workload.workloadIPsand ports describe how to reach the workload, if necessary
Those are the basic steps you might take to configure an external workload to use SPIFFE for identity to join Linkerd mesh. If you’d like to take the demo for a spin yourself, you can get the source code and detailed instructions from Github ›
Why this matters
We’ve only briefly touched on how SPIFFE works in a toy example, but it should help point the way toward what’s possible once you have a common workload identity and shared trust model across a fleet of applications.
Hybrid deployments. With external workloads connected to the mesh, you can start extending modern mesh practices to applications that aren’t cloud-shaped yet. They can authenticate one another across environments without overlapping networks, VPNs, or environment-specific credentials.
Secure integration of edge workloads. Edge workloads often operate across unreliable networks, at sites with limited operational support. In that setting, network-based trust and manual credential management are difficult to scale. A common workload identity lets services authenticate and authorize edge clients by what they are, rather than where they connect from.
Incremental migration and modernization. Not every application needs to move to the cloud—or be rewritten—all at once. A multi-environment deployment model lets you move, refactor, replace, or gradually decompose workloads on your own terms, while a shared identity model helps old and new components communicate securely throughout the transition.
Conclusion
If you’ve made it this far, you’ve hopefully gotten a feel for what SPIFFE is and why we use it. SPIFFE provides a portable identity layer that spans environments and lets us apply consistent policy based on what a workload is, rather than a patchwork of contingent facts about its network. Linkerd builds on that foundation of trust to securely extend service mesh capabilities beyond Kubernetes.
If you want to learn more, here are a few more useful resources:
- Our demo source code and instructions
- Adding non-Kubernetes workloads to your mesh
- Linkerd Beyond Kubernetes: Identity and Mesh Expansion with SPIFFE
- Zero trust network security in Kubernetes with the service mesh

