What is SSL Pinning?

SSL Certificate Pinning is a security technique that associates a host with its expected X.509 certificate or public key. Once a certificate or public key is known or "pinned" to a host, the app will reject connections to that host if the certificate doesn't match.

Why Use SSL Pinning?

  • Prevent Man-in-the-Middle Attacks - Attackers can't intercept traffic even with a forged CA certificate
  • Enhanced Security - Goes beyond standard TLS verification
  • Compliance - Required by many security standards (PCI-DSS, HIPAA)
  • Trust Assurance - Only trust certificates you explicitly approve

Pinning Approaches

Approach Pros Cons
Certificate Pinning Simple to implement Requires app update when certificate changes
Public Key Pinning Survives certificate renewal (if key unchanged) Slightly more complex
SPKI Hash Pinning Modern approach, stable across renewals Requires hash calculation
⚠️

Important: Always Include Backup Pins

Always include at least one backup pin (intermediate CA or backup public key) to avoid locking users out of your app if the certificate changes unexpectedly.

Android (Kotlin/Java)

Option 1: OkHttp CertificatePinner

This is the most common approach for Android apps using OkHttp or Retrofit.

Kotlin
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient

val certificatePinner = CertificatePinner.Builder()
    .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
    // Add backup pin for certificate rotation
    .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
    .build()

val client = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

// Use with Retrofit
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(client)
    .addConverterFactory(GsonConverterFactory.create())
    .build()

Option 2: Network Security Config (Android 7.0+)

XML-based configuration that doesn't require code changes.

1. Create res/xml/network_security_config.xml:

XML
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="false">
        <domain includeSubdomains="true">api.example.com</domain>
        <pin-set expiration="2025-12-31">
            <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
            <!-- Backup pin -->
            <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
        </pin-set>
    </domain-config>
</network-security-config>

2. Reference in AndroidManifest.xml:

XML
<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>
</application>

iOS (Swift)

Option 1: URLSession Delegate

Swift
import Foundation
import CryptoKit

class SSLPinningDelegate: NSObject, URLSessionDelegate {
    
    private let pinnedHashes: Set<String> = [
        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // Primary
        "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="  // Backup
    ]
    
    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }
        
        // Validate the certificate chain
        var error: CFError?
        guard SecTrustEvaluateWithError(serverTrust, &error) else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }
        
        // Check public key hash
        let certificateCount = SecTrustGetCertificateCount(serverTrust)
        for index in 0..<certificateCount {
            guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, index),
                  let publicKey = SecCertificateCopyKey(certificate),
                  let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
                continue
            }
            
            let hash = SHA256.hash(data: publicKeyData)
            let hashString = Data(hash).base64EncodedString()
            
            if pinnedHashes.contains(hashString) {
                completionHandler(.useCredential, URLCredential(trust: serverTrust))
                return
            }
        }
        
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

// Usage
let delegate = SSLPinningDelegate()
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)

Option 2: Alamofire ServerTrustManager

Swift
import Alamofire

let pinnedCertificates: [SecCertificate] = {
    let url = Bundle.main.url(forResource: "api.example.com", withExtension: "cer")!
    let data = try! Data(contentsOf: url)
    return [SecCertificateCreateWithData(nil, data as CFData)!]
}()

let evaluators: [String: ServerTrustEvaluating] = [
    "api.example.com": PinnedCertificatesTrustEvaluator(certificates: pinnedCertificates)
]

let manager = ServerTrustManager(evaluators: evaluators)
let session = Session(serverTrustManager: manager)

// Make requests
session.request("https://api.example.com/endpoint").responseJSON { response in
    // Handle response
}

Flutter (Dart)

Using Dio with BadCertificateCallback

Dart
import 'dart:io';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:crypto/crypto.dart';

class SecureHttpClient {
  static final List<String> _pinnedHashes = [
    'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // Primary
    'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', // Backup
  ];

  static Dio createSecureDio() {
    final dio = Dio(BaseOptions(
      baseUrl: 'https://api.example.com',
      connectTimeout: const Duration(seconds: 30),
      receiveTimeout: const Duration(seconds: 30),
    ));

    dio.httpClientAdapter = IOHttpClientAdapter(
      createHttpClient: () {
        final client = HttpClient();
        client.badCertificateCallback = (cert, host, port) {
          // Calculate hash of the certificate
          final certDer = cert.der;
          final hash = sha256.convert(certDer);
          final hashBase64 = base64Encode(hash.bytes);
          
          // Check if hash matches any pinned hash
          return _pinnedHashes.contains(hashBase64);
        };
        return client;
      },
    );

    return dio;
  }
}

// Usage
final dio = SecureHttpClient.createSecureDio();
final response = await dio.get('/api/data');

React Native

Using react-native-ssl-pinning

JavaScript
import { fetch } from 'react-native-ssl-pinning';

// Using certificate file (place in android/app/src/main/assets/)
const fetchWithCertPin = async () => {
  try {
    const response = await fetch('https://api.example.com/data', {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
      sslPinning: {
        certs: ['api_example_com'], // Certificate file name without extension
      },
    });
    
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('SSL Pinning Error:', error);
    throw error;
  }
};

// Using public key hash
const fetchWithHashPin = async () => {
  try {
    const response = await fetch('https://api.example.com/data', {
      method: 'GET',
      sslPinning: {
        certs: ['sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='],
      },
    });
    
    return await response.json();
  } catch (error) {
    console.error('SSL Pinning Error:', error);
    throw error;
  }
};

Node.js

Using HTTPS Agent

JavaScript
const https = require('https');
const crypto = require('crypto');

const PINNED_HASHES = [
  'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // Primary
  'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', // Backup
];

const agent = new https.Agent({
  checkServerIdentity: (host, cert) => {
    // Get the public key hash
    const publicKey = cert.pubkey;
    const hash = crypto.createHash('sha256').update(publicKey).digest('base64');
    
    if (!PINNED_HASHES.includes(hash)) {
      throw new Error(`Certificate pin mismatch for ${host}. Got: ${hash}`);
    }
    
    // Return undefined to indicate success
    return undefined;
  },
});

// Using with native https
https.get('https://api.example.com/data', { agent }, (res) => {
  // Handle response
});

// Using with axios
const axios = require('axios');
const client = axios.create({
  httpsAgent: agent,
});

await client.get('https://api.example.com/data');

Python

Using Requests with Certificate Verification

Python
import requests
import hashlib
import base64
from requests.adapters import HTTPAdapter
from urllib3.util.ssl_ import create_urllib3_context

PINNED_HASHES = [
    'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',  # Primary
    'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=',  # Backup
]


class PinningAdapter(HTTPAdapter):
    def __init__(self, *args, **kwargs):
        self.pinned_hashes = kwargs.pop('pinned_hashes', [])
        super().__init__(*args, **kwargs)

    def send(self, request, *args, **kwargs):
        response = super().send(request, *args, **kwargs)
        
        # Get the peer certificate
        sock = response.raw._connection.sock
        cert_der = sock.getpeercert(binary_form=True)
        
        # Calculate SHA-256 hash
        cert_hash = base64.b64encode(hashlib.sha256(cert_der).digest()).decode()
        
        if cert_hash not in self.pinned_hashes:
            raise Exception(f'Certificate pin mismatch! Got: {cert_hash}')
        
        return response


# Usage
session = requests.Session()
session.mount('https://api.example.com', PinningAdapter(pinned_hashes=PINNED_HASHES))

response = session.get('https://api.example.com/data')
print(response.json())

Best Practices

1. Always Include Backup Pins

Never ship your app with only one pin. If the certificate changes and you have no backup, users will be locked out until they update.

2. Pin the Public Key, Not the Certificate

Public key pinning is more resilient to certificate renewals, as long as the key pair remains the same.

3. Consider Pinning the Intermediate CA

Pinning an intermediate CA provides a balance between security and operational flexibility.

4. Implement Certificate Rotation Strategy

  • Generate backup key pairs before you need them
  • Add the backup public key hash to your app before rotating
  • Wait for users to update before removing old pins

5. Monitor for Pinning Failures

Implement reporting for pinning failures to detect potential attacks or misconfigurations.

6. Set Pin Expiration (Android)

Use the expiration attribute in Network Security Config to fail open if pins expire.

SSLens Makes It Easy

Use SSLens to quickly get the correct public key hashes for your domains. The generated code includes backup pin placeholders to remind you to add them.