Implementing Mutual TLS (mTLS) and Certificate Pinning in Android and Spring Boot: A Comprehensive Guide


In today’s increasingly connected world, security has become a primary concern for applications that communicate over the network. Ensuring that both the server and the client are authenticated is paramount, especially when handling sensitive data. This is where mutual TLS (mTLS) and certificate pinning come into play. Together, they provide an additional layer of security by ensuring that both parties are trusted and by protecting against potential certificate-based attacks.
In this blog post, we'll walk you through how to implement mTLS in both an Android client and a Spring Boot server, leveraging methods like checkServerTrusted()
and checkClientTrusted()
for certificate validation. Additionally, we'll explore how certificate pinning can further strengthen the security of your mTLS implementation.
What is TLS, mTLS, and Certificate Pinning?
TLS (Transport Layer Security)
TLS is a cryptographic protocol designed to provide secure communication over a computer network. It ensures data privacy, integrity, and authenticity of the communication between the client and server. TLS is the successor to SSL (Secure Sockets Layer) and is widely used to secure connections like HTTPS.
In the TLS handshake, the server proves its identity to the client by presenting a certificate, which is verified by the client using a Certificate Authority (CA). Once the server is authenticated, the client and server establish a secure encrypted communication channel.
What is Mutual TLS (mTLS)?
While traditional TLS only requires the server to authenticate itself, mutual TLS (mTLS) extends this concept by requiring both parties to authenticate each other. In mTLS, both the client and server exchange certificates, and both are validated by the other party. This two-way authentication ensures that not only is the server trustworthy, but the client is as well.
In mTLS, both the client and server present their certificates, and the trust is established based on those certificates, ensuring both the confidentiality and integrity of the data exchanged between them.
What is Certificate Pinning?
Certificate Pinning is a security technique used to prevent man-in-the-middle (MITM) attacks by ensuring that the client only accepts a specific certificate or public key from a server. With certificate pinning, the client "pins" the server’s certificate or public key to a specific known value. If the server's certificate changes (e.g., during a malicious attack), the client will reject the connection, as the certificate won't match the pinned one.
While mTLS verifies the identity of both the server and the client using certificates, certificate pinning adds an extra layer of security. It ensures that the client only trusts a specific certificate, even if an attacker tries to impersonate the server with a different certificate.
Why Use mTLS and Certificate Pinning?
Enhanced Security
With mTLS, both the client and the server authenticate each other using certificates. This prevents unauthorized access by ensuring that both parties are verified. Certificate pinning enhances this by ensuring the client only accepts a pre-defined certificate from the server, preventing any MITM attacks.
Protection Against Certificate Spoofing
Without pinning, an attacker could potentially replace the server's certificate with a fraudulent one. However, with certificate pinning, even if a malicious party tries to issue a fraudulent certificate, the client will reject the connection because the certificate doesn't match the pinned one.
Reduced Risk of Impersonation
Both mTLS and certificate pinning help prevent attackers from impersonating legitimate clients or servers, thus mitigating the risk of fraudulent access or data leakage.
Steps to Implement mTLS with Certificate Pinning in Android and Spring Boot
Let’s dive into the implementation steps for mTLS and certificate pinning in both Android and Spring Boot.
Step 1: Preparing the Certificates
For mTLS and certificate pinning to function properly, the following certificates are required:
Server Certificate: This is the certificate for the server, signed by a Certificate Authority (CA).
Client Certificate: This is the certificate for the client, also signed by the same CA.
CA Certificate: The root certificate used to verify both the server and client certificates.
You can generate these certificates using OpenSSL for testing purposes:
# Generate the CA certificate
openssl genpkey -algorithm RSA -out ca.key
openssl req -key ca.key -new -x509 -out ca.crt
# Generate the server certificate
openssl genpkey -algorithm RSA -out server.key
openssl req -key server.key -new -out server.csr
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt
# Generate the client certificate
openssl genpkey -algorithm RSA -out client.key
openssl req -key client.key -new -out client.csr
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt
Step 2: Configuring the Android Client for mTLS with Certificate Pinning
In Android, the client needs to validate the server’s certificate using the checkServerTrusted()
method. This method ensures that the server’s certificate is valid and signed by a trusted CA. Additionally, we'll configure certificate pinning to ensure that the client only trusts a specific certificate or public key.
Android Client Implementation:
Store the certificates in the res/raw
directory:
client.p12
(Client certificate and private key)ca.crt
(CA certificate)server.crt
(Server certificate)
Certificate Pinning Implementation in Android:
import javax.net.ssl.X509TrustManager;
import java.security.cert.X509Certificate;
import java.security.cert.CertificateException;
import java.security.PublicKey;
import java.security.SignatureException;
import java.security.InvalidKeyException;
import java.security.cert.CertificateFactory;
import java.io.InputStream;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class MyTrustManager implements X509TrustManager {
private X509TrustManager defaultTrustManager;
public MyTrustManager(X509TrustManager defaultTrustManager) {
this.defaultTrustManager = defaultTrustManager;
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
// Validate the server's certificate chain
try {
if (chain == null || chain.length == 0) {
throw new IllegalArgumentException("Server certificate chain is empty.");
}
// Validate the server's certificate against the trusted CA
X509Certificate serverCertificate = chain[0];
serverCertificate.checkValidity(); // Check if the server certificate is valid
serverCertificate.verify(getCA().getPublicKey()); // Verify the server certificate is signed by the CA
// Pinning validation: Check if the server certificate's public key matches the pinned key
String serverPublicKeyPin = getServerPublicKeyPin();
String serverPublicKey = getPublicKeyFingerprint(serverCertificate.getPublicKey());
if (!serverPublicKey.equals(serverPublicKeyPin)) {
throw new CertificateException("Certificate pinning validation failed");
}
} catch (Exception e) {
throw new CertificateException("Certificate validation failed", e);
}
// Call default trust manager to perform standard checks (Optional)
try {
defaultTrustManager.checkServerTrusted(chain, authType);
} catch (CertificateException e) {
throw new CertificateException("Failed to validate the server certificate", e);
}
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
throw new UnsupportedOperationException("Client authentication is not required for this trust manager.");
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return null;
}
private X509Certificate getCA() throws Exception {
// Load the CA certificate
InputStream caInput = getClass().getResourceAsStream("/raw/ca.crt"); // Your CA certificate
CertificateFactory cf = CertificateFactory.getInstance("X.509");
return (X509Certificate) cf.generateCertificate(caInput);
}
private String getPublicKeyFingerprint(PublicKey publicKey) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] keyBytes = publicKey.getEncoded();
byte[] digest = md.digest(keyBytes);
return bytesToHex(digest);
}
private String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
private String getServerPublicKeyPin() {
// The pinned public key or hash
return "b9d8ff308eb0a3f6fdc0f1a5da0526b28bc4242e8e6d4d1ff8244cddfffb8bb4"; // Replace with actual pin
}
}
Explanation:
Certificate Pinning: The method
getPublicKeyFingerprint()
calculates the SHA-256 hash of the server's public key. This hash is compared against a pre-defined pinned public key hash (getServerPublicKeyPin()
).If the server's public key doesn't match the pinned hash, the connection is rejected, ensuring that only the expected server certificate is trusted.
import java.io.InputStream;
import java.security.KeyFactory;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLSocketFactory;
import android.content.Context;
import java.security.cert.Certificate;
import java.io.ByteArrayInputStream;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
public class SSLUtil {
public static SSLSocketFactory getSSLSocketFactory(Context context) throws Exception {
// Load the client certificate (P12 format) and the private key
InputStream clientCertInputStream = context.getResources().openRawResource(R.raw.client_p12); // your client certificate (P12 file)
InputStream caCertInputStream = context.getResources().openRawResource(R.raw.ca_cert); // your CA certificate
char[] clientCertPassword = "your_password".toCharArray(); // password for the client certificate (if any)
// Load the client certificate from the P12 file
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(clientCertInputStream, clientCertPassword);
// Set up the KeyManagerFactory to provide the client certificate and private key
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
keyManagerFactory.init(keyStore, clientCertPassword);
// Set up the TrustManagerFactory to validate the server's certificate (CA certificate)
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
Certificate caCertificate = certificateFactory.generateCertificate(caCertInputStream);
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
trustStore.setCertificateEntry("ca", caCertificate);
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(trustStore);
// Initialize the SSLContext with the KeyManagers and TrustManagers
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(keyManagerFactory.getKeyManagers(), new X509TrustManager[] { new MyTrustManager() }, null);
// Return the SSLSocketFactory
return sslContext.getSocketFactory();
}
}
The above code is creating an SSLSocketFactory
using a client certificate and a CA certificate to establish a secure SSL connection in an Android application.
import android.os.Bundle;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import javax.net.ssl.HttpsURLConnection;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import javax.net.ssl.SSLContext;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
try {
// Set up the SSLContext for mutual TLS with certificate pinning
SSLSocketFactory sslSocketFactory = SSLUtil.getSSLSocketFactory(this);
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
// Start a network operation in a separate thread
new Thread(() -> {
try {
makeRequest(sslSocketFactory ); // Pass the SSLContext to makeRequest
} catch (Exception e) {
e.printStackTrace();
}
}).start();
} catch (Exception e) {
e.printStackTrace();
Toast.makeText(this, "Error in SSL setup", Toast.LENGTH_LONG).show();
}
}
private void makeRequest(SSLSocketFactory sslSocketFactory ) throws Exception {
// Set up the HTTPS connection to the Node.js server
URL url = new URL("https://localhost:3000");
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
// Use the provided SSLContext with mutual TLS for the connection
connection.setSSLSocketFactory(sslSocketFactory);
connection.setRequestMethod("GET");
connection.setDoOutput(true);
// Send the request and get the response
OutputStream os = connection.getOutputStream();
os.write("".getBytes());
os.flush();
os.close();
int responseCode = connection.getResponseCode();
if (responseCode == HttpsURLConnection.HTTP_OK) {
// Handle the response
runOnUiThread(() -> Toast.makeText(MainActivity.this, "Connected successfully", Toast.LENGTH_LONG).show());
} else {
runOnUiThread(() -> Toast.makeText(MainActivity.this, "Failed to connect", Toast.LENGTH_LONG).show());
}
}
}
Step 3: Configuring the Spring Boot Server for mTLS
In Spring Boot, the server must request and validate the client certificate using the checkClientTrusted()
method, while also ensuring that it is using the correct public key for authentication.
Spring Boot Server Implementation:
Store the certificates:
server.p12
(Server private key and certificate)ca.crt
(CA certificate)
Spring Boot Implementation:
import javax.net.ssl.X509TrustManager;
import java.security.cert.X509Certificate;
import java.security.cert.CertificateException;
import java.security.PublicKey;
import java.security.SignatureException;
import java.security.InvalidKeyException;
import java.security.cert.CertificateFactory;
import java.io.FileInputStream;
import java.io.InputStream;
public class MyServerTrustManager implements X509TrustManager {
private X509TrustManager defaultTrustManager;
public MyServerTrustManager(X509TrustManager defaultTrustManager) {
this.defaultTrustManager = defaultTrustManager;
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
try {
if (chain == null || chain.length == 0) {
throw new IllegalArgumentException("Client certificate chain is empty.");
}
// Validate the client certificate
X509Certificate clientCertificate = chain[0];
clientCertificate.checkValidity(); // Check if the client certificate is valid
clientCertificate.verify(getCA().getPublicKey()); // Verify the client certificate is signed by the CA
// Pinning validation: Ensure that the client certificate matches the pinned certificate
String clientPublicKeyPin = getClientPublicKeyPin();
String clientPublicKey = getPublicKeyFingerprint(clientCertificate.getPublicKey());
if (!clientPublicKey.equals(clientPublicKeyPin)) {
throw new CertificateException("Certificate pinning validation failed");
}
} catch (Exception e) {
throw new CertificateException("Certificate validation failed", e);
}
// Call default trust manager to perform standard checks (Optional)
try {
defaultTrustManager.checkClientTrusted(chain, authType);
} catch (CertificateException e) {
throw new CertificateException("Failed to validate the client certificate", e);
}
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
throw new UnsupportedOperationException("Server authentication is not required for this trust manager.");
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return null;
}
private X509Certificate getCA() throws Exception {
// Load the CA certificate
InputStream caInput = new FileInputStream("path/to/ca.crt"); // Your CA certificate
CertificateFactory cf = CertificateFactory.getInstance("X.509");
return (X509Certificate) cf.generateCertificate(caInput);
}
private String getPublicKeyFingerprint(PublicKey publicKey) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] keyBytes = publicKey.getEncoded();
byte[] digest = md.digest(keyBytes);
return bytesToHex(digest);
}
private String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
private String getClientPublicKeyPin() {
// The pinned client public key or hash
return "b9d8ff308eb0a3f6fdc0f1a5da0526b28bc4242e8e6d4d1ff8244cddfffb8bb4"; // Replace with actual pin
}
}
Conclusion
By implementing mTLS along with certificate pinning, you add an additional layer of security that ensures both the server and client are authentic, and that no fraudulent or malicious server can impersonate a legitimate one. Certificate pinning ensures that the client will only trust a specific, known certificate, even if a valid certificate authority (CA) has issued a fraudulent certificate. Together, these two technologies help safeguard sensitive data and transactions from potential attackers.
This enhanced security approach will help you build more secure, robust, and trusted connections in your applications, ensuring data integrity and privacy.
Happy Coding !!!
Subscribe to my newsletter
Read articles from Prashant Bale directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Prashant Bale
Prashant Bale
With 17+ years in software development and 14+ years specializing in Android app architecture and development, I am a seasoned Lead Android Developer. My comprehensive knowledge spans all phases of mobile application development, particularly within the banking domain. I excel at transforming business needs into secure, user-friendly solutions known for their scalability and durability. As a proven leader and Mobile Architect.