Certificate pinning is a security technique where a client (like a mobile or desktop app) associates a specific server with an expected X.509 certificate or public key. When the client connects to the server, it verifies that the certificate presented matches the one it knows.

Why Pin Certificates?

The primary reason for pinning is to prevent man-in-the-middle (MITM) attacks where an attacker intercepts communication using a fake or invalid certificate. Pinning ensures your app only talks to a server you trust.

The implementation

We will create a simple command line application that fetches source code of my SaaS product web site CertWatch. Why command line? Because, it is good enough example that will work everywhere else and because it is isolated enough that it is easy to follow even for a casual tech follower.

The site uses Let's Encrypt certificate, that changes quite often, so beware that certificate public key used in this sample may not be valid at the time you read this article.

First, we need to obtain public key of server certificate. We can do this by either viewing certificate in the browser or use a tool like openssl.

private static string g_ssPublicKey =       
"3082010A0282010100C4FFFFE6F3945AD2BF27D5DD674166130D5D2021CEFF0" +
"4B06AD48F5F56A6245C590433121C8F08B7A565FBF38F1102917CB7434AFE91" +
"18E2CB904BA723D57182B680B872CF05578B234F65DB1A39CD77DEBD07D0939" +
"A0C440A9AE9245D0CAB59480DC3864D744BA6404B0D6DA9BAEE0E85CE0816D9" +
"D7F43468D2E073CBA2EA10114323B0053F8AE29F86AD846B71FE4D7924494FB" +
"0D80E3C78875085163B53121EBEBCF1356A4386DFF9E2CB93D0BD9CA3A39D4A" +
"AC7BB34F2FF4AC70D59DBCD92254D48DE0BC3CCB4A8B4822D64CCE46F1E539B" +
"116A00420825AD2AFF128F7A761D79186FB747761E47187BD527B1398F603DC" +
"F7DCABD3535C28B7FB2C3068230203010001";

In the program Main function, we will use HttpClient to connect to the web site. In order to set certificate handling and verification, we must first declare HttpClientHandler. The handler must do at least three things:

Before we implement the callback method, letโ€™s set a boiler plate for it and set first two items on the list. Validation method will return false and thus fail on every request.

using var handler = new HttpClientHandler(); 
        handler.CheckCertificateRevocationList = true;
        handler.ClientCertificateOptions = ClientCertificateOption.Manual;
        handler.ServerCertificateCustomValidationCallback = (message, certificate, chain, sslPolicyErrors) => { return false; };

Great. With that done, we can implement the body of the callback function. First, we need to check if the server web site certificate actually exists. If it doesn't we return false and print an error. Here, you would do some logging instead, but this is a demo app.

if (null == certificate)
{
    Console.WriteLine("ERROR: No server certificate found");
    return false;
}

Next, we verify there are no SSL policy errors in certificate chain. If they are, we return false and print all policy errors.

if (sslPolicyErrors != SslPolicyErrors.None)
{
    Console.WriteLine($"SSL Policy Errors: {sslPolicyErrors}");
    return false;
}

Then, we obtain, the public key of server certificate and check if it matches the one, we declared in our app. If there is no match, we again print an error and return false. If public keys match, then the certificate is correct and we successfully end validation.

string sPublicKeyToVerify = certificate.GetPublicKeyString();
if (g_ssPublicKey != sPublicKeyToVerify)
{
    Console.WriteLine("ERROR: Certificate public key mismatch");
    return false;
}

Console.WriteLine("Certificate public key matches");
return true;

Now, all we have to do, is call our website and print it's source code. The validation procedure will run immediately after SSL handshake is established.

using var httpClient = new HttpClient(handler);
string sResponse = await httpClient.GetStringAsync("https://certwatch.dev");
Console.WriteLine(sResponse);

In less than 50 lines of code, we have implemented a working, robust certificate pinning that ensures our app only communicates with our genuine API server. This simple technique significantly raises the bar against possible man-in-the-middle attacks.

Complete working example is embedded below. If you cannot see it, it is also published as gist on Github.

using System;
using System.Net;
using System.Net.Security;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
					
public class Program
{
	private static string g_ssPublicKey =
		"3082010A0282010100C4FFFFE6F3945AD2BF27D5DD674166130D5D2021CEFF0" +
		"4B06AD48F5F56A6245C590433121C8F08B7A565FBF38F1102917CB7434AFE91" +
		"18E2CB904BA723D57182B680B872CF05578B234F65DB1A39CD77DEBD07D0939" +
		"A0C440A9AE9245D0CAB59480DC3864D744BA6404B0D6DA9BAEE0E85CE0816D9" +
		"D7F43468D2E073CBA2EA10114323B0053F8AE29F86AD846B71FE4D7924494FB" +
		"0D80E3C78875085163B53121EBEBCF1356A4386DFF9E2CB93D0BD9CA3A39D4A" +
		"AC7BB34F2FF4AC70D59DBCD92254D48DE0BC3CCB4A8B4822D64CCE46F1E539B" +
		"116A00420825AD2AFF128F7A761D79186FB747761E47187BD527B1398F603DC" +
		"F7DCABD3535C28B7FB2C3068230203010001";
	
	public static async Task Main()
	{
		using var handler = new HttpClientHandler(); 
		handler.CheckCertificateRevocationList = true;
		handler.ClientCertificateOptions = ClientCertificateOption.Manual;
		handler.ServerCertificateCustomValidationCallback = (message, certificate, chain, sslPolicyErrors) =>
		{
			if (null == certificate)
			{
				Console.WriteLine("ERROR: No server certificate found");
				return false;
			}

			if (sslPolicyErrors != SslPolicyErrors.None)
			{
				Console.WriteLine($"SSL Policy Errors: {sslPolicyErrors}");
				return false;
			}

			string sPublicKeyToVerify = certificate.GetPublicKeyString();
			if (g_ssPublicKey != sPublicKeyToVerify)
			{
				Console.WriteLine("ERROR: Certificate public key mismatch");
				return false;
			}
			
			// here you can do other certificate checks (e.g. check dates valid from and to, issuer etc.)

			Console.WriteLine("Certificate public key matches");
			return true;
		};
		
		using var httpClient = new HttpClient(handler);
		string sResponse = await httpClient.GetStringAsync("https://certwatch.dev");
		Console.WriteLine(sResponse);
	}
}

The Operational Challenge

While the code is straightforward, the ongoing management of pinned certificates is not. Teams must track expiry dates, coordinate rotations, and update client apps. This usually ends up as a manual process, which is prone to error and often the cause of preventable outages.

If managing this process across multiple APIs feels like a burden that is likely to cause a production incident, you might be interested in CertWatch, a tool I'm building to automate SSL certificate monitoring and alerting.