I remember the exact moment I decided I could no longer trust my own network. It was 3 AM, and I was staring at a production incident where an internal service had been compromised because someone had mistakenly shared a JWT secret across repositories. The service in question had no idea who was calling it—it just saw a valid token and let the request through. That night, I realized that relying on network boundaries and shared secrets was a house of cards held together by hope. I needed something cryptographically certain, identity-based, and immune to credential leakage. That is what led me to zero-trust API security with mutual TLS (mTLS) and the SPIFFE identity framework.
Let me walk you through how I built it in Spring Boot, using Spring Security to enforce policy based on cryptographically verified service identities. I will show you not just the configuration, but the mental shift required to think in zero-trust terms.
Why zero-trust? Because the perimeter is dead
For years we relied on firewalls, private subnets, and VPNs. Inside the network, we trusted everything. But today’s architectures—microservices, containers, serverless—have no clear boundary. A compromised container can move laterally and talk to any internal service. Zero-trust says: never trust, always verify. Every request must be authenticated and authorized, regardless of origin.
That sounds like JWT-based authentication, right? I thought so too. But JWTs have a fundamental weakness: they must be validated against a central authority or shared secret. If an attacker steals your JWT signing key, they can forge tokens for any service. And tokens often have long lifetimes—minutes to hours—during which they can be reused. mTLS offers something different: each service presents a certificate that is short-lived (often 1–8 hours) and automatically rotated by an infrastructure agent. No shared secrets, no tokens to leak. The certificate itself is the identity.
The foundation: mutual TLS in Spring Boot
Configuring mTLS in Spring Boot is surprisingly straightforward, but you need to understand what you’re asking for. Server-side TLS is common; client-certificate verification is not. In mTLS, the server requests a certificate from the client and verifies it against a truststore. Let me show you the minimal configuration for an embedded Tomcat server.
First, create a keystore for the server and a separate truststore containing the CA that will sign client certificates. For testing, I used a self-signed CA. In production, you would use SPIFFE/SPIRE to provision these automatically.
# application.yml
server:
port: 8443
ssl:
enabled: true
client-auth: need # forces client certificate
key-store: classpath:server-keystore.p12
key-store-password: changeit
key-store-type: PKCS12
trust-store: classpath:client-truststore.p12
trust-store-password: changeit
trust-store-type: PKCS12
With client-auth: need, the server refuses any connection that does not present a valid client certificate signed by the CA in the truststore. Simple, but effective.
But here’s the problem: who is the client? The certificate just says “trust me, I’m signed by this CA.” We need to know the identity of the calling service. That is where SPIFFE comes in.
Identity with SPIFFE and SVIDs
SPIFFE defines a standard URI-based identity: spiffe://yourdomain.com/ns/production/sa/order-service. The SPIRE project implements this standard. Each workload (service) gets an agent that fetches a short-lived X.509 certificate (called an SVID) from a central SPIRE server. The certificate’s Subject Alternative Name (SAN) contains the SPIFFE ID. The agent rotates the certificate every hour or two, storing it at a known path like /var/run/spire/sockets/agent.sock.
In my Spring Boot service, I do not run the SPIRE agent inside the container; I run it as a sidecar. The agent exposes a Unix domain socket (or TCP socket) that the application can connect to via the Java SPIFFE library. Here’s how I initialise the workload API client and fetch the SVID:
@Component
public class SpiffeIdentityProvider {
private final WorkloadApiClient workloadApiClient;
public SpiffeIdentityProvider() throws Exception {
// Connect to SPIRE agent socket
this.workloadApiClient = DefaultWorkloadApiClient
.builder()
.agentAddress(UnixDomainSocketAddress.of("/var/run/spire/agent.sock"))
.build();
}
public SpiffeIdentity currentIdentity() throws Exception {
X509Svid svid = workloadApiClient.fetchX509Svid();
// Extract SPIFFE ID from certificate SAN
String spiffeId = svid.getSpiffeId().toString();
return new SpiffeIdentity(spiffeId, svid.getCertificate());
}
}
Every few minutes, I poll the SPIRE agent to get the latest SVID. If the certificate has rotated, I update the service’s SSL context dynamically (more on that later).
Enforcing policies with Spring Security
Now that we have trust at the transport layer (mTLS) and identity at the application layer (SPIFFE IDs), we need to enforce access control. Spring Security’s filter chain is my weapon of choice. I created a custom filter that extracts the client certificate from the request and maps it to a SPIFFE ID.
But wait—how do we get the certificate from the request? When mTLS is configured, the servlet container populates jakarta.servlet.request.X509Certificate. In Spring Boot, I can access it like this:
@Component
public class SpiffeAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
X509Certificate[] certs = (X509Certificate[])
request.getAttribute("jakarta.servlet.request.X509Certificate");
if (certs == null || certs.length == 0) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "No client certificate");
return;
}
String spiffeId = extractSpiffeId(certs[0]);
// Create a Spring Security authentication token
SpiffeAuthenticationToken auth =
new SpiffeAuthenticationToken(spiffeId, certs[0]);
SecurityContextHolder.getContext().setAuthentication(auth);
chain.doFilter(request, response);
}
private String extractSpiffeId(X509Certificate cert) {
// Iterate SANs and look for SPIFFE URI
// Using java-spiffe-core library
return SpiffeId.fromCertificate(cert).toString();
}
}
Have you ever wondered how to handle rotation of trust material without restarting the service? I had the same question. In production, certificates expire hourly. My solution was to use a scheduled task that reconnects to the SPIRE agent and replaces the SSLContext in the embedded server. Tomcat supports hot-reload of SSL contexts if you use the correct API. Here’s the gist:
@Component
public class SslReloader {
private final SslBundleRegistrar bundleRegistrar;
@Scheduled(fixedRate = 60_000) // check every minute
public void reloadIfRotated() {
// Fetch new SVID from SPIRE agent
// Convert to KeyStore and TrustStore
// Update Tomcat's SSLHostConfig
}
}
Full implementation requires careful handling of thread safety, but it works reliably.
Testing the whole thing without a real cluster
You might ask: how do I test this locally without a full SPIFFE infrastructure? I use Testcontainers to spin up a SPIRE server and agent inside containers, then configure my Spring Boot tests to use those. Let me show you the test setup:
@SpringBootTest
@Testcontainers
class ZeroTrustIntegrationTest {
@Container
static SpireContainer spire = new SpireContainer("ghcr.io/spiffe/spire-server:1.9.0")
.withServerConfig("test-spire-server.conf");
@Container
static SpireAgentContainer agent = new SpireAgentContainer("ghcr.io/spiffe/spire-agent:1.9.0")
.withServer(spire);
@Test
void testServiceToServiceCallWithMutualTls() {
// The test uses the SPIRE agent's socket to fetch an SVID for the client
// Then makes a HTTPS request to the order-service with that certificate
// Expects 200 OK with proper identity
}
}
This setup verifies the entire chain: certificate provisioning, mTLS handshake, SPIFFE extraction, and Spring Security authorization.
A personal lesson: trust spans the whole chain
Early on, I made a mistake. I configured mTLS correctly but forgot to validate that the calling service’s SPIFFE ID was actually allowed to call my endpoint. I had written a Spring Security AccessDecisionVoter that checked the SPIFFE ID against a whitelist, but I had left the default Spring Security permitAll endorsement on the endpoints. The mTLS handshake succeeded, the certificate was valid, but any SPIFFE ID was accepted. That is not zero-trust. You must verify both the certificate chain and the identity claim. I learned the hard way to never skip the authorization layer.
Here is the final piece: a method-level security expression that only allows services from a specific namespace and service account:
@PreAuthorize("hasSpiffeId('spiffe://myorg.com/ns/production/sa/order-service')")
@PostMapping("/orders")
public Order createOrder(@RequestBody OrderRequest req) {
// only order-service can call this
}
The custom hasSpiffeId method is implemented in a MethodSecurityExpressionHandler that checks the current SpiffeAuthenticationToken.
When not to use mTLS for microservices?
I would be dishonest if I did not mention situations where mTLS with SPIFFE is overkill. If you have a handful of services running on the same Kubernetes cluster with strong network policies and you already use Istio or Linkerd, you can let the service mesh handle mTLS. But if you need fine-grained, application-level control—like allowing only specific service accounts to access certain endpoints—then the approach I described gives you surgical precision.
Also, Java’s handling of mTLS can be resource-intensive for very high-throughput systems. Each handshake involves asymmetric cryptography. Use persistent HTTP connections (keep-alive) to reduce handshake frequency.
The shift in mindset
Zero-trust is not a product you buy; it is a philosophy you adopt. Every request, every call, every connection must be verified. With mTLS and SPIFFE, I no longer worry about token theft or leaked secrets. The identity is bound to the workload itself, and it changes every hour. If a container is compromised, the attacker gets a certificate that will expire soon and cannot be reused elsewhere.
I encourage you to try this in a small test project. Set up a SPIRE server, register a workload, configure Spring Boot with mTLS, and watch the logs confirm that every call is authenticated and authorized. Once you see that chain of trust in action, you will never go back to trusting your internal network again.
If you found this approach useful, please like, share, and comment below. What other zero-trust patterns have you implemented in your Spring applications? I’d love to hear how you handle certificate rotation and identity propagation across async boundaries.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva