SPIFFE and SPIRE: Zero Trust Workload Identity
Shared secrets are a security nightmare. API keys in environment variables, service account tokens passed around, secrets rotated once a year (if ever). SPIFFE and SPIRE fix this by giving every workload a cryptographic identity.
This guide covers what SPIFFE is, how SPIRE implements it, and how to deploy it on Kubernetes with real mTLS examples.
TL;DR
- SPIFFE = standard for workload identity (like OIDC for services)
- SPIRE = reference implementation of SPIFFE
- Every workload gets a short-lived X.509 certificate
- No more shared secrets between services
- Automatic rotation, no manual key management
- Full Kubernetes deployment included
What is SPIFFE?
SPIFFE (Secure Production Identity Framework For Everyone) is a set of standards for identifying and securing workloads. Think of it as OIDC/OAuth but for services instead of users.
The core concept is the SPIFFE ID - a URI that uniquely identifies a workload:
spiffe://trust-domain/path/to/workload
Examples:
spiffe://company.com/ns/production/sa/api-server
spiffe://company.com/k8s/cluster-1/ns/default/pod/frontend-abc123
spiffe://company.com/aws/us-east-1/instance/i-1234567890
SPIFFE Components
COMPONENT DESCRIPTION
========= ===========
SPIFFE ID URI identifying a workload
SVID SPIFFE Verifiable Identity Document (X.509 or JWT)
Trust Bundle CA certificates for verifying SVIDs
Workload API Unix socket API for fetching SVIDs
Why Not Just Use Kubernetes Service Accounts?
K8s service account tokens work within a cluster. But:
- They don’t work across clusters
- They don’t work for non-K8s workloads
- They’re long-lived (security risk)
- No automatic rotation
- Can’t be used for mTLS directly
SPIFFE solves all of these.
SPIRE Architecture
SPIRE (SPIFFE Runtime Environment) implements the SPIFFE spec.
┌─────────────────────────────────────────────────────────────┐
│ SPIRE Server │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ CA │ │ Registry │ │ Node Attestation │ │
│ │ (signs │ │ (workload │ │ (verifies nodes) │ │
│ │ SVIDs) │ │ entries) │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ gRPC (mTLS)
▼
┌─────────────────────────────────────────────────────────────┐
│ SPIRE Agent │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Workload │ │ SVID │ │ Workload │ │
│ │ Attestor │ │ Cache │ │ Attestation │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ Unix Socket (Workload API)
▼
┌─────────────────────────────────────────────────────────────┐
│ Workloads │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Service A │ │ Service B │ │ Service C │ │
│ │ (gets SVID)│ │ (gets SVID)│ │ (gets SVID) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Attestation Flow
-
Node Attestation: SPIRE Agent proves its identity to Server
- On K8s: uses projected service account token
- On AWS: uses instance identity document
- On GCP: uses instance metadata
-
Workload Attestation: Agent identifies workloads on the node
- On K8s: checks pod UID, service account, namespace
- On Linux: checks PID, UID, binary hash
-
SVID Issuance: Agent requests SVID for workload from Server
Deploy SPIRE on Kubernetes
We’ll deploy SPIRE using the official Helm chart, then configure it for Kubernetes workload attestation.
Prerequisites
TOOL VERSION PURPOSE
==== ======= =======
kubectl >= 1.28 Cluster access
helm >= 3.12 Package manager
Install SPIRE with Helm
# Add SPIRE Helm repo
helm repo add spiffe https://spiffe.github.io/helm-charts-hardened/
helm repo update
# Create namespace
kubectl create namespace spire-system
# Install SPIRE
helm upgrade --install spire spiffe/spire \
--namespace spire-system \
--values values.yaml \
--wait
Helm Values
# values.yaml
global:
spire:
trustDomain: company.com
clusterName: production
spire-server:
replicaCount: 3
# CA configuration
ca_subject:
country: GB
organization: Company
common_name: SPIRE CA
# SVID TTL (short-lived = more secure)
default_x509_svid_ttl: 1h
# Node attestor for Kubernetes
nodeAttestor:
k8sPsat:
enabled: true
serviceAccountAllowList:
- spire-system:spire-agent
# Datastore (SQLite for dev, PostgreSQL for prod)
dataStore:
sql:
databaseType: sqlite3
connectionString: /run/spire/data/datastore.sqlite3
spire-agent:
# Workload attestor for Kubernetes
workloadAttestors:
k8s:
enabled: true
# Skip validation for specific namespaces
skipKubeletVerification: false
# Socket path for Workload API
socketPath: /run/spire/agent-sockets/spire-agent.sock
Register Workload Entries
Before a workload can get an SVID, you need to register it with SPIRE. This tells SPIRE “this workload should get this SPIFFE ID.”
Using kubectl
# Register all pods in 'api' namespace with 'api-server' service account
kubectl exec -n spire-system spire-server-0 -- \
/opt/spire/bin/spire-server entry create \
-spiffeID spiffe://company.com/ns/api/sa/api-server \
-parentID spiffe://company.com/spire/agent/k8s_psat/production \
-selector k8s:ns:api \
-selector k8s:sa:api-server
# Register specific pod
kubectl exec -n spire-system spire-server-0 -- \
/opt/spire/bin/spire-server entry create \
-spiffeID spiffe://company.com/ns/default/pod/frontend \
-parentID spiffe://company.com/spire/agent/k8s_psat/production \
-selector k8s:ns:default \
-selector k8s:pod-label:app:frontend
Using ClusterSPIFFEID CRD
The SPIRE Controller Manager provides a Kubernetes-native way to register workloads:
# cluster-spiffe-id.yaml
apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
name: api-server
spec:
# SPIFFE ID template
spiffeIDTemplate: "spiffe://company.com/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}"
# Match pods
podSelector:
matchLabels:
spiffe.io/enabled: "true"
# Match namespaces
namespaceSelector:
matchLabels:
spiffe.io/enabled: "true"
---
apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
name: all-workloads
spec:
# Dynamic SPIFFE ID based on pod metadata
spiffeIDTemplate: "spiffe://company.com/k8s/{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}"
podSelector: {}
namespaceSelector:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values:
- kube-system
- spire-system
Integrating Workloads
Workloads fetch SVIDs via the SPIFFE Workload API, exposed as a Unix socket. There are several integration patterns:
Pattern 1: SPIFFE Helper Sidecar
The simplest approach - sidecar writes certs to shared volume:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
template:
spec:
containers:
- name: api
image: api-server:latest
volumeMounts:
- name: spiffe-certs
mountPath: /var/run/secrets/spiffe
readOnly: true
env:
- name: TLS_CERT
value: /var/run/secrets/spiffe/svid.pem
- name: TLS_KEY
value: /var/run/secrets/spiffe/svid_key.pem
- name: TLS_CA
value: /var/run/secrets/spiffe/bundle.pem
- name: spiffe-helper
image: ghcr.io/spiffe/spiffe-helper:latest
args:
- -config
- /etc/spiffe-helper/helper.conf
volumeMounts:
- name: spiffe-certs
mountPath: /var/run/secrets/spiffe
- name: spiffe-socket
mountPath: /run/spire/agent-sockets
readOnly: true
- name: helper-config
mountPath: /etc/spiffe-helper
volumes:
- name: spiffe-certs
emptyDir: {}
- name: spiffe-socket
hostPath:
path: /run/spire/agent-sockets
type: DirectoryOrCreate
- name: helper-config
configMap:
name: spiffe-helper-config
---
apiVersion: v1
kind: ConfigMap
metadata:
name: spiffe-helper-config
data:
helper.conf: |
agent_address = "/run/spire/agent-sockets/spire-agent.sock"
cmd = ""
cert_dir = "/var/run/secrets/spiffe"
svid_file_name = "svid.pem"
svid_key_file_name = "svid_key.pem"
svid_bundle_file_name = "bundle.pem"
renew_signal = ""
Pattern 2: Native SPIFFE Library
Better for new applications - use go-spiffe or equivalent:
package main
import (
"context"
"crypto/tls"
"log"
"net/http"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/go-spiffe/v2/spiffetls"
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
const socketPath = "unix:///run/spire/agent-sockets/spire-agent.sock"
func main() {
ctx := context.Background()
// Create X509Source - automatically fetches and renews SVIDs
source, err := workloadapi.NewX509Source(ctx,
workloadapi.WithClientOptions(workloadapi.WithAddr(socketPath)),
)
if err != nil {
log.Fatalf("Unable to create X509Source: %v", err)
}
defer source.Close()
// Define allowed SPIFFE IDs for clients
allowedIDs := []spiffeid.ID{
spiffeid.RequireFromString("spiffe://company.com/ns/frontend/sa/frontend"),
spiffeid.RequireFromString("spiffe://company.com/ns/api/sa/api-gateway"),
}
// Create TLS config that verifies client SPIFFE ID
tlsConfig := tlsconfig.MTLSServerConfig(source, source,
tlsconfig.AuthorizeOneOf(allowedIDs...),
)
server := &http.Server{
Addr: ":8443",
TLSConfig: tlsConfig,
Handler: http.HandlerFunc(handler),
}
log.Println("Starting mTLS server on :8443")
log.Fatal(server.ListenAndServeTLS("", ""))
}
func handler(w http.ResponseWriter, r *http.Request) {
// Get client's SPIFFE ID from the connection
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
clientID, err := spiffeid.FromURI(r.TLS.PeerCertificates[0].URIs[0])
if err == nil {
log.Printf("Request from: %s", clientID.String())
}
}
w.Write([]byte("Hello from mTLS server"))
}
Pattern 3: Envoy with SDS
Use Envoy as sidecar proxy - it fetches SVIDs via SDS:
# envoy-config.yaml
static_resources:
listeners:
- name: mtls_listener
address:
socket_address:
address: 0.0.0.0
port_value: 8443
filter_chains:
- transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
require_client_certificate: true
common_tls_context:
tls_certificate_sds_secret_configs:
- name: "spiffe://company.com/ns/api/sa/api-server"
sds_config:
api_config_source:
api_type: GRPC
grpc_services:
- envoy_grpc:
cluster_name: spire_agent
validation_context_sds_secret_config:
name: "spiffe://company.com"
sds_config:
api_config_source:
api_type: GRPC
grpc_services:
- envoy_grpc:
cluster_name: spire_agent
filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: local_service
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: spire_agent
type: STATIC
http2_protocol_options: {}
load_assignment:
cluster_name: spire_agent
endpoints:
- lb_endpoints:
- endpoint:
address:
pipe:
path: /run/spire/agent-sockets/spire-agent.sock
- name: local_service
type: STATIC
load_assignment:
cluster_name: local_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 8080
mTLS Between Services
Here’s a complete example of two services communicating with mTLS:
Server (Go)
package main
import (
"context"
"log"
"net/http"
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
func main() {
ctx := context.Background()
source, err := workloadapi.NewX509Source(ctx)
if err != nil {
log.Fatal(err)
}
defer source.Close()
// Allow any SPIFFE ID in our trust domain
tlsConfig := tlsconfig.MTLSServerConfig(source, source,
tlsconfig.AuthorizeMemberOf("company.com"),
)
server := &http.Server{
Addr: ":8443",
TLSConfig: tlsConfig,
}
http.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status": "ok"}`))
})
log.Fatal(server.ListenAndServeTLS("", ""))
}
Client (Go)
package main
import (
"context"
"io"
"log"
"net/http"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
func main() {
ctx := context.Background()
source, err := workloadapi.NewX509Source(ctx)
if err != nil {
log.Fatal(err)
}
defer source.Close()
// Only connect to the expected server SPIFFE ID
serverID := spiffeid.RequireFromString("spiffe://company.com/ns/api/sa/api-server")
tlsConfig := tlsconfig.MTLSClientConfig(source, source,
tlsconfig.AuthorizeID(serverID),
)
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}
resp, err := client.Get("https://api-server:8443/api/data")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
log.Printf("Response: %s", body)
}
Federation: Cross-Cluster Identity
SPIFFE supports federation - trusting identities from other trust domains. This enables secure cross-cluster communication.
┌─────────────────────┐ ┌─────────────────────┐
│ Trust Domain A │ │ Trust Domain B │
│ (company.com) │◄─────────►│ (partner.com) │
│ │ Federation│ │
│ ┌───────────────┐ │ │ ┌───────────────┐ │
│ │ SPIRE Server │ │ │ │ SPIRE Server │ │
│ └───────────────┘ │ │ └───────────────┘ │
└─────────────────────┘ └─────────────────────┘
Configure Federation
# On SPIRE Server A
spire-server:
federation:
enabled: true
bundleEndpoint:
address: 0.0.0.0
port: 8443
# Trust bundles from other domains
federatesWith:
partner.com:
bundleEndpointURL: https://spire.partner.com:8443
bundleEndpointProfile:
https_spiffe:
endpointSPIFFEID: spiffe://partner.com/spire/server
Now workloads in company.com can verify SVIDs from partner.com.
Troubleshooting
Agent can’t connect to server:
# Check agent logs
kubectl logs -n spire-system -l app=spire-agent
# Verify server is running
kubectl exec -n spire-system spire-server-0 -- \
/opt/spire/bin/spire-server healthcheck
Workload not getting SVID:
# Check if entry exists
kubectl exec -n spire-system spire-server-0 -- \
/opt/spire/bin/spire-server entry show
# Check agent logs for attestation
kubectl logs -n spire-system -l app=spire-agent | grep -i attest
SVID verification failing:
# Inspect SVID
openssl x509 -in svid.pem -text -noout
# Check SPIFFE ID in certificate
openssl x509 -in svid.pem -text -noout | grep URI
Entry selectors not matching:
# List selectors for a workload
kubectl exec -n spire-system spire-agent-xxxxx -- \
/opt/spire/bin/spire-agent api fetch -socketPath /run/spire/agent-sockets/spire-agent.sock
Security Best Practices
- Short SVID TTL: 1 hour or less (default is 1h)
- Least privilege entries: Specific selectors, not wildcards
- Federation carefully: Only trust domains you control
- Rotate CA: Use upstream CA with regular rotation
- Audit entries: Review registered entries regularly
- Network policies: Restrict access to SPIRE components
References
- SPIFFE Spec: https://spiffe.io/docs/latest/spiffe-about/overview/
- SPIRE Docs: https://spiffe.io/docs/latest/spire-about/spire-concepts/
- go-spiffe Library: https://github.com/spiffe/go-spiffe
- SPIRE Helm Charts: https://github.com/spiffe/helm-charts-hardened
- Zero Trust with SPIFFE: https://spiffe.io/docs/latest/spiffe-about/use-cases/