Mutual TLS (mTLS) means both sides present a certificate during the handshake. The server still needs a keystore with its private key, and the client still needs a truststore to validate the server — but the server also needs a truststore to validate client certificates, and the client needs its own keystore to prove who it is.
Most Spring Boot mTLS tutorials jump straight to application.properties. This guide stays on the files: create a lab CA, sign server and client certificates with the right extended key usage, build four PKCS12 stores with keytool, and run a minimal Java HttpsServer that requires a client cert. You will see certificate_required when the client has trust but no identity, then HTTP 200 once both sides load the right files. For the one-way TLS baseline, see Java keystore vs truststore. For JVM -D flags after the files exist, see Configure javax.net.ssl.trustStore and keyStore.
Tested on: Ubuntu 26.04 LTS; OpenJDK 25.0.3; kernel 7.0.0-27-generic.
Four PKCS12 files in an mTLS deployment
| File | Role | Entry types | Loaded by |
|---|---|---|---|
server.p12 |
Server identity | PrivateKeyEntry (+ optional CA chain) |
Server KeyManagerFactory |
server-trust.p12 |
Who the server trusts (client CAs) | trustedCertEntry |
Server TrustManagerFactory |
client.p12 |
Client identity | PrivateKeyEntry |
Client KeyManagerFactory |
client-trust.p12 |
Who the client trusts (server CA) | trustedCertEntry |
Client TrustManagerFactory |
One-way HTTPS uses the first and fourth rows only. mTLS adds the client identity keystore and the server truststore so each peer can validate the other.
Prerequisites
- OpenJDK 11+ with
keytoolandjavac(Install keytool on Ubuntu) — see keytool command. opensslfor CA creation and CSR signing — see OpenSSL. This lab uses OpenSSL as the simple CA signer; for the PKI layout behind it, see create a root CA on Linux.- A free TCP port — this lab binds
127.0.0.1:8443. - Familiarity with CSR and CA-signed import and SAN on certificates.
Password changeit everywhere below.
Step 1 — Create a private root CA
keytool can generate key pairs but does not replace a full CA workflow for issuing many leaf certificates. Create the root with OpenSSL:
mkdir -p ~/java-mtls-lab/certs && cd ~/java-mtls-lab
STOREPASS=changeit
openssl genrsa -out certs/root-ca.key 4096
openssl req -new -x509 -days 3650 -key certs/root-ca.key -out certs/root-ca.crt \
-subj "/CN=Lab mTLS Root CA/O=GoLinuxCloud Lab/C=US"Confirm the self-signed root:
openssl x509 -in certs/root-ca.crt -noout -subject -issuersubject=CN=Lab mTLS Root CA, O=GoLinuxCloud Lab, C=US
issuer=CN=Lab mTLS Root CA, O=GoLinuxCloud Lab, C=USKeep root-ca.key offline in production. Clients and servers only need root-ca.crt inside truststores.
Step 2 — Server identity keystore (server.p12)
Generate the server key inside PKCS12, export a CSR, sign it with the lab CA, then import the CA and signed certificate back — the same pattern as keystore vs truststore, with serverAuth extended key usage and an IP SAN for 127.0.0.1:
keytool -genkeypair -alias server -keyalg RSA -keysize 2048 -validity 365 \
-keystore server.p12 -storetype PKCS12 -storepass "$STOREPASS" \
-dname "CN=mtls-server.lab.local, O=Lab, C=US" \
-ext "san=dns:mtls-server.lab.local,ip:127.0.0.1"
keytool -certreq -alias server -keystore server.p12 -storetype PKCS12 \
-storepass "$STOREPASS" -file certs/server.csrSign with OpenSSL (SAN + server EKU):
cat > certs/server.ext << 'EOF'
subjectAltName = IP:127.0.0.1,DNS:mtls-server.lab.local
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -in certs/server.csr -CA certs/root-ca.crt -CAkey certs/root-ca.key \
-CAcreateserial -out certs/server.crt -days 365 -extfile certs/server.extImport CA then signed server cert:
keytool -importcert -alias lab-root-ca -keystore server.p12 -storetype PKCS12 \
-storepass "$STOREPASS" -file certs/root-ca.crt -noprompt
keytool -importcert -alias server -keystore server.p12 -storetype PKCS12 \
-storepass "$STOREPASS" -file certs/server.crt -nopromptList the server keystore:
keytool -list -keystore server.p12 -storepass "$STOREPASS"Keystore type: PKCS12
Your keystore contains 2 entries
lab-root-ca, Jul 3, 2026, trustedCertEntry,
server, Jul 3, 2026, PrivateKeyEntry,The server alias is what KeyManagerFactory presents during TLS. The lab-root-ca entry helps Java build the chain toward your private CA.
Verify extensions on the signed file:
openssl x509 -in certs/server.crt -noout -ext subjectAltName -ext extendedKeyUsageX509v3 Subject Alternative Name:
IP Address:127.0.0.1, DNS:mtls-server.lab.local
X509v3 Extended Key Usage:
TLS Web Server AuthenticationUse 127.0.0.1 in client URLs so the IP SAN matches. Do not disable hostname verification in production.
Step 3 — Client identity keystore (client.p12)
Client certificates need clientAuth in extended key usage. Generate the key pair with keytool, sign the CSR with the same root CA, import the chain:
keytool -genkeypair -alias client -keyalg RSA -keysize 2048 -validity 365 \
-keystore client.p12 -storetype PKCS12 -storepass "$STOREPASS" \
-dname "CN=mtls-client.lab.local, O=Lab, C=US" \
-ext "san=dns:mtls-client.lab.local" -ext "extendedKeyUsage=clientAuth"
keytool -certreq -alias client -keystore client.p12 -storetype PKCS12 \
-storepass "$STOREPASS" -file certs/client.csrSign the client CSR:
cat > certs/client.ext << 'EOF'
subjectAltName = DNS:mtls-client.lab.local
extendedKeyUsage = clientAuth
EOF
openssl x509 -req -in certs/client.csr -CA certs/root-ca.crt -CAkey certs/root-ca.key \
-CAcreateserial -out certs/client.crt -days 365 -extfile certs/client.extImport CA and signed client certificate:
keytool -importcert -alias lab-root-ca -keystore client.p12 -storetype PKCS12 \
-storepass "$STOREPASS" -file certs/root-ca.crt -noprompt
keytool -importcert -alias client -keystore client.p12 -storetype PKCS12 \
-storepass "$STOREPASS" -file certs/client.crt -nopromptkeytool -list -keystore client.p12 -storepass "$STOREPASS"Keystore type: PKCS12
Your keystore contains 2 entries
lab-root-ca, Jul 3, 2026, trustedCertEntry,
client, Jul 3, 2026, PrivateKeyEntry,Check the client EKU:
openssl x509 -in certs/client.crt -noout -ext extendedKeyUsageX509v3 Extended Key Usage:
TLS Web Client AuthenticationWithout clientAuth, some servers reject the certificate even when the chain is valid.
Step 4 — Server and client truststores
Truststores hold public certificates only — no private keys. Import the root CA into both sides so each peer can validate chains signed by that CA:
keytool -importcert -alias lab-root-ca -file certs/root-ca.crt \
-keystore server-trust.p12 -storetype PKCS12 -storepass "$STOREPASS" -noprompt
keytool -importcert -alias lab-root-ca -file certs/root-ca.crt \
-keystore client-trust.p12 -storetype PKCS12 -storepass "$STOREPASS" -nopromptkeytool -list -keystore server-trust.p12 -storepass "$STOREPASS"Keystore type: PKCS12
Your keystore contains 1 entry
lab-root-ca, Jul 3, 2026, trustedCertEntry,client-trust.p12 has the same single trustedCertEntry. The server uses server-trust.p12 to validate client certs issued by lab-root-ca. The client uses client-trust.p12 to validate the server cert.
.crt files into server-trust.p12 instead of the root CA when you want to allow only named clients. Trusting the root CA is simpler when every legitimate client chains to the same issuer. For production, prefer an intermediate CA dedicated to client identities instead of trusting a broad corporate root CA in server-trust.p12.
Step 5 — Java mTLS server (setNeedClientAuth)
Save as MtlsServer.java. The server loads server.p12 for identity and server-trust.p12 for client validation, then requires a client certificate on every connection:
import com.sun.net.httpserver.*;
import javax.net.ssl.*;
import java.io.*;
import java.net.InetSocketAddress;
import java.security.KeyStore;
import java.util.concurrent.Executors;
public class MtlsServer {
private static final char[] PASS = "changeit".toCharArray();
public static void main(String[] args) throws Exception {
KeyStore ks = KeyStore.getInstance("PKCS12");
try (FileInputStream in = new FileInputStream("server.p12")) {
ks.load(in, PASS);
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, PASS);
KeyStore ts = KeyStore.getInstance("PKCS12");
try (FileInputStream in = new FileInputStream("server-trust.p12")) {
ts.load(in, PASS);
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(ts);
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
HttpsServer server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 8443), 0);
server.setHttpsConfigurator(new HttpsConfigurator(ctx) {
public void configure(HttpsParameters p) {
p.setNeedClientAuth(true);
}
});
server.createContext("/", ex -> {
ex.sendResponseHeaders(200, 2);
try (OutputStream os = ex.getResponseBody()) {
os.write("OK".getBytes());
}
});
server.setExecutor(Executors.newSingleThreadExecutor());
server.start();
System.out.println("mTLS server listening on https://127.0.0.1:8443/");
}
}Compile and start in one terminal:
javac MtlsServer.java
java MtlsServermTLS server listening on https://127.0.0.1:8443/setNeedClientAuth(true) is the line that turns one-way TLS into mutual TLS. Without a loaded server truststore, the server cannot validate whatever certificate the client might present.
Step 6 — Java mTLS client (fail then succeed)
Save as MtlsClient.java. Pass with-client-key to load client.p12 through KeyManagerFactory; always load client-trust.p12 for server validation:
import javax.net.ssl.*;
import java.io.*;
import java.net.URL;
import java.security.KeyStore;
public class MtlsClient {
public static void main(String[] args) throws Exception {
boolean withKey = args.length > 0 && "with-client-key".equals(args[0]);
char[] pass = "changeit".toCharArray();
KeyStore ts = KeyStore.getInstance("PKCS12");
try (FileInputStream in = new FileInputStream("client-trust.p12")) {
ts.load(in, pass);
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(ts);
KeyManager[] kms = null;
if (withKey) {
KeyStore ks = KeyStore.getInstance("PKCS12");
try (FileInputStream in = new FileInputStream("client.p12")) {
ks.load(in, pass);
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, pass);
kms = kmf.getKeyManagers();
}
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(kms, tmf.getTrustManagers(), null);
HttpsURLConnection.setDefaultSSLSocketFactory(ctx.getSocketFactory());
try {
HttpsURLConnection c = (HttpsURLConnection) new URL("https://127.0.0.1:8443/").openConnection();
c.setConnectTimeout(5000);
c.setReadTimeout(5000);
c.connect();
System.out.println("HTTP status: " + c.getResponseCode());
} catch (Exception e) {
String msg = e.getMessage();
if (msg != null && msg.contains("\n")) {
msg = msg.split("\n")[0];
}
System.out.println("FAILED: " + e.getClass().getSimpleName() + ": " + msg);
}
}
}With MtlsServer running, compile and run twice.
Client with truststore only (no identity)
javac MtlsClient.java
java MtlsClientThe server requires a client certificate. The client trusts the server but never sends its own cert:
FAILED: SSLHandshakeException: (certificate_required) Received fatal alert: certificate_requiredDepending on JDK version and TLS protocol, the client may show certificate_required, bad_certificate, or Remote host terminated the handshake. The key point is that the server requested a client certificate and the client did not present one.
That alert is expected mTLS behavior — not a PKIX trust failure. The fix is loading client.p12 as a keystore, not adding more CAs to client-trust.p12.
Client with identity + truststore
java MtlsClient with-client-keyHTTP status: 200Two-way TLS completed: the client validated the server chain against client-trust.p12, presented the client PrivateKeyEntry, and the server validated that chain against server-trust.p12.
Step 7 — Same client with JVM system properties
To prove the javax.net.ssl.* properties work, use a client that does not manually load client.p12 or client-trust.p12. Save as MtlsClientSystemProps.java:
import javax.net.ssl.HttpsURLConnection;
import java.net.URL;
public class MtlsClientSystemProps {
public static void main(String[] args) throws Exception {
try {
HttpsURLConnection c = (HttpsURLConnection) new URL("https://127.0.0.1:8443/").openConnection();
c.setConnectTimeout(5000);
c.setReadTimeout(5000);
c.connect();
System.out.println("HTTP status: " + c.getResponseCode());
} catch (Exception e) {
String msg = e.getMessage();
if (msg != null && msg.contains("\n")) {
msg = msg.split("\n")[0];
}
System.out.println("FAILED: " + e.getClass().getSimpleName() + ": " + msg);
}
}
}Compile MtlsClientSystemProps while MtlsServer is running in another terminal:
javac MtlsClientSystemProps.javaWith MtlsServer still running, run without JVM properties:
java MtlsClientSystemPropsThe client falls back to JSSE default truststore lookup and has no identity keystore, so the handshake fails before HTTP:
FAILED: SSLHandshakeException: (certificate_unknown) PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested targetNow pass both truststore and keystore properties documented in Oracle's JSSE reference guide. mTLS clients need all six flags — trust alone still leaves the server waiting for a client certificate:
| Property | File in this lab |
|---|---|
javax.net.ssl.trustStore |
client-trust.p12 — validates the server chain |
javax.net.ssl.keyStore |
client.p12 — presents the client PrivateKeyEntry |
*Password / *Type |
changeit and PKCS12 for both files |
java \
-Djavax.net.ssl.trustStore=client-trust.p12 \
-Djavax.net.ssl.trustStorePassword=changeit \
-Djavax.net.ssl.trustStoreType=PKCS12 \
-Djavax.net.ssl.keyStore=client.p12 \
-Djavax.net.ssl.keyStorePassword=changeit \
-Djavax.net.ssl.keyStoreType=PKCS12 \
MtlsClientSystemPropsHTTP status: 200This version proves that JSSE picked up the system properties because the client code does not load any keystore or truststore manually — the same pattern you would use for a packaged JAR you cannot recompile.
For Spring Boot, map the same files to server.ssl.key-store, server.ssl.trust-store, and server.ssl.client-auth=need as described in Spring Boot HTTPS with keytool.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
certificate_required |
Server has needClientAuth but client has no keystore |
Load client.p12 via KeyManagerFactory or javax.net.ssl.keyStore |
PKIX path building failed on client |
Server cert not signed by a CA in client-trust.p12 |
Import issuing CA with keytool -importcert |
PKIX path building failed on server |
Client cert not chained to a CA in server-trust.p12 |
Import root CA or specific client .crt into server-trust.p12 |
| Client certificate rejected even though CA is trusted | Client certificate missing clientAuth EKU or wrong certificate purpose |
Reissue the client certificate with extendedKeyUsage = clientAuth |
| Client rejects server certificate purpose | Server certificate missing serverAuth EKU |
Reissue the server certificate with extendedKeyUsage = serverAuth |
| Handshake OK but wrong hostname | URL hostname does not match server SAN | Use 127.0.0.1 when cert has IP SAN, or fix DNS SAN |
| Server accepts any client | setWantClientAuth instead of setNeedClientAuth |
Use setNeedClientAuth(true) when client certs are mandatory |
Enable JSSE debug when PKIX messages are unclear:
java -Djavax.net.debug=ssl,handshake MtlsClient with-client-keyTrim the log to the certificate_required or PKIX block — full debug is verbose.
References
- keytool man page —
PrivateKeyEntry,trustedCertEntry,-genkeypair,-importcert - Java Secure Socket Extension (JSSE) reference guide —
javax.net.ssl.*properties - RFC 8446 — TLS 1.3 — client certificate authentication
Summary
mTLS adds two files to the familiar TLS pair: a client identity keystore and a server truststore. Use keytool to generate PKCS12 key pairs, export CSRs, import signed certificates with the right extended key usage (serverAuth / clientAuth), and build truststores with keytool -importcert of your root CA. On the server, load both stores into SSLContext and call setNeedClientAuth(true). On the client, load client-trust.p12 for server validation and client.p12 for identity — trust alone triggers certificate_required, and identity plus trust returns HTTP 200.

