Let's get started with a Microservice Architecture with Spring Cloud:
Using a Different Client Certificate per Connection in Java
Last updated: October 6, 2025
1. Introduction
In this tutorial, we’ll configure Java’s SSLContext to use different client certificates based on the target server. We’ll start with a straightforward approach using Apache HttpComponents, then implement a custom solution using a routing KeyManager and TrustManager.
2. Scenario and Setup
We’ll simulate a Java client that needs to make HTTPS calls to two different endpoints requiring different client certificates for mutual TLS authentication. For demonstration, we’ll imagine both https://api.service1/ and https://api.service2/ as intranet services.
2.1. Generating the CA, Keys, and Trust Stores
Our setup uses a client/server key pair for each host. Each key is signed by a shared certificate authority (CA), and both sides of the TLS connection trust the CA.
Creating the CA, signed certificates, keystores, and trust stores manually can be error-prone and repetitive. To simplify this, we provide a helper script, automating the process:
- Create a private certificate authority (CA) and add it to a truststore; for example, trust.api.service1.p12
- Generate separate key pairs for the client and server
- Sign both client and server certificates using the CA
- Package each certificate and key into a PKCS12 keystore for easy loading; for example, client.api.service1.p12 and server.api.service1.p12
Furthermore, we’ll use an alias with the same value as the hostname of each endpoint, which will always be part of each generated file, so it’s easier to distinguish between them by prefix. We also use the same password to simplify the examples. In a real scenario, we’d use different passwords.
2.2. Mocking the Servers and Certificate Setup
We’ll use WireMock to simulate our servers, relying on two properties: CERTS_DIR, with the directory containing the p12 files for both servers, and PASSWORD:
private static WireMockServer mockHttpsServer(String hostname, int port) {
return new WireMockServer(WireMockConfiguration.options()
.bindAddress(hostname)
.httpsPort(port)
.trustStorePath(CERTS_DIR + "/trust." + host + ".p12")
.trustStorePassword(password)
.keystorePath(CERTS_DIR + "/server." + host + ".p12")
.keystorePassword(PASSWORD)
.keyManagerPassword(PASSWORD)
.needClientAuth(true));
}
Let’s go through the most important options:
- Bind address: Each certificate is bound to a specific hostname, so we specify it here
- HTTPS port: This is the port we’ll use in our tests
- Keystore path and trust store path: Since our certificate is self-signed, we also need to specify a trust store. Additionally, to simplify the example, we’re assuming the file names use the prefix “trust.” for the trust store and “server.” for the server key store
- Client auth: Enabled explicitly for mTLS
- Passwords: In this example, we’re using the same password
3. Using Apache HTTP Components
Let’s first examine how we can set up different clients with their own SSL contexts using Apache’s HTTP library.
3.1. Configuring the Client
Since we’re assuming the prefix “trust.” for the trust store and “client.” for the client key store, we only need one parameter to receive the endpoint hostname to build our SSLContext. With the help of the library’s SSLContexts, we can load the trust and key material:
private CloseableHttpClient httpsClient(String host) {
char[] password = PASSWORD.toCharArray();
SSLContext context = SSLContexts.custom()
.loadTrustMaterial(Paths.get(CERTS_DIR + "/trust." + host + ".p12"), password)
.loadKeyMaterial(Paths.get(CERTS_DIR + "/client." + host + ".p12"), password, password)
.build();
// ...
}
Then, we create a connection manager and set our newly created SSL context as a TLS socket strategy. We’ll use DefaultClientTlsStrategy, introduced in HttpComponents Core 5:
var manager = PoolingHttpClientConnectionManagerBuilder.create()
.setTlsSocketStrategy(new DefaultClientTlsStrategy(context))
.build();
Finally, we use the manager to return a configured HTTPS client, ready to be used to make secure API calls:
return HttpClients.custom()
.setConnectionManager(manager)
.build();
3.2. Calling the Endpoints
With the boilerplate ready, we’ll create different client configurations for each API call:
@Test
void whenBuildingSeparateContexts_thenCorrectCertificateUsed() {
CloseableHttpClient client1 = httpsClient("api.service1");
HttpGet api1Get = new HttpGet("https://api.service1:10443/test");
client1.execute(api1Get, response -> {
assertEquals(HttpStatus.SC_OK, response.getCode());
return response;
});
CloseableHttpClient client2 = httpsClient("api.service2");
HttpGet api2Get = new HttpGet("https://api.service2:20443/test");
client2.execute(api2Get, response -> {
assertEquals(HttpStatus.SC_OK, response.getCode());
return response;
});
}
Let’s see what this looks like without third-party dependencies and a single SSLContext.
4. Creating a Custom KeyManager and TrustManager
The X509ExtendedKeyManager and X509ExtendedTrustManager are abstract classes from Java’s SSL package that we can extend to have complete control over how certificate keys and trust stores are loaded and used during SSL handshakes. We’ll use them to create a RoutingSslContextBuilder class that can choose the correct certificate based on the hostname.
4.1. Loading a KeyStore
Let’s start with utilities to load keys and trust managers. We’ll use this method to load a KeyStore into memory:
public class CertUtils {
private static KeyStore loadKeyStore(Path path, String password) {
KeyStore store = KeyStore.getInstance(path.toFile(), password.toCharArray());
try (InputStream stream = Files.newInputStream(path)) {
store.load(stream, password.toCharArray());
}
return store;
}
// ...
}
4.2. Loading a KeyManager and a TrustManager
Now let’s put it together to load a key manager of type X509KeyManager. We use this type because KeyManager is only a marker interface, and X.509 is the standard for certificate formats:
public static X509KeyManager loadKeyManager(Path path, String password) {
KeyStore store = loadKeyStore(path, password);
KeyManagerFactory factory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
factory.init(store, password.toCharArray());
return (X509KeyManager) Stream.of(factory.getKeyManagers())
.filter(X509KeyManager.class::isInstance)
.findAny()
.orElseThrow();
}
And also a trust manager for the trust store:
public static X509TrustManager loadTrustManager(Path path, String password) {
KeyStore store = loadKeyStore(path, password);
TrustManagerFactory factory =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
factory.init(store);
return (X509TrustManager)
filter(factory.getTrustManagers(), X509TrustManager.class::isInstance);
}
4.3. Creating the RoutingKeyManager
With a custom key manager, we’ll decide which store to use based on the hostname or certificate alias. We’ll do that by storing every keystore in a Map and overriding the methods in X509ExtendedKeyManager.
When extending this class, it’s not always possible to get the hostname. In those cases, we’ll receive the alias in our select() method instead. Therefore, this strategy only works when the hostname of the target server matches the alias of the certificate:
public class RoutingKeyManager extends X509ExtendedKeyManager {
private final Map<String, X509KeyManager> hostMap = new HashMap<>();
public void put(String host, X509KeyManager manager) {
hostMap.put(host, manager);
}
private X509KeyManager select(String host) {
X509KeyManager manager = hostMap.get(host);
if (manager == null)
throw new IllegalArgumentException("key manager not found for " + host);
return manager;
}
// ...
}
The chooseEngineClientAlias() method is called to choose an alias for authenticating the client. We get the host from the SSLEngine parameter and delegate the call to its manager’s chooseClientAlias(), ignoring the Socket parameter:
@Override
public String chooseEngineClientAlias(
String[] keyType, Principal[] issuers, SSLEngine engine) {
String host = engine.getPeerHost();
return select(host).chooseClientAlias(keyType, issuers, (Socket) null);
}
Next, we’ll override and delegate getCertificateChain(). Notice that this time we’re selecting the key manager based on the alias:
@Override
public X509Certificate[] getCertificateChain(String alias) {
return select(alias).getCertificateChain(alias);
}
The last method we need to delegate is getPrivateKey():
@Override
public PrivateKey getPrivateKey(String alias) {
return select(alias).getPrivateKey(alias);
}
For our purposes, we don’t need the other methods in X509KeyManager, so we’ll throw an UnsupportedOperationException for the remaining overrides:
@Override
public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
throw new UnsupportedOperationException();
}
// ...
4.4. Creating the RoutingTrustManager
Our custom trust manager follows the same idea as our RoutingKeyManager, delegating to one of the registered trust managers based on the hostname:
public class RoutingTrustManager extends X509ExtendedTrustManager {
private final Map<String, X509TrustManager> hostMap = new HashMap<>();
public void put(String host, X509TrustManager manager) {
hostMap.put(host, manager);
}
private X509TrustManager select(String host) {
X509TrustManager manager = hostMap.get(host);
if (manager == null)
throw new IllegalArgumentException("trust manager not found for " + host);
return manager;
}
// ...
}
This time, the only implementation we’ll need to care about is the checkServerTrusted() with an SSLEngine parameter:
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine)
throws CertificateException {
String host = engine.getPeerHost();
select(host).checkServerTrusted(chain, authType);
}
Again, we need to throw UnsupportedOperationExceptions for the other overrides:
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
throw new UnsupportedOperationException();
}
5. Putting It All Together to Create a RoutingSslContextBuilder
The final piece is building the SSL context. Since our implementation handles routing internally, we’ll need a single HttpClient for all API calls, even if they require different certificates.
5.1. Creating a Builder
We’ll start with a builder class to combine our custom managers:
public class RoutingSslContextBuilder {
private final RoutingKeyManager routingKeyManager;
private final RoutingTrustManager routingTrustManager;
public RoutingSslContextBuilder() {
routingKeyManager = new RoutingKeyManager();
routingTrustManager = new RoutingTrustManager();
}
public static RoutingSslContextBuilder create() {
return new RoutingSslContextBuilder();
}
// ...
}
When building an instance, we’ll call this method for every combination of host and certificate. This loads both the key and trust manager for every server we need to access:
public RoutingSslContextBuilder trust(String host, String certsDir, String password) {
routingTrustManager.put(host, CertUtils.loadTrustManager(
Paths.get(certsDir, "trust." + host + ".p12"), password));
routingKeyManager.put(host, CertUtils.loadKeyManager(
Paths.get(certsDir, "client." + host + ".p12"), password));
return this;
}
Lastly, we’ll initialize the SSL context with our custom managers, leaving the SecureRandom parameter null to get the default implementation:
public SSLContext build() throws NoSuchAlgorithmException, KeyManagementException {
SSLContext context = SSLContext.getInstance("TLS");
context.init(
new KeyManager[] { routingKeyManager },
new TrustManager[] { routingTrustManager },
null);
return context;
}
5.2. Testing With Java’s HttpClient
Since we’re not dependent on the Apache HttpComponents library, we’ll use core Java to make the calls. Let’s start by building the SSL context and an HttpClient:
@Test
void whenBuildingCustomSslContext_thenCorrectCertificateUsedForEachConnection() {
SSLContext context = RoutingSslContextBuilder.create()
.trust("api.service1", CERTS_DIR, PASSWORD)
.trust("api.service2", CERTS_DIR, PASSWORD)
.build();
HttpClient client = HttpClient.newBuilder()
.sslContext(context)
.build();
// ...
}
Now, let’s make the first call:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.service1:10443/test"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals("ok from server 1", response.body());
Then, the second call:
request = HttpRequest.newBuilder()
.uri(URI.create("https://api.service2:20443/test"))
.GET()
.build();
response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals("ok from server 2", response.body());
As expected, we were able to make secure requests to different servers that require different certificates using the same SSL context.
6. Conclusion
In this article, we demonstrated how to use multiple client certificates in Java when interacting with different HTTPS endpoints. We started with a multi-client solution using Apache HttpComponents and then built a custom solution with core Java, using custom KeyManager and TrustManager implementations. This approach is especially useful in systems requiring dynamic TLS credential selection, such as service meshes, multitenant clients, or API gateways.
The code backing this article is available on GitHub. Once you're logged in as a Baeldung Pro Member, start learning and coding on the project.















