mTLS and SPIFFE/SPIRE: Cryptographic Service Identity
mTLS and SPIFFE/SPIRE: Cryptographic Service Identity
The Assumption
Service-to-service authentication is an API key in a header. The assumption: if the key matches, the caller is who they claim to be.
An API key proves possession of a secret. It does not prove identity. If the key leaks through a log, a config dump, or a compromised intermediate service, any process with that key becomes indistinguishable from the legitimate service. There is no per-instance identity. There is no automatic rotation. There is no attestation that the caller is actually the workload it claims to be.
The Attack
Lateral movement via leaked API key. An attacker compromises the Notification Service. The Notification Service’s environment variables contain the API key used to call the Billing Service. The attacker uses this key to call the Billing Service directly, retrieving billing data, modifying subscriptions, or triggering refunds. The Billing Service sees a valid API key and serves the request.
The blast radius is the union of all services that the compromised service could call. With shared API keys, one compromised service often means access to every other service in the mesh.
With mTLS, compromising the Notification Service gives the attacker the Notification Service’s certificate. This certificate identifies the caller as “notification-service.” The Billing Service can verify the caller’s identity and check whether “notification-service” is authorized to call billing endpoints. The attacker cannot impersonate “core-api” because they do not have core-api’s private key.
Certificate vs API key theft. An API key is a string. It can be copied, emailed, committed to git, or printed to a log. A private key bound to a SPIFFE SVID (SPIFFE Verifiable Identity Document) is stored in memory, delivered via a Unix domain socket, and rotated automatically. The attack surface is fundamentally different.
The Spec or Mechanism
mTLS Handshake
Standard TLS: the client verifies the server’s certificate. The server does not verify the client’s identity at the TLS level.
mTLS: both sides verify. The handshake adds client certificate presentation:
The diagram highlights the two steps that differentiate mTLS from standard TLS: the server’s CertificateRequest (step 4) and the client’s Certificate presentation (step 6). In standard TLS, neither step exists. The server accepts any client without identity verification at the transport layer. With mTLS, the server cryptographically verifies the client’s identity before application-layer processing begins, eliminating the need for shared secrets or API keys.
After the handshake, the server knows the client’s identity from the certificate’s Subject (CN or SAN). No tokens needed for transport-level identity.
SPIFFE/SPIRE
SPIFFE (Secure Production Identity Framework for Everyone) defines a standard for service identity:
-
SPIFFE ID:
spiffe://saas.example/ns/production/sa/core-api- Trust domain:
saas.example - Path:
/ns/production/sa/core-api(namespace, service account)
- Trust domain:
-
SVID (SPIFFE Verifiable Identity Document): An X.509 certificate or JWT containing the SPIFFE ID. For mTLS, the X.509 SVID is used.
SPIRE (SPIFFE Runtime Environment) implements the SPIFFE standard:
The architecture divides trust into three layers. The SPIRE Server is the root of trust, signing SVIDs and storing registration entries. SPIRE Agents run on every node, cache SVIDs locally, and expose the Workload API over a Unix domain socket. Workloads never contact the SPIRE Server directly. Workload attestation uses kernel-level inspection of /proc/{pid} to verify the calling process’s UID, binary path, and Kubernetes pod labels before issuing an SVID. Even if an attacker gains shell access to a node, they cannot request SVIDs for other workloads unless they match the attestation selectors.
The Implementation
Spring Boot mTLS Server Configuration
# application.yml
server:
ssl:
enabled: true
client-auth: need # Require client certificate
key-store: /run/spire/svid-keystore.p12
key-store-password: ${SVID_KEYSTORE_PASSWORD}
key-store-type: PKCS12
trust-store: /run/spire/trust-bundle.p12
trust-store-password: ${TRUST_BUNDLE_PASSWORD}
trust-store-type: PKCS12
X509 Authentication in Spring Security
// VULNERABLE: API key authentication
@Bean
public SecurityFilterChain vulnerableChain(HttpSecurity http) throws Exception {
return http
.addFilterBefore(apiKeyFilter(), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/internal/**").authenticated()
)
.build();
}
// The API key filter:
// 1. Reads X-API-Key header
// 2. Compares against stored key
// 3. If match: authenticated
// Problem: key is a string, can be copied, leaked, shared
// HARDENED: mTLS with X509 authentication
@Bean
public SecurityFilterChain hardenedChain(HttpSecurity http) throws Exception {
return http
.x509(x509 -> x509
.subjectPrincipalRegex("CN=(.*?)(?:,|$)")
.userDetailsService(serviceIdentityUserDetailsService())
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/internal/billing/**")
.hasAuthority("SERVICE_CORE_API")
.requestMatchers("/internal/analytics/**")
.hasAnyAuthority("SERVICE_CORE_API", "SERVICE_ANALYTICS")
.requestMatchers("/internal/**").authenticated()
.anyRequest().denyAll()
)
.build();
}
@Bean
public UserDetailsService serviceIdentityUserDetailsService() {
// Map certificate CN to service authorities
return cn -> {
Map<String, List<String>> serviceAuthorities = Map.of(
"core-api", List.of("SERVICE_CORE_API"),
"billing-service", List.of("SERVICE_BILLING"),
"notification-service", List.of("SERVICE_NOTIFICATION"),
"analytics-service", List.of("SERVICE_ANALYTICS")
);
List<String> authorities = serviceAuthorities.get(cn);
if (authorities == null) {
throw new UsernameNotFoundException("Unknown service: " + cn);
}
return User.withUsername(cn)
.password("") // No password, certificate-authenticated
.authorities(authorities.stream()
.map(SimpleGrantedAuthority::new)
.toArray(GrantedAuthority[]::new))
.build();
};
}
SPIFFE-Aware Authentication
For SPIFFE SVIDs, the identity is in the SAN (Subject Alternative Name) URI extension, not the CN:
@Component
public class SpiffeAuthenticationProvider implements AuthenticationProvider {
private static final Pattern SPIFFE_PATTERN =
Pattern.compile("spiffe://saas\\.example/ns/([^/]+)/sa/([^/]+)");
@Override
public Authentication authenticate(Authentication authentication) {
X509Certificate cert = ((X509CertificateAuthentication) authentication)
.getCertificate();
// Extract SPIFFE ID from SAN URI
String spiffeId = extractSpiffeId(cert);
if (spiffeId == null) {
throw new BadCredentialsException("No SPIFFE ID in certificate");
}
Matcher matcher = SPIFFE_PATTERN.matcher(spiffeId);
if (!matcher.matches()) {
throw new BadCredentialsException(
"SPIFFE ID does not match trust domain: " + spiffeId);
}
String namespace = matcher.group(1);
String serviceAccount = matcher.group(2);
// Build authorities from SPIFFE ID components
List<GrantedAuthority> authorities = List.of(
new SimpleGrantedAuthority("NAMESPACE_" + namespace),
new SimpleGrantedAuthority("SERVICE_" + serviceAccount)
);
return new SpiffeAuthentication(spiffeId, namespace, serviceAccount, authorities);
}
private String extractSpiffeId(X509Certificate cert) {
try {
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
if (sans == null) return null;
return sans.stream()
.filter(san -> (Integer) san.get(0) == 6) // URI type
.map(san -> (String) san.get(1))
.filter(uri -> uri.startsWith("spiffe://"))
.findFirst()
.orElse(null);
} catch (CertificateParsingException e) {
return null;
}
}
@Override
public boolean supports(Class<?> authentication) {
return X509CertificateAuthentication.class.isAssignableFrom(authentication);
}
}
SVID Auto-Rotation
SPIRE delivers short-lived SVIDs (default: 1 hour) and rotates them automatically before expiry. The Spring Boot application needs to reload the keystore when the SVID is rotated:
@Component
public class SvidRotationWatcher {
private final SslBundleRegistry sslBundleRegistry;
private final Path svidPath = Path.of("/run/spire/svid-keystore.p12");
@Scheduled(fixedDelay = 30, timeUnit = TimeUnit.SECONDS)
public void checkForRotation() {
try {
// SPIRE agent writes new SVID to the same path
// Check if the file has been modified
BasicFileAttributes attrs = Files.readAttributes(svidPath,
BasicFileAttributes.class);
if (isNewer(attrs.lastModifiedTime())) {
// Reload the SSL context with the new SVID
sslBundleRegistry.updateBundle("server",
SslBundle.of(createSslStoreBundle()));
log.info("SVID rotated, SSL context reloaded");
}
} catch (IOException e) {
log.error("Failed to check SVID rotation", e);
}
}
}
The Verification
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MtlsAuthenticationTest {
@Test
void requestWithValidClientCertIsAuthenticated() throws Exception {
// Create test client with valid certificate
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(
new KeyManager[]{loadClientKeyManager("core-api")},
new TrustManager[]{loadTrustManager()},
new SecureRandom());
HttpClient client = HttpClient.newBuilder()
.sslContext(sslContext)
.build();
HttpResponse<String> response = client.send(
HttpRequest.newBuilder()
.uri(URI.create("https://localhost:" + port + "/internal/health"))
.build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
}
@Test
void requestWithoutClientCertIsRejected() throws Exception {
// Client without certificate
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{loadTrustManager()}, new SecureRandom());
HttpClient client = HttpClient.newBuilder()
.sslContext(sslContext)
.build();
assertThatThrownBy(() -> client.send(
HttpRequest.newBuilder()
.uri(URI.create("https://localhost:" + port + "/internal/health"))
.build(),
HttpResponse.BodyHandlers.ofString()))
.isInstanceOf(IOException.class); // TLS handshake failure
}
@Test
void serviceCanOnlyAccessAuthorizedEndpoints() throws Exception {
HttpClient analyticsClient = createClientWithCert("analytics-service");
// Analytics can access analytics endpoints
HttpResponse<String> allowedResponse = analyticsClient.send(
HttpRequest.newBuilder()
.uri(URI.create("https://localhost:" + port + "/internal/analytics/data"))
.build(),
HttpResponse.BodyHandlers.ofString());
assertThat(allowedResponse.statusCode()).isEqualTo(200);
// Analytics cannot access billing endpoints
HttpResponse<String> deniedResponse = analyticsClient.send(
HttpRequest.newBuilder()
.uri(URI.create("https://localhost:" + port + "/internal/billing/invoices"))
.build(),
HttpResponse.BodyHandlers.ofString());
assertThat(deniedResponse.statusCode()).isEqualTo(403);
}
}
The third test proves the authorization model: service identity determines endpoint access. The analytics-service certificate authenticates the caller, but the authorization check (.hasAuthority("SERVICE_CORE_API") on billing endpoints) rejects the request because analytics-service does not have the CORE_API authority. Identity verification and authorization are separate concerns, even at the service-to-service level.