Aug 27, 2024
Apr 18, 2025
N/A Views
MD

Warning

This article was automatically translated by OpenAI (gemini-2.5-pro-exp-03-25).It may be edited eventually, but please be aware that it may contain incorrect information at this time.

This post describes how to configure mTLS (Mutual TLS) in Spring Boot. Tested with Spring Boot 3.3.3. It uses SSL Bundles, introduced in 3.1, so it won't work with versions prior to 3.1.

The final source code is available at https://github.com/making/demo-mtls.

Table of Contents

Creating a Sample Project

First, create a template project using Spring Initializr.

curl https://start.spring.io/starter.tgz \
       -d artifactId=demo-mtls \
       -d baseDir=demo-mtls \
       -d packageName=com.example \
       -d dependencies=web,actuator,security \
       -d type=maven-project \
       -d name=demo-mtls \
       -d applicationName=DemoMtlsApplication | tar -xzvf -
cd demo-mtls

Initially, we won't use Spring Security, so comment out the following section in pom.xml.

                <!--
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-security</artifactId>
                </dependency>
                -->

Build the template project.

./mvnw clean package -DskipTests

Start the application.

java -jar target/demo-mtls-0.0.1-SNAPSHOT.jar

Access the health check endpoint using curl.

$ curl http://localhost:8080/actuator/health
{"status":"UP"}

So far, everything is standard for the template.

Enabling HTTPS on the Server Side (One-way TLS)

Before configuring bidirectional mTLS, let's first set up one-way TLS on the server side. This is a standard HTTPS configuration.

Generate a self-signed CA certificate and a server certificate using it with OpenSSL. Use the following commands to issue the certificates.

DIR=$PWD/src/main/resources/self-signed
mkdir -p ${DIR}

# Create CA certificate
openssl req -new -nodes -out ${DIR}/ca.csr -keyout ${DIR}/ca.key -subj "/CN=@making/O=LOL.MAKI/C=JP"
chmod og-rwx ${DIR}/ca.key

cat <<EOF > ${DIR}/ext_ca.txt
basicConstraints=CA:TRUE
keyUsage=digitalSignature,keyCertSign
EOF

openssl x509 -req -in ${DIR}/ca.csr -days 3650 -signkey ${DIR}/ca.key -out ${DIR}/ca.crt -extfile ${DIR}/ext_ca.txt

cat <<EOF > ${DIR}/ext.txt
basicConstraints=CA:FALSE
keyUsage=digitalSignature,dataEncipherment,keyEncipherment,keyAgreement
extendedKeyUsage=serverAuth,clientAuth
EOF

# Create Server certificate signed by CA
openssl req -new -nodes -out ${DIR}/server.csr -keyout ${DIR}/server.key -subj "/CN=localhost"
chmod og-rwx ${DIR}/server.key
openssl x509 -req -in ${DIR}/server.csr -days 3650 -CA ${DIR}/ca.crt -CAkey ${DIR}/ca.key -CAcreateserial -out ${DIR}/server.crt -extfile ${DIR}/ext.txt

The following files should be created:

$ ls -l src/main/resources/self-signed
total 72
-rw-r--r--  1 tmaki  staff  1164  8 27 10:42 ca.crt
-rw-r--r--  1 tmaki  staff   932  8 27 10:42 ca.csr
-rw-------  1 tmaki  staff  1704  8 27 10:42 ca.key
-rw-r--r--  1 tmaki  staff    41  8 27 10:42 ca.srl
-rw-r--r--  1 tmaki  staff   137  8 27 10:42 ext.txt
-rw-r--r--  1 tmaki  staff    63  8 27 10:42 ext_ca.txt
-rw-r--r--  1 tmaki  staff  1204  8 27 10:42 server.crt
-rw-r--r--  1 tmaki  staff   891  8 27 10:42 server.csr
-rw-------  1 tmaki  staff  1704  8 27 10:42 server.key

Configure application.properties to use this server certificate. Here, we'll write the content in application-tls.properties so that this configuration is enabled with the tls profile. Define an SSL Bundle named self-signed.

cat <<EOF > src/main/resources/application-tls.properties
server.port=8443
server.ssl.enabled=true
server.ssl.bundle=self-signed
spring.ssl.bundle.pem.self-signed.keystore.certificate=classpath:self-signed/server.crt
spring.ssl.bundle.pem.self-signed.keystore.private-key=classpath:self-signed/server.key
EOF

Note

Since Spring Boot 2.7, certificate configuration supports PEM format in addition to JKS (Keystore). This simplifies integration with tools like cert-manager. The server.ssl.bundle.* format (SSL Bundle) configuration was supported in Spring Boot 3.1.

Tip

Starting from 3.2, Hot Reloading for SSL Bundles is also supported.

Build the project.

./mvnw clean package -DskipTests

