Application-Level Mutual TLS for Securing a Server-Side Rendering Service against a Spring Boot API


The initial security mandate was clear: transition our internal network architecture towards a zero-trust model. A recent audit flagged a critical communication path as non-compliant: our Node.js Server-Side Rendering (SSR) service was calling a downstream Spring Boot data API using a simple bearer token over standard TLS. While the channel was encrypted, the API server had no cryptographic proof of the caller’s identity. Any service with a valid, leaked token could impersonate our SSR frontend. The remediation task was to implement mutual TLS (mTLS), ensuring both client and server cryptographically verify each other’s identity before any application data is exchanged.

The platform team’s service mesh solution was still months away from production readiness, and waiting was not an option for this specific compliance requirement. The decision was made to implement mTLS at the application layer. This approach gives us granular control and removes dependencies on infrastructure that isn’t mature yet, but it puts the entire burden of certificate management and TLS configuration directly on the service development teams. In a real-world project, this trade-off between control and operational overhead is a constant balancing act. Our stack was fixed: a Node.js Express application for the SSR layer and a Spring Boot 3 application for the data API.

Foundational Step: Generating a Certificate Hierarchy

Before writing a single line of application code, a local Public Key Infrastructure (PKI) is necessary. For development and testing, OpenSSL is the standard tool. We need to create our own Certificate Authority (CA) that both the client and server will trust. This CA will then sign the server’s certificate and the client’s certificate.

First, create a workspace and generate the private key and certificate for our internal CA.

# Create a workspace for all certs
mkdir -p mtls-workspace
cd mtls-workspace

# 1. Generate the private key for our Certificate Authority (CA)
# This key is the root of trust and must be kept secure.
openssl genrsa -out ca.key 4096

# 2. Create the self-signed CA certificate
# This certificate is public and will be distributed to all parties
# that need to trust certificates signed by this CA.
# The `-days` flag sets the validity period. For production, this requires a rotation policy.
openssl req -new -x509 -key ca.key -sha256 -days 365 -out ca.crt \
  -subj "/C=US/ST=CA/L=PaloAlto/O=MyApp/CN=myapp.ca.internal"

With the CA established, we can now issue certificates for the server (our Spring Boot API) and the client (our Node.js SSR service). A critical detail here is the Common Name (CN) in the certificate’s subject. We will use this later for authentication and authorization.

For the server, the CN should match the hostname the client will use to connect. In our Dockerized environment, this will be the service name, e.g., data-api-service.

# 3. Generate the server's private key
openssl genrsa -out server.key 4096

# 4. Create a Certificate Signing Request (CSR) for the server
# The CN='data-api-service' is crucial for hostname verification.
openssl req -new -key server.key -out server.csr \
  -subj "/C=US/ST=CA/L=PaloAlto/O=MyApp/CN=data-api-service"

# 5. Sign the server's CSR with our CA key
# This creates the server's public certificate, trusted by our CA.
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365 -sha256

Similarly, we generate a key and certificate for the client. The CN here will act as the client’s unique identity, for example, ssr-rendering-service.

# 6. Generate the client's private key
openssl genrsa -out client.key 4096

# 7. Create a CSR for the client
# The CN='ssr-rendering-service' will be used to identify the client application.
openssl req -new -key client.key -out client.csr \
  -subj "/C=US/ST=CA/L=PaloAlto/O=MyApp/CN=ssr-rendering-service"

# 8. Sign the client's CSR with our CA key
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 365 -sha256

At this point, our mtls-workspace directory contains all the necessary cryptographic material. The next step is to configure our applications to use them.

Implementing the Server: Spring Boot API with mTLS Enforcement

The goal for the Spring Boot application is twofold: first, to terminate TLS using our generated server certificate, and second, to require and validate a client certificate signed by our trusted CA.

Spring Boot makes this configuration declarative in application.yml. However, a common mistake is to stop at just enabling TLS. We must configure both a keystore (for the server’s identity) and a truststore (to validate clients against our CA).

