In this blog post, we will explore how to establish a Mutual TLS (mTLS) connection with Spring Boot and then add an authentication layer with Spring Security that validates the client's X.509 certificate.
X.509 ¶
X.509 is a standard that defines the format of public key certificates. These certificates are used in various network protocols, including TLS, to provide a means of establishing a secure connection between clients and servers. An X.509 certificate contains the public key of the entity it represents, along with information about the entity and the digital signature of a trusted certificate authority (CA).
The most common use of X.509 certificates is in HTTP over TLS (HTTPS). In this context, the client (e.g., a web browser) verifies the server's identity by checking its X.509 certificate against a list of trusted certificate authorities.
Mutual TLS (mTLS) ¶
With TLS, only the server presents its certificate to the client, so only the client verifies the server's identity. With mTLS, the server also requests a certificate from the client, allowing both parties to authenticate each other.
Benefits: ¶
-
Zero-Trust Architecture: Nobody is trusted by default, and verification is required at every step.
-
Stronger Authentication: The use of client certificates provides a stronger form of authentication compared to passwords or API keys.
-
Enhanced Protection Against Attacks: The mutual authentication process makes it more difficult for attackers to intercept or alter communications between the client and server.
Downsides ¶
While mTLS provides strong security benefits, it also comes with several challenges and downsides:
-
Certificate Management Complexity: The management of client and server certificates can be cumbersome, requiring robust processes for issuance, renewal, and revocation.
-
Performance Overhead: The additional cryptographic operations and handshake steps can introduce latency and increase resource consumption.
-
Operational Complexity: The setup and management of mTLS can be more complex than traditional TLS, requiring additional configuration and maintenance efforts. Scalability can be a concern as the system grows.
-
Cost: Adding mTLS can increase infrastructure costs, as organizations may need to invest in additional hardware, software, and personnel to manage the certificate lifecycle and ensure proper implementation.
Despite these challenges, many organizations find that the security benefits of mTLS outweigh the operational complexity, especially in zero-trust architectures and high-security environments.
mTLS Handshake Process ¶
mTLS builds on top of the TLS handshake process. The following is a simplified overview of the mTLS handshake, with green boxes highlighting the additional steps that mTLS introduces.
-
Client Hello
The client starts the handshake by sending a "Client Hello" message. This includes the TLS version it supports, a list of cipher suites, and a random value. This begins the negotiation process. -
Server Hello
The server responds with a "Server Hello" message. It selects the highest TLS version and strongest cipher suite supported by both, and sends its own random value. -
Certificate (Server's X.509)
The server sends its X.509 certificate to the client. This certificate contains the server's public key and identity, signed by a trusted Certificate Authority (CA). The client uses this to authenticate the server. -
Certificate Request
The server sends a "Certificate Request" message, asking the client to provide its X.509 certificate. This is a key part of mutual TLS, enabling the server to authenticate the client. -
Server Hello Done
The server sends a "Server Hello Done" message, indicating it has finished its initial handshake messages. -
Verify Server Certificate
The client verifies the server's X.509 certificate. It checks the certificate's validity (dates, domain name) and confirms the digital signature using the CA's public key (from its truststore). If valid, the server is authenticated. -
Certificate (Client's X.509)
The client sends its own X.509 certificate to the server. This certificate contains the client's public key and identity, also signed by a CA. The server will use this to authenticate the client. -
Client Key Exchange
The client generates a "premaster secret," encrypts it with the server's public key (from the server's certificate), and sends it to the server. Only the server, with its private key, can decrypt this. -
Certificate Verify
The client sends a "Certificate Verify" message. This is a digital signature of all previous handshake messages, created using the client's private key. This proves the client possesses the private key corresponding to its certificate, thus authenticating the client. -
Generate Master Secret
Both client and server generate the master secret locally from the premaster secret and client/server randoms. -
Change Cipher Spec (Client)
The client sends a "Change Cipher Spec" notification, indicating all future messages from the client will be encrypted. -
Finished (Client)
The client sends a "Finished" message encrypted with the newly negotiated symmetric keys. -
Verify Client Certificate
The server verifies the client's X.509 certificate. It checks the certificate's validity and signature using the CA's public key. It also verifies the "Certificate Verify" signature using the client's public key (from the client's certificate). If valid, the client is authenticated. -
Change Cipher Spec (Server)
The server sends "Change Cipher Spec," indicating all future messages from the server will be encrypted. -
Finished (Server)
The server sends a "Finished" message encrypted with the symmetric keys. The handshake is now complete, and all further communication is encrypted and authenticated with the shared symmetric keys.
Setting up key stores ¶
The following Spring Boot example requires a set of certificates and keys for mutual TLS (mTLS) authentication.
I use task to run all the necessary steps. For setting up the infrastructure, the task requires the openSSL command-line tool and the keytool from the Java Development Kit (JDK).
The following task sets up the certificate authority (CA) and generates the necessary certificates and keys. Note that while this example uses the same CA for both client and server certificates, you can use different CAs.
generate-ca:
cmds:
- echo "Creating Certificate Authority (CA) with {{.CRYPTO_ALGORITHM}}..."
- openssl genpkey -algorithm {{.CRYPTO_ALGORITHM}} -out ca-key.pem
- cmd: openssl req -new -x509 -key ca-key.pem -out ca-cert.pem -days {{.VALIDITY_DAYS}} -subj "/C=CH/ST=BE/L=Bern/O=Demo AG/OU=IT Department/CN=Demo CA"
platforms: [windows]
- cmd: openssl req -new -x509 -key ca-key.pem -out ca-cert.pem -days {{.VALIDITY_DAYS}} -subj '/C=CH/ST=BE/L=Bern/O=Demo AG/OU=IT Department/CN=Demo CA'
platforms: [linux, darwin]
The following two tasks create the public and private keys for the server and client, respectively, and then generate and sign their X.509 certificates with the CA. This demo uses the Ed448 elliptic curve cryptography algorithm, but other algorithms like RSA or Ed25519 are also supported by Java and OpenSSL.
generate-server-cert:
cmds:
- echo "Creating server certificate with {{.CRYPTO_ALGORITHM}}..."
- openssl genpkey -algorithm {{.CRYPTO_ALGORITHM}} -out server-key.pem
- cmd: openssl req -new -key server-key.pem -out server.csr -subj "/C=CH/ST=BE/L=Bern/O=Demo AG/OU=Server/CN=localhost"
platforms: [windows]
- cmd: openssl req -new -key server-key.pem -out server.csr -subj '/C=CH/ST=BE/L=Bern/O=Demo AG/OU=Server/CN=localhost'
platforms: [linux, darwin]
- openssl x509 -req -in server.csr -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem -days {{.VALIDITY_DAYS}}
generate-client-cert:
cmds:
- echo "Creating client certificate with {{.CRYPTO_ALGORITHM}}..."
- openssl genpkey -algorithm {{.CRYPTO_ALGORITHM}} -out client-key.pem
- cmd: openssl req -new -key client-key.pem -out client.csr -subj "/C=CH/ST=BE/L=Bern/O=Demo AG/OU=Client/CN=demo-client"
platforms: [windows]
- cmd: openssl req -new -key client-key.pem -out client.csr -subj '/C=CH/ST=BE/L=Bern/O=Demo AG/OU=Client/CN=demo-client'
platforms: [linux, darwin]
- openssl x509 -req -in client.csr -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out client-cert.pem -days {{.VALIDITY_DAYS}}
Lastly, all these keys and certificates need to be imported into keystores.
create-keystores:
cmds:
- echo "Creating keystores..."
- openssl pkcs12 -export -in server-cert.pem -inkey server-key.pem -out keystore.p12 -name {{.SERVER_ALIAS}} -CAfile ca-cert.pem -caname root -passout pass:{{.KEYSTORE_PASSWORD}}
- openssl pkcs12 -export -in client-cert.pem -inkey client-key.pem -out client-keystore.p12 -name {{.CLIENT_ALIAS}} -CAfile ca-cert.pem -caname root -passout pass:{{.KEYSTORE_PASSWORD}}
- echo "Creating server truststore (contains CA and client certificates)..."
- keytool -import -file ca-cert.pem -alias {{.CA_ALIAS}} -keystore server-truststore.p12 -storetype PKCS12 -storepass {{.KEYSTORE_PASSWORD}} -noprompt
- keytool -import -file client-cert.pem -alias {{.CLIENT_ALIAS}} -keystore server-truststore.p12 -storetype PKCS12 -storepass {{.KEYSTORE_PASSWORD}} -noprompt
- echo "Creating client truststore (contains only CA certificate)..."
- keytool -import -file ca-cert.pem -alias {{.CA_ALIAS}} -keystore client-truststore.p12 -storetype PKCS12 -storepass {{.KEYSTORE_PASSWORD}} -noprompt
- keystore.p12: contains the server's private key and certificate chain.
- client-keystore.p12: contains the client's private key and certificate chain.
- server-truststore.p12: contains the CA and client certificates.
- client-truststore.p12: contains only the CA certificate.
Note that client-truststore.p12 is not required if you use a CA that is already in the operating system's trust store. For instance, Let's Encrypt is a widely trusted CA, and its certificates are included in most operating systems. In this example, however, we are using a self-signed CA for demonstration, which is not trusted by default.
Similarly, the CA in server-truststore.p12 is only necessary if it is not already trusted by the operating system. What is essential, however, is that every client's certificate is stored in server-truststore.p12, as the server needs these to authenticate clients. It is also crucial that each client has its own unique certificate. In this example, we have only created a single client key pair and certificate.
mTLS with Spring Boot ¶
mTLS is not handled by Spring Boot directly but relies on the embedded servlet container (e.g., Tomcat, Jetty) for support. You can enable mTLS in a Spring Boot application without writing any Java code by adding the following properties to your application.properties
or application.yml
file. Spring Boot will then configure the embedded server accordingly on startup.
# Server port
server.port=8443
# SSL Configuration
server.ssl.enabled=true
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=changeit
server.ssl.key-store-type=PKCS12
server.ssl.key-alias=server
# Client Certificate Configuration
server.ssl.client-auth=need
server.ssl.trust-store=classpath:truststore.p12
server.ssl.trust-store-password=changeit
server.ssl.trust-store-type=PKCS12
Setting server.ssl.enabled
to true
will enable TLS for the application. TLS support also needs to know where the keystore is located, which contains the server's private key and certificate chain.
For mTLS to work, the embedded server needs access to the truststore, which contains all the client certificates and the CA with which these client certificates are signed.
The server.ssl.client-auth
property configures how the embedded server handles client certificate authentication. There are three possible options:
-
none
This is the default setting. The server will not request a client certificate. -
want
The server will request a client certificate, but the connection will not be terminated if the client doesn't provide one. If a client does present a certificate, the server will validate it. This option is useful when you want to authenticate some clients while still allowing access for others that don't have a certificate. -
need
The server will require a client to present a valid and trusted certificate. If the client fails to provide one, or if the server does not trust the provided certificate, the TLS handshake will fail, and the connection will be immediately terminated. This setting is used for high-security applications where every client's identity must be strictly verified.
X.509 Authentication with Spring Security ¶
mTLS in Spring Boot works without Spring Security. mTLS is a core feature of the embedded servlet container and therefore part of Spring Boot core. You can use mTLS as is. When setting server.ssl.client-auth
to need
, you ensure that all clients must present a valid certificate. This might already be enough for your security requirements.
However, Spring Security, when used in conjunction with mTLS, can provide an additional layer of security by enabling fine-grained access control. This allows you to define more specific security policies based on the client's identity, roles, or other attributes present in the certificate.
For this blog post I wrote a simple Spring Boot application with the following three endpoints:
/api/public/health
: Public endpoint/api/secure/data
: Secure endpoint/api/secure/update
: Secure endpoint
You can find the source code of the example RestController
here.
To enable X.509 authentication in a Spring Boot/Spring Security application, create a security configuration class and configure a principal extractor with the x509
method. In this example, the SubjectX500PrincipalExtractor
is used to extract the CN (Common Name) from the client's certificate. The UserDetailsService
then checks if the extracted CN is authorized to access the secure endpoints.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(
authz -> authz.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/secure/**").authenticated().anyRequest().permitAll())
.x509(x509 -> x509.x509PrincipalExtractor(new SubjectX500PrincipalExtractor())
.userDetailsService(userDetailsService()))
.csrf(CsrfConfigurer::disable);
return http.build();
}
@Bean
UserDetailsService userDetailsService() {
return cn -> {
// For this demo, we'll accept only certificate with CN == "demo-client"
if ("demo-client".equals(cn.toLowerCase())) {
return new User(cn, "", AuthorityUtils.createAuthorityList("ROLE_USER"));
}
throw new UsernameNotFoundException("Certificate not authorized: " + cn);
};
}
}
Based on the server.ssl.client-auth
setting, the behavior of the application changes:
server.ssl.client-auth=need
With this setting, the server requires all clients to present a valid certificate. This means that even for accessing the public endpoint /api/public/health
, a valid certificate is required. However, compared to the secure endpoints, the configuration above allows any client with a valid certificate to access the public endpoint. The secure endpoints can only be accessed by clients with a valid certificate that contains the CN "demo-client".
It is important to note that all checks in Spring Security occur after the client certificate has been presented to the embedded server. Therefore, when the request reaches the Spring Security layer, a valid certificate is always present, and the user has already been authenticated by the embedded server.
In this demo application, we only have one user and one client certificate. Consequently, the check in userDetailsService()
is superfluous because only one client can connect to our server, and the CN of this client is always "demo-client".
server.ssl.client-auth=want
With this setting, any client can connect to our server, regardless of whether they present a certificate. This means all clients can access the public endpoint. However, only clients with a valid certificate containing the CN "demo-client" can access the secure endpoints.
The check in userDetailsService()
becomes crucial in this scenario because we have clients with and without a certificate, and we need to ensure that only clients with a valid certificate are granted access to the secure endpoints.
mTLS configuration with Java 11 HTTP client ¶
In this section, we take a look at how to configure the key and trust store for the Java 11 HTTP client to enable mTLS.
The Java 11 HTTP client supports mTLS by allowing you to set a custom SSLContext
that contains the necessary key and trust stores. The following code snippet demonstrates how to create an SSLContext
with a client certificate and a trust store containing the CA certificates.
private SSLContext createSSLContext() throws Exception {
// Client certificate
KeyStore clientKeyStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(this.clientKeystorePath)) {
clientKeyStore.load(fis, this.clientKeystorePassword.toCharArray());
}
KeyManagerFactory kmf = KeyManagerFactory
.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(clientKeyStore, this.clientKeystorePassword.toCharArray());
// Trust store with CA certificate
KeyStore trustStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(this.truststorePath)) {
trustStore.load(fis, this.truststorePassword.toCharArray());
}
TrustManagerFactory tmf = TrustManagerFactory
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trustStore);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());
return sslContext;
}
I already mentioned before that the client-truststore.p12 is optional when the root certificate of the CA is already present in the operating system's trust store. In that case, you can skip the trust store configuration. However, in this example, I created a self-signed root certificate and therefore have to provide the trust store with the CA certificate.
The key store, on the other hand, is essential because it contains the client's private key and the corresponding certificate chain. When the server requests the client's certificate during the TLS handshake, the key store provides the necessary credentials.
After creating the SSLContext
, you can use it to create an HttpClient
instance that supports mTLS. Here's an example of how to do this:
SSLContext sslContext = createSSLContext();
try (HttpClient httpClient = HttpClient.newBuilder().sslContext(sslContext)
.connectTimeout(Duration.ofSeconds(this.connectionTimeout)).build()) {
The HttpClient
instance can be used to send requests. The client handles the whole mTLS handshake automatically under the hood.
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(this.serverBaseUrl + "/public/health")).GET().build();
HttpResponse<String> response = httpClient.send(request, BodyHandlers.ofString());
Conclusion ¶
Implementing mTLS with Spring Boot provides a robust foundation for zero-trust architectures and high-security applications. While the setup requires careful certificate management and additional operational complexity, the security benefits—particularly the mutual authentication and protection against various attack vectors—make it worthwhile for scenarios requiring strong client authentication.
We have seen that configuring mTLS with Spring Boot is straightforward and can be done without writing any Java code. Spring Security adds a layer on top of mTLS by providing authentication and authorization features.
Also, mTLS with the Java 11 HttpClient
is not that complicated. It requires some configuration of the SSL context, but once that's done, the client can handle the mTLS handshake automatically.
The setup I have shown in this post is only feasible for small deployments with a limited number of clients. For handling thousands of clients, a proper Public Key Infrastructure (PKI) is necessary to manage certificates and keys efficiently. You can use tools like HashiCorp Vault, AWS Certificate Manager, or other PKI solutions to automate the certificate issuance and management process.