Start the application with the tls profile enabled.

java -jar target/demo-mtls-0.0.1-SNAPSHOT.jar --spring.profiles.active=tls

Access the health check endpoint using curl. Use the -k option to ignore certificate checks.

$ curl -k https://localhost:8443/actuator/health
{"status":"UP"}

Instead of using the -k option, you can specify the CA certificate path with the --cacert option.

$ curl --cacert src/main/resources/self-signed/ca.crt https://localhost:8443/actuator/health
{"status":"UP"}

You can check the server certificate details using the -v option.

$ curl --cacert src/main/resources/self-signed/ca.crt https://localhost:8443/actuator/health -v
...
* Server certificate:
*  subject: CN=localhost
*  start date: Aug 27 01:42:54 2024 GMT
*  expire date: Aug 25 01:42:54 2034 GMT
*  common name: localhost (matched)
*  issuer: CN=@making; O=LOL.MAKI; C=JP
*  SSL certificate verify ok.
* using HTTP/1.x
...
{"status":"UP"}

Requiring Client Certificates (Mutual TLS)

Next, configure application.properties to also perform client authentication. Here, we'll write the content in application-mtls.properties so that this configuration is enabled with the mtls profile.

cat <<EOF > src/main/resources/application-mtls.properties
server.ssl.client-auth=need
spring.ssl.bundle.pem.self-signed.truststore.certificate=classpath:self-signed/ca.crt
EOF

Build the project.

./mvnw clean package -DskipTests

Start the application with the tls and mtls profiles enabled.

java -jar target/demo-mtls-0.0.1-SNAPSHOT.jar --spring.profiles.active=tls,mtls

Access the application as before. This time, you'll get an SSLV3_ALERT_BAD_CERTIFICATE error.

$ curl --cacert src/main/resources/self-signed/ca.crt https://localhost:8443/actuator/health   
curl: (56) BoringSSL SSL_read: BoringSSL: error:10000412:SSL routines:OPENSSL_internal:SSLV3_ALERT_BAD_CERTIFICATE, errno 0

Client authentication is performed at the Tomcat level, and this request does not reach the servlet.

Issue a client certificate using the CA configured in spring.ssl.bundle.pem.self-signed.truststore.certificate. The creation method is the same as for the server certificate. Change the Subject value (-subj) as needed.

DIR=$PWD/src/main/resources/self-signed
# Create Client certificate signed by CA
openssl req -new -nodes -out ${DIR}/client.csr -keyout ${DIR}/client.key -subj "/CN=toshiaki-maki"
chmod og-rwx ${DIR}/client.key
openssl x509 -req -in ${DIR}/client.csr -days 3650 -CA ${DIR}/ca.crt -CAkey ${DIR}/ca.key -CAcreateserial -out ${DIR}/client.crt -extfile ${DIR}/ext.txt

Verify the generated certificate. Check if the Issuer and Subject are as expected.

$ openssl x509 -noout -text -in src/main/resources/self-signed/client.crt 
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            72:52:05:2b:43:f7:d8:6a:23:95:50:65:19:d0:be:38:0e:e9:82:ed
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN=@making, O=LOL.MAKI, C=JP
        Validity
            Not Before: Aug 27 01:56:49 2024 GMT
            Not After : Aug 25 01:56:49 2034 GMT
        Subject: CN=toshiaki-maki
...

Access the application using this client certificate. This time, it should return OK.

$ curl --cacert src/main/resources/self-signed/ca.crt --cert src/main/resources/self-signed/client.crt --key src/main/resources/self-signed/client.key https://localhost:8443/actuator/health
{"status":"UP"}   

Client-Side mTLS Support

Next, let's look at how to handle mTLS on the client side, specifically how to configure the equivalents of curl's --cert, --key, and --cacert options. This can also be easily configured using SSL Bundles.

Prepare the following test code.

cat <<EOF > src/test/java/com/example/DemoMtlsApplicationTests.java
package com.example;

import javax.net.ssl.SSLHandshakeException;

import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.web.client.RestClientSsl;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
        "spring.profiles.active=tls,mtls",
        "spring.ssl.bundle.pem.client.keystore.certificate=classpath:self-signed/client.crt",
        "spring.ssl.bundle.pem.client.keystore.private-key=classpath:self-signed/client.key",
        "spring.ssl.bundle.pem.client.truststore.certificate=classpath:self-signed/ca.crt",
        "spring.ssl.bundle.pem.cacert.truststore.certificate=classpath:self-signed/ca.crt"
})
class DemoMtlsApplicationTests {
    @LocalServerPort int port;

    @Autowired 
    RestClient.Builder restClientBuilder;

    @Autowired
    RestClientSsl clientSsl;

