Hoe weet Service A dat Service B echt Service B is?

Ik blijf bij die vraag terugkomen, want het gangbare antwoord is ongemakkelijk. Jarenlang vertrouwden we op netwerklocatie. Traffic van het juiste IP was legitiem, klaar. Zero trust nam die aanname mee naar buiten en schoot hem dood. Nu moet elke service bewijzen wie hij is, bij elk request, ongeacht waar hij op het netwerk zit.

Dat klinkt prima op een slide. Het lastige is het bewijs. Hoe laat een pod werkelijk zijn identiteit zien aan een andere pod, zonder dat ik overal met de hand secrets uitdeel en daarna eindeloos hun rotatie zit te bewaken?

SPIFFE (Secure Production Identity Framework for Everyone) is een standaard voor service identity. SPIRE is de productie-klare implementatie. Samen geven ze elke workload automatisch een cryptografische identiteit, zonder statische secrets om te lekken. Deze post bouwt op van het kleinste idee (een naam) tot het complete ding dat ik in mijn eigen cluster draai.

Wat we steeds verkeerd doen

Vóór SPIFFE had elke aanpak van service authenticatie die ik probeerde dezelfde vorm: plak er een credential op, en breng de rest van je leven door met het beheren ervan. Loop de opties langs en het patroon duikt elke keer weer op.

Shared secrets

# Elke pod kent hetzelfde wachtwoord
env:
  - name: API_KEY
    valueFrom:
      secretKeyRef:
        name: shared-secret
        key: api-key

Problemen:

  • Eén gecompromitteerde service legt alle services bloot
  • Rotatie vereist gecoördineerde updates
  • Geen identiteitsonderscheid tussen services

Service accounts met tokens

# Kubernetes ServiceAccount tokens
automountServiceAccountToken: true

Beter, maar:

  • Tokens zijn lang geldig
  • Werken alleen binnen Kubernetes
  • Beperkte attestatie mogelijkheden

mTLS met statische certificaten

# Handmatig certificaat beheer
volumeMounts:
  - name: certs
    mountPath: /certs

Veilig, maar:

  • Certificaat management is operationele overhead
  • Rotatie vereist restarts
  • Cross-cluster identity is complex

Elke regel in dat lijstje is werk dat ik doe terwijl een machine het zou moeten doen. Dat is de frictie die ik weg wil. Dus laat ik het alternatief van niets af opbouwen, laag voor laag.

SPIFFE: De standaard

De simpelste vorm van identiteit is een naam. SPIFFE begint daar en zet er precies twee dingen bovenop. Drie concepten in totaal.

1. SPIFFE ID

Een URI die een workload uniek identificeert:

spiffe://trust-domain/path/to/workload

Voorbeelden:

spiffe://example.com/ns/production/sa/api-server
spiffe://example.com/k8s/cluster-a/ns/default/pod/frontend-abc123
spiffe://example.com/aws/account-123/region/eu-west-1/instance/i-abc123

Het formaat blijft overal gelijk: Kubernetes, VMs, cloud instances, bare metal. Eén naamschema voor het hele landschap, wat meteen een hele categorie “wacht, hoe identificeren we dingen hier” problemen wegneemt.

2. SVID (SPIFFE Verifiable Identity Document)

Een naam op zichzelf bewijst niets. Iedereen kan claimen api-server te zijn. De SVID is de kortlevende credential die de claim onderbouwt met crypto. Hij komt in twee smaken:

X.509 SVID is een certificaat met de SPIFFE ID in de SAN

Subject Alternative Name:
  URI: spiffe://example.com/ns/production/sa/api-server

JWT SVID is een gesigneerde JWT token

{
  "sub": "spiffe://example.com/ns/production/sa/api-server",
  "aud": ["spiffe://example.com/ns/production/sa/database"],
  "exp": 1690000000
}

3. Workload API

Een lokale API (meestal een Unix socket) waar workloads hun SVIDs ophalen:

/run/spire/sockets/agent.sock

Dit is het stuk dat ik het mooiste vind. Workloads raken nooit direct private keys aan. Ze vragen de Workload API om een SVID, en key generatie plus rotatie gebeurt achter die socket. De applicatiecode hoeft zich helemaal niet meer met certificaten bezig te houden, weer één ding minder dat ik fout kan doen.

Dus: een naam, een credential die de naam bewijst, en een socket om hem op te halen. Dat is de hele standaard. Nu de machinerie die het echt maakt.

SPIRE: De implementatie

SPIRE is de motor die die SVIDs uitdeelt. Twee componenten, en de opdeling weerspiegelt hoe trust werkelijk stroomt.

