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.
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 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:
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
</application>
iOS (Swift)
Option 1: URLSession Delegate
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
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
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
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
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
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.