    @Test
    void healthCheckWithValidCertificate() {
        RestClient restClient = this.restClientBuilder
                .baseUrl("https://localhost:" + this.port)
                .apply(this.clientSsl.fromBundle("client")) // (1)
                .build();
        ResponseEntity<String> response = restClient.get()
                .uri("/actuator/health")
                .retrieve()
                .toEntity(String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo("{\"status\":\"UP\"}");
    }

    @Test
    void healthCheckWithoutCertificate() {
        RestClient restClient = this.restClientBuilder
                .baseUrl("https://localhost:" + this.port)
                .apply(this.clientSsl.fromBundle("cacert")) // (2)
                .build();
        try {
            restClient.get()
                    .uri("/actuator/health")
                    .retrieve()
                    .toEntity(String.class);
            fail("Should have thrown an exception");
        }
        catch (ResourceAccessException e) {
            assertThat(e.getCause()).isInstanceOf(SSLHandshakeException.class);
            assertThat(e.getCause().getMessage()).isEqualTo("Received fatal alert: bad_certificate");
        }
    }

}
EOF
  • (1) ... Use the client SSL Bundle configured with the correct certificate.
  • (2) ... Use the cacert SSL Bundle configured only with the CA certificate, without the client certificate.

Run the tests.

./mvnw clean test

TLS Authentication/Authorization with Spring Security

The mTLS processing up to this point occurs at the Tomcat layer. Only the validity of the client certificate is checked. By using Spring Security's X.509 authentication, you can create user information from the certificate content and use that information for authorization.

X.509 authentication is implemented as Pre-Authentication. This is a method where requests authenticated before entering Spring Security (here, by Tomcat) are trusted to create an authenticated user.

Uncomment the section previously commented out in pom.xml.

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

Create the following file to configure Spring Security.

cat <<EOF > src/main/java/com/example/SecurityConfig.java
package com.example;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;

@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers("/").hasRole("MTLS") // (3)
                        .anyRequest().permitAll())
                .x509(s -> s.subjectPrincipalRegex("CN=([\\w\\-]+)")) // (1)
                .build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return username -> User.withUsername(username).password("{noop}dummy" /* (2) */).roles("MTLS").build();
    }
}
EOF
  • (1) ... Extract the CN attribute from the certificate and use it as the username.
  • (2) ... Password is not needed for Pre-Authentication, so set a dummy value (required by the builder).
  • (3) ... Access to / requires the MTLS role.

Tip

If you want to flexibly extract user information from java.security.cert.X509Certificate, you can implement the logic in org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor and configure it with .x509(s -> s.x509PrincipalExtractor(new MyExtractor())).

Create a Controller to handle requests to /.

cat <<EOF > src/main/java/com/example/HelloController.java
package com.example;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
    @GetMapping(path = "/")
    public String sayHello(@AuthenticationPrincipal UserDetails user) {
        return "Hello " + user.getUsername() + "!";
    }
}
EOF

Add the following test code to DemoMtlsApplicationTests.

    @Test
    void hello() {
        RestClient restClient = this.restClientBuilder
                .baseUrl("https://localhost:" + this.port)
                .apply(this.clientSsl.fromBundle("client"))
                .build();
        ResponseEntity<String> response = restClient.get()
                .uri("/")
                .retrieve()
                .toEntity(String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isEqualTo("Hello toshiaki-maki!");
    }

Build and run the application.

./mvnw clean package
java -jar target/demo-mtls-0.0.1-SNAPSHOT.jar --spring.profiles.active=tls,mtls

If the correct client certificate is provided, the username set in the CN attribute will be returned.

$ curl --cacert src/main/resources/self-signed/ca.crt --cert src/main/resources/self-signed/client.crt --key src/main/resources/self-signed/client.key https://localhost:8443 
Hello toshiaki-maki!

If no client certificate is provided, a certificate error will occur.

$ curl --cacert src/main/resources/self-signed/ca.crt https://localhost:8443  
curl: (56) BoringSSL SSL_read: BoringSSL: error:10000412:SSL routines:OPENSSL_internal:SSLV3_ALERT_BAD_CERTIFICATE, errno 0

If you want to return a 403 error from Spring Security instead of a certificate error when no client certificate is provided, change server.ssl.client-auth from need to want. In this case, even if certificate validation fails at the Tomcat layer, the request will be sent to the servlet layer.

java -jar target/demo-mtls-0.0.1-SNAPSHOT.jar --spring.profiles.active=tls,mtls --server.ssl.client-auth=want

In this state, sending a request without a client certificate will return a 403 error.

$ curl --cacert src/main/resources/self-signed/ca.crt https://localhost:8443 
{"timestamp":"2024-08-27T03:28:12.310+00:00","status":403,"error":"Forbidden","path":"/"}

We have configured mTLS in Spring Boot. Since Spring Boot 2.7, gradual improvements related to TLS certificates have made the configuration very straightforward.

Found a mistake? Update the entry.
Share this article: