Zero-Trust Service-to-Service Authentication with mTLS and SPIFFE/SPIRE in Kubernetes
Introduction
In a microservices architecture, the question "who is calling me?" is surprisingly hard to answer securely. Network policies restrict traffic at the IP level, but IP addresses are not identities. A compromised pod inside a namespace can impersonate any other service. JWT tokens carry user identity but say nothing about the calling service itself. Long-lived TLS certificates stored in Kubernetes Secret objects are static, manually rotated, and a persistent target for attackers.
The CNCF-graduated SPIFFE (Secure Production Identity Framework For Everyone) project was designed to solve exactly this problem. Combined with its reference implementation SPIRE (SPIFFE Runtime Environment), it gives every workload a cryptographically verified identity in the form of short-lived X.509 certificates — automatically issued, automatically rotated, with no secrets stored in etcd.
In this tutorial you will:
- Deploy SPIRE v1.10.2 on Kubernetes using the hardened Helm chart
- Auto-register workloads using the
ClusterSPIFFEIDCRD - Build two Spring Boot 3.3 services that perform mutual TLS (mTLS) using SPIFFE-issued certificates
- Validate the caller's SPIFFE identity in a servlet filter — enforcing fine-grained, cryptographic service-to-service authorization
- Leverage Spring Boot 3.2 SSL hot-reload so certificates rotate without a restart
This approach requires zero service mesh. No sidecars. No Istio control plane. Just SPIRE running as a DaemonSet + StatefulSet and a few dozen lines of Spring configuration.
Prerequisites
- Kubernetes cluster 1.28+ (kind, k3d, EKS, GKE, AKS all work)
kubectlandhelmv3.13+ configured- Java 21 and Maven 3.9+
- Docker (to build service images)
- Basic familiarity with Kubernetes deployments and Spring Boot
Understanding SPIFFE and SPIRE
What is a SPIFFE ID?
A SPIFFE ID is a URI that uniquely names a workload:
spiffe://prod.example.com/ns/payments/sa/payment-service
The components are:
- Trust domain:
prod.example.com— your organisation's domain, owned by the SPIRE Server - Path:
/ns/payments/sa/payment-service— encodes namespace and service account
What is an SVID?
A SVID (SPIFFE Verifiable Identity Document) is how a workload proves its SPIFFE ID. For mTLS, the SVID is an X.509 certificate where the SPIFFE ID appears as a URI Subject Alternative Name. It is:
- Short-lived (~1 hour TTL)
- Signed by the SPIRE Server's CA
- Auto-renewed by the SPIRE Agent before expiry — the workload never sees a certificate expiry
Attestation Flow
When a pod starts, the SPIRE Agent (running as a DaemonSet on every node) does two things:
- Node attestation: Proves to the SPIRE Server that it is running on a legitimate Kubernetes node using a Projected Service Account Token (PSAT). The Server verifies the token against the Kubernetes API.
- Workload attestation: When the pod's process connects to the Workload API Unix socket (
/run/spire/sockets/agent.sock), the Agent identifies the calling pod by its kernel process context, then queries the Kubernetes API for pod metadata (namespace, service account, labels). It matches this metadata against registeredEntryobjects in the Server. On a match, the Server signs and returns the SVID.
The SPIFFE CSI Driver simplifies delivery: instead of the app connecting to the socket, the CSI Driver projects the certificate files directly into the pod's filesystem — svid.0.pem, svid.0.key.pem, and bundle.0.pem — and keeps them fresh.
Setting Up SPIRE on Kubernetes
The official hardened Helm chart deploys everything you need: the SPIRE Server (StatefulSet), SPIRE Agent (DaemonSet), SPIRE Controller Manager (for ClusterSPIFFEID CRDs), and the SPIFFE CSI Driver.
# Add the SPIFFE Helm repository
helm repo add spiffe https://spiffe.github.io/helm-charts-hardened/
helm repo update
# Install CRDs first (required before the main chart)
helm upgrade --install spire-crds spiffe/spire-crds \
--namespace spire-system \
--create-namespace
# Install SPIRE
helm upgrade --install spire spiffe/spire \
--namespace spire-system \
--set global.trustDomain="prod.example.com" \
--set global.clusterName="my-cluster" \
--wait
Verify all pods are running:
kubectl get pods -n spire-system
# NAME READY STATUS RESTARTS
# spire-agent-xxxxx 1/1 Running 0 (DaemonSet — one per node)
# spire-server-0 1/1 Running 0 (StatefulSet)
# spire-controller-manager-xxxxx 1/1 Running 0
# spiffe-csi-driver-xxxxx 2/2 Running 0
Create the payments namespace for our demo services:
kubectl create namespace payments
kubectl label namespace payments kubernetes.io/metadata.name=payments
Create service accounts for each service:
kubectl create serviceaccount payment-service -n payments
kubectl create serviceaccount order-service -n payments
Registering Workloads with ClusterSPIFFEID
The SPIRE Controller Manager watches ClusterSPIFFEID custom resources and automatically creates SPIRE registration entries for matching pods. This is infrastructure-as-code for workload identity.
Create k8s/spire-setup.yaml:
# ClusterSPIFFEID for payment-service
apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
name: payment-service-id
spec:
spiffeIDTemplate: "spiffe://prod.example.com/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}"
podSelector:
matchLabels:
app: payment-service
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: payments
---
# ClusterSPIFFEID for order-service
apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
name: order-service-id
spec:
spiffeIDTemplate: "spiffe://prod.example.com/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}"
podSelector:
matchLabels:
app: order-service
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: payments
Apply it:
kubectl apply -f k8s/spire-setup.yaml
When pods labelled app: payment-service or app: order-service start in the payments namespace, the Controller Manager will automatically register them. Their SPIFFE IDs will be:
spiffe://prod.example.com/ns/payments/sa/payment-service
spiffe://prod.example.com/ns/payments/sa/order-service
Building the Payment Service (Server-Side mTLS)
The payment service is the server — it listens on port 8443 with mTLS enforced. It requires every caller to present a valid SPIFFE certificate from the same trust domain.
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>
<groupId>com.vimleshpandey</groupId>
<artifactId>spiffe-mtls-demo</artifactId>
<version>1.0.0</version>
<name>spiffe-mtls-demo</name>
<properties>
<java.version>21</java.version>
<spiffe.version>0.8.17</spiffe.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- SPIFFE Java provider: fetches SVIDs from Workload API -->
<dependency>
<groupId>io.spiffe</groupId>
<artifactId>java-spiffe-provider</artifactId>
<version>${spiffe.version}</version>
</dependency>
<dependency>
<groupId>io.spiffe</groupId>
<artifactId>java-spiffe-core</artifactId>
<version>${spiffe.version}</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml
Spring Boot 3.1 introduced SSL bundles, and 3.2 added hot-reload. Both are essential here: the bundle points to the PEM files written by the SPIFFE CSI Driver, and hot-reload picks up the refreshed certificates that SPIRE rotates every hour.
server:
port: 8443
ssl:
enabled: true
client-auth: need # enforces mutual TLS — rejects any client without a cert
bundle: spiffe-bundle
spring:
ssl:
bundle:
pem:
spiffe-bundle:
reload-on-update: true # Spring Boot 3.2 hot-reload
keystore:
certificate: /run/spire/sockets/svid.0.pem
private-key: /run/spire/sockets/svid.0.key.pem
truststore:
certificate: /run/spire/sockets/bundle.0.pem
watch:
enabled: true
file:
quiet-period: 10s # wait 10s after a cert change before reloading
management:
server:
port: 8080 # actuator on plaintext for liveness/readiness probes
endpoints:
web:
exposure:
include: health,info
spiffe:
trust-domain: prod.example.com
allowed-namespaces:
- payments
PaymentApplication.java
package com.vimleshpandey.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PaymentApplication {
public static void main(String[] args) {
SpringApplication.run(PaymentApplication.class, args);
}
}
PaymentController.java
package com.vimleshpandey.demo.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
@PostMapping
public ResponseEntity<Map<String, Object>> processPayment(
@RequestBody Map<String, Object> request,
jakarta.servlet.http.HttpServletRequest httpRequest) {
// The caller's SPIFFE ID is already validated by SpiffeIdFilter.
// Here we can read it for audit logging.
X509Certificate[] certs = (X509Certificate[]) httpRequest
.getAttribute("jakarta.servlet.request.X509Certificate");
String callerSpiffeId = extractSpiffeId(certs);
return ResponseEntity.ok(Map.of(
"status", "PROCESSED",
"transactionId", java.util.UUID.randomUUID().toString(),
"calledBy", callerSpiffeId,
"amount", request.getOrDefault("amount", 0)
));
}
@GetMapping("/health")
public ResponseEntity<Map<String, String>> health() {
return ResponseEntity.ok(Map.of("status", "UP"));
}
private String extractSpiffeId(X509Certificate[] certs) {
if (certs == null || certs.length == 0) return "unknown";
try {
Collection<List<?>> sans = certs[0].getSubjectAlternativeNames();
if (sans == null) return "unknown";
return sans.stream()
.filter(san -> Integer.valueOf(6).equals(san.get(0)))
.map(san -> (String) san.get(1))
.findFirst()
.orElse("unknown");
} catch (Exception e) {
return "unknown";
}
}
}
Validating SPIFFE IDs in Spring Security Filter
The mTLS handshake guarantees that the calling service presented a certificate signed by our SPIRE CA. The SpiffeIdFilter goes further — it checks that the URI SAN of the certificate is a SPIFFE ID from our trust domain, from the expected namespace. This implements workload-level authorization, not just authentication.
package com.vimleshpandey.demo.security;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.List;
@Component
public class SpiffeIdFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(SpiffeIdFilter.class);
@Value("${spiffe.trust-domain}")
private String trustDomain;
@Value("${spiffe.allowed-namespaces}")
private List<String> allowedNamespaces;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
// Skip actuator health checks (plaintext port 8080) — no client cert expected
if (req.getLocalPort() == 8080) {
chain.doFilter(req, res);
return;
}
X509Certificate[] certs = (X509Certificate[]) req
.getAttribute("jakarta.servlet.request.X509Certificate");
if (certs == null || certs.length == 0) {
log.warn("mTLS rejected: no client certificate from {}", req.getRemoteAddr());
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Client certificate required");
return;
}
String spiffeId = extractSpiffeId(certs[0]);
if (spiffeId == null) {
log.warn("mTLS rejected: certificate has no SPIFFE URI SAN");
res.sendError(HttpServletResponse.SC_FORBIDDEN, "SPIFFE ID required");
return;
}
if (!isAllowed(spiffeId)) {
log.warn("mTLS rejected: SPIFFE ID {} not in allowlist", spiffeId);
res.sendError(HttpServletResponse.SC_FORBIDDEN,
"SPIFFE ID not authorised: " + spiffeId);
return;
}
log.debug("mTLS accepted from SPIFFE ID: {}", spiffeId);
chain.doFilter(req, res);
}
private String extractSpiffeId(X509Certificate cert) {
try {
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
if (sans == null) return null;
return sans.stream()
.filter(san -> Integer.valueOf(6).equals(san.get(0))) // 6 = URI SAN
.map(san -> (String) san.get(1))
.filter(uri -> uri.startsWith("spiffe://"))
.findFirst()
.orElse(null);
} catch (Exception e) {
return null;
}
}
private boolean isAllowed(String spiffeId) {
// Must be from our trust domain
String prefix = "spiffe://" + trustDomain + "/ns/";
if (!spiffeId.startsWith(prefix)) return false;
// Must be from an allowed namespace
String remainder = spiffeId.substring(prefix.length());
return allowedNamespaces.stream()
.anyMatch(ns -> remainder.startsWith(ns + "/"));
}
}
Building the Order Service (Client-Side mTLS)
The order service is the client — it calls the payment service using a WebClient configured with the SPIFFE-issued certificate as its client identity. Spring Boot 3.3 makes this clean via SslBundles.
MtlsClientConfig.java
package com.vimleshpandey.demo.config;
import io.netty.handler.ssl.ClientAuth;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.SslProvider;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
@Configuration
public class MtlsClientConfig {
/**
* WebClient that presents the pod's SPIFFE X.509-SVID on every outbound TLS connection.
* Spring Boot 3.2 SSL hot-reload updates the bundle in place when SPIRE rotates the cert,
* so this bean does not need to be recreated.
*/
@Bean
public WebClient mtlsWebClient(SslBundles sslBundles) throws Exception {
SslBundle bundle = sslBundles.getBundle("spiffe-bundle");
SslContext nettySsl = SslContextBuilder.forClient()
.sslProvider(SslProvider.JDK)
.keyManager(bundle.getManagers().getKeyManagerFactory())
.trustManager(bundle.getManagers().getTrustManagerFactory())
.clientAuth(ClientAuth.REQUIRE)
.build();
HttpClient httpClient = HttpClient.create()
.secure(spec -> spec.sslContext(nettySsl));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.baseUrl("https://payment-service.payments.svc.cluster.local:8443")
.build();
}
}
Call the payment service from an order handler:
package com.vimleshpandey.demo.controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.Map;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final WebClient mtlsWebClient;
public OrderController(WebClient mtlsWebClient) {
this.mtlsWebClient = mtlsWebClient;
}
@PostMapping
public Mono<Map> createOrder(@RequestBody Map<String, Object> orderRequest) {
// Forward payment portion to payment-service over mTLS
return mtlsWebClient.post()
.uri("/api/payments")
.bodyValue(Map.of("amount", orderRequest.get("amount")))
.retrieve()
.bodyToMono(Map.class);
}
}
Testing the mTLS Handshake
package com.vimleshpandey.demo;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.http.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration test: verifies that the mTLS endpoint rejects plain HTTP
* and that the actuator health endpoint (plaintext) remains accessible.
*
* For a full mTLS round-trip test in CI, use Testcontainers with a SPIRE
* dev-mode container or generate a self-signed cert with the SPIFFE URI SAN.
*/
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class MtlsIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private SslBundles sslBundles;
@Test
void actuatorHealthReturnsUp() {
// Actuator is on plaintext port 8080 — no cert needed
ResponseEntity<String> response = restTemplate
.getForEntity("/actuator/health", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("UP");
}
@Test
void sslBundleIsConfigured() {
SslBundle bundle = sslBundles.getBundle("spiffe-bundle");
assertThat(bundle).isNotNull();
assertThat(bundle.getManagers().getKeyManagerFactory()).isNotNull();
assertThat(bundle.getManagers().getTrustManagerFactory()).isNotNull();
}
}
Running the Application
Kubernetes Deployment
Create k8s/payment-service-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-service
namespace: payments
spec:
replicas: 2
selector:
matchLabels:
app: payment-service
template:
metadata:
labels:
app: payment-service
spec:
serviceAccountName: payment-service
containers:
- name: app
image: your-registry/payment-service:1.0.0
ports:
- containerPort: 8443 # mTLS
- containerPort: 8080 # actuator
volumeMounts:
- name: spiffe-certs
mountPath: /run/spire/sockets
readOnly: true
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 15
env:
- name: SPRING_PROFILES_ACTIVE
value: kubernetes
volumes:
- name: spiffe-certs
csi:
driver: "csi.spiffe.io"
readOnly: true
---
apiVersion: v1
kind: Service
metadata:
name: payment-service
namespace: payments
spec:
selector:
app: payment-service
ports:
- name: mtls
port: 8443
targetPort: 8443
- name: actuator
port: 8080
targetPort: 8080
Deploy:
# Build and push
docker build -t your-registry/payment-service:1.0.0 .
docker push your-registry/payment-service:1.0.0
# Apply manifests
kubectl apply -f k8s/payment-service-deployment.yaml
# Verify the SPIFFE cert was delivered
kubectl exec -n payments deploy/payment-service -- \
openssl x509 -in /run/spire/sockets/svid.0.pem -noout -text | grep URI
# URI:spiffe://prod.example.com/ns/payments/sa/payment-service
Local Development (without SPIRE)
For local development, generate a self-signed certificate with the SPIFFE URI SAN using openssl, point application.yml at those files, and disable client-auth:
# Generate a test SVID-shaped certificate
openssl req -x509 -newkey rsa:2048 -keyout svid.key.pem -out svid.pem \
-days 1 -nodes \
-subj "/CN=payment-service" \
-addext "subjectAltName=URI:spiffe://prod.example.com/ns/payments/sa/payment-service"
Common Pitfalls and Tips
1. Use the CSI Driver, not the Unix socket
The SPIFFE CSI Driver (csi.spiffe.io) projects certificate files into the pod — no need for a hostPath volume or socket permissions. In production this is the right choice: the Driver handles file permissions and updates automatically.
2. Certificate files are PEM, not KeyStore
The CSI Driver writes svid.0.pem, svid.0.key.pem, and bundle.0.pem. Spring Boot's spring.ssl.bundle.pem reads these directly — no KeyStore conversion required.
3. Enable SSL hot-reloadreload-on-update: true on the PEM bundle combined with spring.ssl.bundle.watch.enabled=true makes Spring Boot watch the cert files for changes and reload the SSLContext automatically. Since SPIRE rotates certs every ~55 minutes (5-minute pre-expiry window), this means zero downtime on rotation.
4. Archived library warning
Do NOT use com.transferwise.spiffe:java-spiffe — that repository was archived in May 2024. The current library is io.spiffe:java-spiffe-provider and io.spiffe:java-spiffe-core from https://github.com/spiffe/java-spiffe.
5. SVIDs are not Kubernetes service accounts
SPIFFE IDs are a separate identity plane for service-to-service mTLS. They do not replace Kubernetes RBAC or pod service accounts. The service account is used during attestation (to prove workload identity to SPIRE) but the resulting SVID is independent of Kubernetes permissions.
6. SPIFFE ID paths are assertions, not lookup keys
The path in a SPIFFE ID like /ns/payments/sa/payment-service is constructed from the ClusterSPIFFEID template. Validate the full URI in your filter — do not assume a cert from the right trust domain comes from an authorised namespace.
7. Multi-cluster federation
If you have multiple clusters, each SPIRE Server can federate with others to establish cross-cluster trust. This is advanced configuration — start with a single trust domain and add federation once the basic setup is solid.
Conclusion
You now have a zero-trust service identity plane running in Kubernetes — no service mesh, no static secrets, no manually rotated certificates. SPIRE issues each workload a fresh X.509-SVID every hour; the SPIFFE CSI Driver delivers it as files; Spring Boot 3.3's SSL bundle configuration hot-reloads it transparently.
The SpiffeIdFilter enforces workload-level authorization at the application layer — the payment service rejects any caller whose certificate lacks a valid SPIFFE URI from the expected namespace, even if the mTLS handshake itself succeeded. This is the zero-trust principle applied to east-west traffic: every call is authenticated, every identity is verified, and trust is never inferred from network position.
From here, consider:
- Federating trust domains across clusters for multi-region deployments
- Replacing static Kubernetes Secrets for database credentials with SPIRE-federated Vault dynamic secrets
- Adding SPIFFE ID claims to audit logs for a complete service-to-service call graph
- Istio with SPIRE backend if you eventually want transparent mTLS without application code changes
The SPIFFE specification is vendor-neutral and infrastructure-agnostic — the same identity model works on bare metal, VMs, and cloud-managed Kubernetes alike.