Skip to content
Back to blog SPIFFE and SPIRE: Zero Trust Workload Identity

SPIFFE and SPIRE: Zero Trust Workload Identity

SecurityK8s

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

  1. 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
  2. Workload Attestation: Agent identifies workloads on the node

    • On K8s: checks pod UID, service account, namespace
    • On Linux: checks PID, UID, binary hash
  3. 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

  1. Short SVID TTL: 1 hour or less (default is 1h)
  2. Least privilege entries: Specific selectors, not wildcards
  3. Federation carefully: Only trust domains you control
  4. Rotate CA: Use upstream CA with regular rotation
  5. Audit entries: Review registered entries regularly
  6. Network policies: Restrict access to SPIRE components

References

======================================== SPIFFE + SPIRE + Kubernetes

Cryptographic identity. Zero secrets.

Found this helpful?

Comments