We first need to package our server key and certificate into a PKCS12 keystore, which is a standard format for Java applications.

# Convert server key and certificate into a PKCS12 file.
# The password 'secret' is used here for demonstration. Use a secure mechanism in production.
openssl pkcs12 -export -in server.crt -inkey server.key \
  -name data-api-service -out server.p12 -password pass:secret

We also need a truststore containing our public CA certificate so the server knows who to trust.

# Create a JKS truststore containing our CA certificate.
# The `keytool` utility is part of the JDK.
keytool -import -alias myapp-ca -file ca.crt -keystore truststore.jks -storepass secret -noprompt

Now, we can configure the application.yml:

server:
  port: 8443
  ssl:
    enabled: true
    # Keystore contains the server's private key and certificate.
    key-store-type: PKCS12
    key-store: "classpath:certs/server.p12"
    key-store-password: "secret"
    key-alias: "data-api-service"
    
    # Truststore contains CAs we trust for validating client certificates.
    trust-store-type: JKS
    trust-store: "classpath:certs/truststore.jks"
    trust-store-password: "secret"
    
    # This is the most critical setting. It forces the client to present a certificate.
    # 'need' means the connection is dropped if a valid cert is not provided.
    # 'want' would allow non-mTLS connections as well. For zero-trust, 'need' is required.
    client-auth: "need"

spring:
  application:
    name: data-api-service

This configuration handles the TLS layer. The next, and arguably more important part, is integrating the client’s identity from the certificate into Spring Security’s context. Merely establishing an mTLS connection isn’t enough; the application needs to make authorization decisions based on the verified identity.

We’ll configure Spring Security to extract the Common Name (CN) from the client certificate’s Subject Distinguished Name (DN) and use it for authentication.

package com.example.mtls.api;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // To enable method-level security like @PreAuthorize
public class SecurityConfig {

    private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class);
    private static final String REQUIRED_CLIENT_CN = "ssr-rendering-service";

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                // All requests must be authenticated.
                .anyRequest().authenticated()
            )
            .x509(x509 -> x509
                // Extract the principal from the certificate's Subject DN.
                .subjectPrincipalRegex("CN=(.*?)(?:,|$)")
                // Map the extracted principal to a UserDetailsService.
                .userDetailsService(userDetailsService())
            );

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return subject -> {
            // The 'subject' here is the CN extracted by the regex above.
            logger.info("Authenticating client with CN: {}", subject);
            
            // In a production system, you might look up this CN in a database
            // of registered clients to fetch its specific roles and permissions.
            // Here, we hardcode the check for our specific SSR service client.
            if (REQUIRED_CLIENT_CN.equals(subject)) {
                // Assign a role based on the client's identity.
                // This role can be used in @PreAuthorize annotations.
                return new User(subject, "", AuthorityUtils.createAuthorityList("ROLE_SSR_SERVICE"));
            } else {
                // If the CN doesn't match a known client, authentication fails.
                logger.warn("Authentication failed for unexpected CN: {}", subject);
                // Spring Security will handle this as an authentication failure.
                // Returning null or throwing UsernameNotFoundException works.
                return null; 
            }
        };
    }
}

This security configuration ensures that only a client presenting a certificate with CN=ssr-rendering-service and signed by our trusted CA can access the API. The client is then granted the ROLE_SSR_SERVICE authority.

Finally, a simple protected controller demonstrates this:

package com.example.mtls.api;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.Instant;
import java.util.Map;

@RestController
@RequestMapping("/api/data")
public class SecureDataController {

    @GetMapping("/profile")
    @PreAuthorize("hasRole('SSR_SERVICE')")
    public Map<String, Object> getUserProfile() {
        // Log the authenticated principal to verify it's coming from the certificate.
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        System.out.println("Request served for principal: " + auth.getName());

        return Map.of(
            "user", "Jane Doe",
            "retrievedAt", Instant.now().toString(),
            "source", "data-api-service",
            "authenticatedClient", auth.getName()
        );
    }
}

