---
title: Notes on Configuring mTLS (Mutual TLS) in Spring Boot
tags: ["Java", "Spring Boot", "Spring Security", "mTLS", "TLS"]
categories: ["Programming", "Java", "org", "springframework", "security", "web", "authentication", "preauth", "x509"]
date: 2024-08-27T03:33:38Z
updated: 2025-04-18T02:24:55Z
---

> ⚠️ 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)](https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/) in Spring Boot. Tested with Spring Boot 3.3.3. It uses [SSL Bundles](https://docs.spring.io/spring-boot/reference/features/ssl.html#features.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**
<!-- toc -->

### Creating a Sample Project

First, create a template project using Spring Initializr.

```bash
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`.

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

Build the template project.

```bash
./mvnw clean package -DskipTests
```

Start the application.

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

Access the health check endpoint using curl.

```bash
$ 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.

```bash
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:

```bash
$ 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`.

```properties
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](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.7-Release-Notes#web-server-ssl-configuration-using-pem-encoded-certificates), certificate configuration supports PEM format in addition to JKS (Keystore). This simplifies integration with tools like [cert-manager](https://cert-manager.io). The `server.ssl.bundle.*` format (SSL Bundle) configuration was supported in [Spring Boot 3.1](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.1-Release-Notes#ssl-configuration).

> [!TIP] Starting from 3.2, [Hot Reloading](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2-Release-Notes#ssl-bundle-reloading) for SSL Bundles is also supported.

Build the project.

```bash
./mvnw clean package -DskipTests
```

Start the application with the `tls` profile enabled.

```bash
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.

```bash
$ 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.

```bash
$ 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.

```bash
$ 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.

```properties
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.

```bash
./mvnw clean package -DskipTests
```

Start the application with the `tls` and `mtls` profiles enabled.

```bash
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.

```bash
$ 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.

```bash
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.

```bash
$ 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.

```bash
$ 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](https://docs.spring.io/spring-boot/reference/io/rest-client.html#io.rest-client.restclient.ssl).

Prepare the following test code.

```java
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.

```bash
./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](https://docs.spring.io/spring-security/reference/servlet/authentication/x509.html) authentication, you can create user information from the certificate content and use that information for authorization.

X.509 authentication is implemented as [Pre-Authentication](https://docs.spring.io/spring-security/reference/servlet/authentication/preauth.html). 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`.

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

Create the following file to configure Spring Security.

```java
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 `/`.

```java
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`.

```java
	@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.

```bash
./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.

```bash
$ 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.

```bash
$ 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.

```bash
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.

```bash
$ 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.