SPIRE Server

  • Centrale autoriteit die SVIDs uitgeeft
  • Slaat registratie entries op (welke workloads welke identiteiten krijgen)
  • Beheert trust bundles

SPIRE Agent

  • Draait op elke node
  • Attesteert workloads (bewijst dat ze zijn wie ze claimen)
  • Stelt de Workload API beschikbaar
  • Cachet en roteert SVIDs
flowchart TD
    subgraph server["SPIRE Server"]
        REG["Registration Entries"]
        TRUST["Trust Bundles"]
    end

    server --> A1["Agent<br/>(Node1)"]
    server --> A2["Agent<br/>(Node2)"]
    server --> A3["Agent<br/>(Node3)"]

    A1 --> W1["Workload API"]
    A2 --> W2["Workload API"]
    A3 --> W3["Workload API"]

De server is het brein, de agents zijn de handen op elke node. De agent is ook het ding dat fysiek voor een workload instaat, wat ertoe doet als we straks bij attestatie komen. Eerst installeren.

SPIRE installeren op Kubernetes

De minimale install is twee Helm commando’s.

helm repo add spiffe https://spiffe.github.io/helm-charts-hardened
helm repo update

# Installeer SPIRE CRDs
helm install spire-crds spiffe/spire-crds \
  --namespace spire-system \
  --create-namespace

# Installeer SPIRE
helm install spire spiffe/spire \
  --namespace spire-system \
  --set global.spire.trustDomain=example.com

Dat werkt, maar ik zou het nooit als imperatieve Helm commando’s laten staan. Alles in mijn cluster leeft in Git en wordt gereconcilieerd, omdat ik wil dat de cluster een functie is van de repo en niet van wat ik vorige dinsdag toevallig intypte. Hier als ArgoCD applicaties:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: spire-crds
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://spiffe.github.io/helm-charts-hardened
    chart: spire-crds
    targetRevision: 0.4.0
  destination:
    server: https://kubernetes.default.svc
    namespace: spire-system
  syncPolicy:
    automated:
      prune: true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: spire
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://spiffe.github.io/helm-charts-hardened
    chart: spire
    targetRevision: 0.21.0
    helm:
      values: |
        global:
          spire:
            trustDomain: example.com
            clusterName: production
        spire-server:
          replicaCount: 3
          dataStore:
            sql:
              databaseType: postgres
        spire-agent:
          socketPath: /run/spire/sockets/agent.sock
  destination:
    server: https://kubernetes.default.svc
    namespace: spire-system
  syncPolicy:
    automated:
      prune: true

Workload registratie

SPIRE geeft geen identiteit aan een workload waar hij nog nooit van gehoord heeft. Iets moet zeggen “een pod met deze vorm hoort deze SPIFFE ID te krijgen.” Dat kun je met de hand doen, maar de Kubernetes workload registrar maakt er een label-gedreven regel van, en dat is de versie die ik draai:

apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
  name: api-server
spec:
  spiffeIDTemplate: "spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}"
  podSelector:
    matchLabels:
      app: api-server
  namespaceSelector:
    matchLabels:
      spire-enabled: "true"

Elke pod die matcht met de selector in een gelabelde namespace krijgt de SPIFFE ID. Nieuwe deployment, dezelfde labels, meteen identiteit, geen ticket naar mij. Dat is de frictiereductie waar ik aan het begin op uit was.

SVIDs gebruiken in applicaties

Nu de praktische vraag: je app heeft een identiteit beschikbaar op een socket, dus hoe gebruikt hij die echt? Er zijn drie manieren naar binnen, ruwweg gesorteerd op hoeveel je je applicatiecode wilt aanraken.

Optie 1: SPIFFE helper sidecar

De minst ingrijpende optie. Een sidecar haalt SVIDs op en schrijft ze als gewone PEM bestanden naar disk, en je app leest certificaten zoals hij dat al doet:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  template:
    spec:
      containers:
        - name: app
          image: my-app:v1.0.0
          volumeMounts:
            - name: spiffe
              mountPath: /spiffe
              readOnly: true
          env:
            - name: TLS_CERT
              value: /spiffe/svid.pem
            - name: TLS_KEY
              value: /spiffe/svid_key.pem
            - name: CA_BUNDLE
              value: /spiffe/bundle.pem
        - name: spiffe-helper
          image: ghcr.io/spiffe/spiffe-helper:latest
          volumeMounts:
            - name: spiffe
              mountPath: /spiffe
            - name: spire-agent-socket
              mountPath: /run/spire/sockets
      volumes:
        - name: spiffe
          emptyDir: {}
        - name: spire-agent-socket
          hostPath:
            path: /run/spire/sockets
            type: Directory

