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
- Enabling HTTPS on the Server Side (One-way TLS)
- Requiring Client Certificates (Mutual TLS)
- Client-Side mTLS Support
- TLS Authentication/Authorization with Spring Security
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 theclientSSL Bundle configured with the correct certificate.(2)... Use thecacertSSL 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 theMTLSrole.
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.