The server is now complete. It’s configured to enforce mTLS and use the client certificate’s identity for application-level authorization.

Implementing the Client: Node.js SSR Service

Configuring the client side in Node.js requires creating a custom https.Agent. This agent will be responsible for presenting the client’s certificate and private key during the TLS handshake and for verifying the server’s certificate against our custom CA.

A common pitfall is improper file handling or forgetting to include the CA for server verification, which leads to UNABLE_TO_VERIFY_LEAF_SIGNATURE errors.

Here is a simple Express.js application acting as our SSR service. It has one endpoint, /, which fetches data from the secure Spring Boot API and renders it.

const express = require('express');
const https = require('https');
const fs = require('fs');
const path = require('path');
const axios = require('axios');

const app = express();
const PORT = 3000;

// --- mTLS Configuration ---
// In a real-world project, paths and credentials should come from environment variables,
// not be hardcoded.
const certsBasePath = path.join(__dirname, 'certs');
const clientKey = fs.readFileSync(path.join(certsBasePath, 'client.key'));
const clientCert = fs.readFileSync(path.join(certsBasePath, 'client.crt'));
const caCert = fs.readFileSync(path.join(certsBasePath, 'ca.crt'));

// Create a custom HTTPS agent for mTLS.
// This is the core piece for the client-side implementation.
const mtlsAgent = new https.Agent({
  key: clientKey,
  cert: clientCert,
  ca: caCert, // The CA certificate is crucial for verifying the server's identity.
  rejectUnauthorized: true, // Fail if the server certificate is not signed by our CA.
});

// The target URL for our Spring Boot API.
// 'data-api-service' is the hostname, which must match the CN in the server's certificate.
const API_URL = 'https://data-api-service:8443/api/data/profile';

app.get('/', async (req, res) => {
  console.log('Received request. Fetching data from secure API...');

  try {
    // Make the request using axios, passing our custom mTLS agent.
    const response = await axios.get(API_URL, {
      httpsAgent: mtlsAgent,
    });
    
    const apiData = response.data;
    
    // Simple server-side render
    res.status(200).send(`
      <html>
        <head><title>SSR Page</title></head>
        <body>
          <h1>Welcome, ${apiData.user}</h1>
          <p>This data was securely fetched from <strong>${apiData.source}</strong> at ${apiData.retrievedAt}.</p>
          <p>The backend API verified our identity as: <strong>${apiData.authenticatedClient}</strong></p>
        </body>
      </html>
    `);
  } catch (error) {
    console.error('Error fetching data from API:', error.message);

    // Provide detailed error feedback for debugging.
    // In production, this should be a more generic error message.
    let errorMessage = 'Failed to fetch data from the upstream service.';
    if (error.code) {
        errorMessage += ` (Code: ${error.code})`;
    }
    if (error.response) {
      errorMessage += ` (Status: ${error.response.status})`;
    }

    res.status(500).send(`
      <html>
        <body>
          <h1>Error</h1>
          <p>${errorMessage}</p>
          <pre>${error.stack}</pre>
        </body>
      </html>
    `);
  }
});

app.listen(PORT, () => {
  console.log(`SSR service listening on port ${PORT}`);
});

This Node.js code is now fully equipped to act as an mTLS client. It reads the necessary certificate files and configures an https.Agent that axios uses for all its outgoing HTTPS requests to the target API.

Orchestration and Verification with Docker Compose

To run and test this entire setup, containerization is the most practical approach. We’ll use Docker Compose to define and link our two services. The main challenge here is securely providing the certificates to each container. For local development, volume mounting is sufficient.

First, the Dockerfile for the Spring Boot API:

# Dockerfile for Spring Boot API
FROM eclipse-temurin:17-jdk-jammy

WORKDIR /app