Optie 2: native SPIFFE library

Als je de code zelf bezit, is direct met de Workload API praten schoner. De library handelt rotatie in geheugen af, dus er is geen bestand op disk dat kan lekken of verouderen:

import (
    "github.com/spiffe/go-spiffe/v2/workloadapi"
)

func main() {
    ctx := context.Background()

    // Connect naar Workload API
    source, err := workloadapi.NewX509Source(ctx)
    if err != nil {
        log.Fatal(err)
    }
    defer source.Close()

    // Haal SVID op
    svid, err := source.GetX509SVID()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("SPIFFE ID: %s\n", svid.ID)

    // Gebruik voor mTLS
    tlsConfig := tlsconfig.MTLSClientConfig(source, source, tlsconfig.AuthorizeID(
        spiffeid.RequireID(spiffeid.MustParseSpiffeID("spiffe://example.com/ns/production/sa/database")),
    ))
}

Optie 3: Envoy sidecar met SDS

Als je de app niet kunt aanpassen en je mTLS volledig erbuiten wilt afhandelen, zet Envoy ervoor. Die haalt SVIDs op via de Secret Discovery Service en termineert TLS voor je:

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          image: my-app:v1.0.0
          ports:
            - containerPort: 8080
        - name: envoy
          image: envoyproxy/envoy:v1.28-latest
          volumeMounts:
            - name: envoy-config
              mountPath: /etc/envoy
            - name: spire-agent-socket
              mountPath: /run/spire/sockets

Envoy config voor SDS:

static_resources:
  clusters:
    - name: spire_agent
      connect_timeout: 1s
      type: STATIC
      http2_protocol_options: {}
      load_assignment:
        cluster_name: spire_agent
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    pipe:
                      path: /run/spire/sockets/agent.sock

    - name: backend
      connect_timeout: 1s
      type: STRICT_DNS
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
          common_tls_context:
            tls_certificate_sds_secret_configs:
              - name: "spiffe://example.com/ns/default/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://example.com"
              sds_config:
                api_config_source:
                  api_type: GRPC
                  grpc_services:
                    - envoy_grpc:
                        cluster_name: spire_agent

Service mesh integratie

Draai je al een service mesh, dan hoef je SPIRE er niet naast te plakken. De mesh kan SPIRE gebruiken als het ding dat identiteiten uitgeeft, zodat je één bron van waarheid krijgt in plaats van twee overlappende certificaatsystemen.

Istio

Istio kan SPIRE gebruiken als identity provider:

apiVersion: install.istio.io/v1alpha1
kind: IstioOperator
spec:
  meshConfig:
    trustDomain: example.com
  values:
    global:
      caAddress: spiffe-csi-driver:443

Linkerd

Linkerd’s identity systeem is SPIFFE-compatibel. Integratie is eenvoudig met de identity controller.

Cross-cluster federatie

Hier betaalt het ene naamschema zich uit. Twee clusters met aparte trust domains kunnen federeren, trust bundles uitwisselen, en daarna elkaars workloads verifiëren. Geen gedeelde CA, geen certs met de hand rondkopiëren:

Cluster A (production)

global:
  spire:
    trustDomain: production.example.com
    federation:
      enabled: true
      bundleEndpoint:
        address: spire-bundle.production.example.com
        port: 8443
      trustDomainBundles:
        - endpointURL: https://spire-bundle.staging.example.com:8443
          trustDomain: staging.example.com

Cluster B (staging)

global:
  spire:
    trustDomain: staging.example.com
    federation:
      enabled: true
      bundleEndpoint:
        address: spire-bundle.staging.example.com
        port: 8443
      trustDomainBundles:
        - endpointURL: https://spire-bundle.production.example.com:8443
          trustDomain: production.example.com

Nu kunnen services in staging identiteiten van production verifiëren en andersom.

Attestatie methoden

Tijd om de vraag te beantwoorden die ik eerder parkeerde: hoe weet SPIRE dat een pod die claimt api-server te zijn dat ook werkelijk is? Hij kan de pod niet zomaar op zijn woord geloven, anders is het hele ding toneel. Het antwoord is attestatie, en het gebeurt in twee fases.

Node Attestatie

# Hoe nodes hun identiteit bewijzen aan de server
nodeAttestor:
  k8sPsat:
    enabled: true
    cluster: production

De node bewijst dat het een legitieme Kubernetes node is met projected service account tokens.

Workload Attestatie

# Hoe workloads hun identiteit bewijzen aan de agent
workloadAttestors:
  k8s:
    enabled: true

De agent verifieert de workload’s container via de Kubernetes API. Twee checks, aan elkaar geknoopt: de node bewijst zichzelf aan de server, daarna bewijst de agent op die node de workload aan zichzelf. Geen enkele stap leunt op een secret dat de workload ergens vandaan gekopieerd kan hebben.

Autorisatie met OPA

Eén eerlijke kanttekening. SPIRE vertelt je wie er belt. Het vertelt je niet of die ook mág. Identiteit en autorisatie zijn verschillende klussen, en SPIRE doet alleen de eerste. Voor de tweede grijp ik naar Kyverno of OPA, met de SPIFFE ID rechtstreeks de policy in:

# OPA policy met SPIFFE IDs
package authz

default allow = false

allow {
    # Sta api-server toe database te benaderen
    input.source == "spiffe://example.com/ns/production/sa/api-server"
    input.destination == "spiffe://example.com/ns/production/sa/database"
}

allow {
    # Sta frontend toe api-server te benaderen
    input.source == "spiffe://example.com/ns/production/sa/frontend"
    input.destination == "spiffe://example.com/ns/production/sa/api-server"
}

Mijn productie setup

Alle lagen bij elkaar, hier is de config die ik echt draai. Niks exotisch, gewoon de defaults aangetrokken naar mijn smaak:

global:
  spire:
    trustDomain: infrastructure.internal
    clusterName: production

spire-server:
  replicaCount: 3

  dataStore:
    sql:
      databaseType: postgres
      connectionString: "postgres://spire:password@postgres:5432/spire?sslmode=require"

  nodeAttestor:
    k8sPsat:
      enabled: true
      serviceAccountAllowList:
        - spire-system:spire-agent

  ca:
    keyType: ec-p256
    ttl: 24h

spire-agent:
  socketPath: /run/spire/sockets/agent.sock

  workloadAttestors:
    k8s:
      enabled: true

  sds:
    enabled: true
    defaultBundleName: "null"
    defaultAllBundlesName: ROOTCA

spiffe-csi-driver:
  enabled: true

Belangrijke beslissingen, en waarom:

  • PostgreSQL backend houdt server state persistent zodat ik drie replicas kan draaien en een stervende node overleef
  • EC-P256 keys zitten op een verstandig punt tussen security en CPU kosten
  • 24h TTL betekent dat SVIDs vaak roteren, dus een gelekte credential is morgen waardeloos
  • CSI driver levert de SVID als volume af, wat schoner is dan in host paths porren

Troubleshooting

Als er iets stilvalt, draai ik deze drie commando’s voordat ik iets anders doe.

Check agent health

kubectl exec -n spire-system -it spire-agent-xxx -- \
  /opt/spire/bin/spire-agent healthcheck

Lijst geregistreerde workloads

kubectl exec -n spire-system -it spire-server-0 -- \
  /opt/spire/bin/spire-server entry show

Verifieer workload identity

kubectl exec -it my-pod -- \
  cat /run/spire/sockets/agent.sock  # Check socket bestaat

kubectl exec -it my-pod -- \
  openssl x509 -in /spiffe/svid.pem -text -noout | grep URI

Waarom dit ertoe doet

Terug naar het begin: een naam, een credential, een socket. Daarvandaan bouwden we op naar een systeem waar elke service in mijn cluster met crypto bewijst wie hij is, bij elk request, en waar ik nooit met de hand een certificaat aanraak.

Statische secrets waren altijd al een risico. Ze lekken, ze zijn pijnlijk te roteren, en het ergste is dat ze nooit de enige vraag beantwoorden die in zero trust telt: wie maakt dit request? SPIFFE en SPIRE beantwoorden die met bewijs in plaats van een wachtwoord.

Wat het me oplevert:

  • Automatische identity, dus workloads krijgen een naam zonder dat ik secrets uitdeel
  • Kortlevende credentials, dus een gestolen SVID verloopt voordat hij bruikbaar is
  • Cryptografisch bewijs, dus een identiteit kan niet worden vervalst of herhaald
  • Eén model overal, dus een cluster, een VM en een cloud instance spreken dezelfde taal

Dit doet er voor mij toe om dezelfde reden als self-hosting. Ik wil het mechanisme dat ik vertrouw begrijpen, helemaal tot op de bodem, en ik wil dat het blijft werken als één component omvalt. Workload identity waar ik over kan redeneren is het verschil tussen een breach die ingedamd blijft en een breach die alles overneemt. Dat is de setup kosten waard.


Identity zou automatisch en cryptografisch verifieerbaar moeten zijn. SPIFFE en SPIRE geven elke workload een identiteit die het nooit hoeft te beheren, en die een aanvaller niet kan stelen.