# Copy the JAR file built by Maven/Gradle
COPY target/*.jar app.jar

# Copy the keystore and truststore into the container
COPY src/main/resources/certs/ /app/certs/

EXPOSE 8443

ENTRYPOINT ["java", "-jar", "app.jar"]

Next, the Dockerfile for the Node.js SSR service:

# Dockerfile for Node.js SSR Service
FROM node:18-alpine

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install

COPY . .

# Copy the client certificates into the container
COPY certs/ ./certs/

EXPOSE 3000

CMD [ "node", "server.js" ]

Now, the docker-compose.yml file to tie it all together. Note the service names (data-api-service, ssr-service), which act as DNS hostnames within the Docker network.

version: '3.8'

services:
  data-api-service:
    build:
      context: ./spring-mtls-api # Path to the Spring Boot project
    container_name: data-api-service
    ports:
      - "8443:8443"
    networks:
      - secure-network

  ssr-service:
    build:
      context: ./node-ssr-client # Path to the Node.js project
    container_name: ssr-service
    ports:
      - "3000:3000"
    depends_on:
      - data-api-service
    networks:
      - secure-network

networks:
  secure-network:
    driver: bridge

To prepare the project structure, the generated certificates from mtls-workspace need to be copied into the respective service directories before building the containers.

  • server.p12 and truststore.jks go into the Spring Boot project’s src/main/resources/certs.
  • client.key, client.crt, and ca.crt go into the Node.js project’s certs directory.

Once running (docker-compose up --build), accessing http://localhost:3000 in a browser should display the page with data fetched from the Spring API. To verify that mTLS is enforced, we can use curl from within the Docker network to simulate calls.

A call without the client certificate should fail:

# Exec into a temporary container on the same network
docker run --rm --network=<your_network_name> nicolaka/netshoot

# Inside the container:
# This curl command attempts a standard TLS connection, which should fail the handshake
curl -v --cacert ca.crt https://data-api-service:8443/api/data/profile
# --> This will result in a TLS handshake failure message from the server.

A call with the client certificate should succeed:

# Inside the container:
curl -v --cacert ca.crt --key client.key --cert client.crt https://data-api-service:8443/api/data/profile
# --> This will return the JSON payload successfully.

This confirms our implementation is working as intended. The API is inaccessible without a valid, CA-signed client certificate.

sequenceDiagram
    participant Browser
    participant SSR_Service as "Node.js SSR Service (Client)"
    participant Data_API as "Spring Boot API (Server)"

    Browser->>+SSR_Service: GET http://localhost:3000/
    SSR_Service->>+Data_API: HTTPS GET /api/data/profile (Initiate TLS Handshake)
    Note over Data_API: Server presents its certificate (server.crt)
    Data_API-->>-SSR_Service: Server Certificate
    Note over SSR_Service: Verifies server.crt against its ca.crt
    Note over Data_API: Server requests client certificate
    SSR_Service->>+Data_API: Client presents its certificate (client.crt)
    Note over Data_API: Verifies client.crt against its ca.crt (truststore.jks)
    Note over Data_API: TLS Handshake successful
    Data_API-->>-SSR_Service: Encrypted Channel Established
    Note over Data_API: Spring Security extracts CN='ssr-rendering-service' from certificate, authenticates, and authorizes ROLE_SSR_SERVICE
    Data_API-->>SSR_Service: 200 OK (JSON Payload)
    SSR_Service->>-Browser: 200 OK (HTML Page with API data)

The solution, while effective, introduces significant operational complexity. The certificates we generated have a finite lifetime. Their manual generation and distribution are prone to error and do not scale. In a production environment, this entire process must be automated. The next logical iteration would be to integrate with a centralized secrets management and PKI system like HashiCorp Vault. Vault could act as our internal CA, dynamically generating short-lived certificates for services upon startup via an authentication mechanism like Kubernetes Service Account tokens. This would eliminate the need for manually creating, distributing, and rotating certificates, addressing the primary limitation of this application-level mTLS implementation.


  TOC