Certificate pinning adds a critical layer of protection against man-in-the-middle attacks by ensuring your application only trusts specific certificates, not just any certificate from a trusted CA. This guide covers implementation for mobile and web applications.
Understanding Certificate Pinning
┌─────────────────────────────────────────────────────────────────────────┐
│ STANDARD TLS VALIDATION │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────┐ certificate ┌────────┐ validate ┌────────────┐ │
│ │ Server │ ─────────────────►│ App │ ──────────────►│ Trust Store│ │
│ └────────┘ └────────┘ │ (100+ CAs) │ │
│ └────────────┘ │
│ Problem: Any of 100+ CAs can issue valid certs for your domain │
│ Risk: Compromised CA = MITM attack with valid certificate │
│ │
├─────────────────────────────────────────────────────────────────────────┤
│ WITH CERTIFICATE PINNING │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────┐ certificate ┌────────┐ validate ┌────────────┐ │
│ │ Server │ ─────────────────►│ App │ ──────────────►│ Pinned Keys│ │
│ └────────┘ └────────┘ + match │ (1-3 keys) │ │
│ │ └────────────┘ │
│ │ │
│ ┌────▼────┐ │
│ │ Reject │ ← If cert doesn't match pins │
│ └─────────┘ │
│ │
│ Solution: Only accept specific certificates/keys you control │
│ Benefit: Rogue CA certificates are rejected │
│ │
└─────────────────────────────────────────────────────────────────────────┘
What to Pin
| Target | Security | Flexibility | Recommendation |
|---|---|---|---|
| Leaf Certificate | Highest | Lowest | Requires app update on every cert renewal |
| Intermediate CA | High | Medium | Recommended - survives leaf rotation |
| Root CA | Low | Highest | Too broad - trusts all certs from CA |
| Public Key (SPKI) | High | High | Best - survives cert renewal if key reused |
Extracting Pin Hashes
Get Public Key Hash (SPKI)
# From a live server
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
base64
# Output: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
# From a certificate file
openssl x509 -in certificate.crt -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
base64
Get Intermediate CA Hash
# View the certificate chain
openssl s_client -connect api.example.com:443 -showcerts </dev/null 2>/dev/null
# Extract intermediate (certificate 1, not 0)
openssl s_client -connect api.example.com:443 -showcerts </dev/null 2>/dev/null | \
awk '/-----BEGIN CERTIFICATE-----/{i++}i==2' | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
base64
iOS Implementation
Native URLSession
import Foundation
import Security
import CryptoKit
class PinnedURLSessionDelegate: NSObject, URLSessionDelegate {
// SHA-256 hashes of pinned public keys (SPKI)
private let pinnedPublicKeyHashes: 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 certificate chain
let policies = [SecPolicyCreateSSL(true, challenge.protectionSpace.host as CFString)]
SecTrustSetPolicies(serverTrust, policies as CFArray)
var error: CFError?
guard SecTrustEvaluateWithError(serverTrust, &error) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Check if any certificate in chain matches our pins
let certificateCount = SecTrustGetCertificateCount(serverTrust)
for i in 0..<certificateCount {
guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, i),
let publicKey = SecCertificateCopyKey(certificate),
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
continue
}
// Hash the public key
let hash = SHA256.hash(data: publicKeyData)
let hashBase64 = Data(hash).base64EncodedString()
if pinnedPublicKeyHashes.contains(hashBase64) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
return
}
}
// No pin matched - reject connection
print("Certificate pinning failed for \(challenge.protectionSpace.host)")
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
// Usage
let delegate = PinnedURLSessionDelegate()
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
let task = session.dataTask(with: URL(string: "https://api.example.com/data")!) { data, response, error in
if let error = error {
print("Request failed: \(error)")
return
}
// Handle response
}
task.resume()
Using TrustKit (Recommended)
// In AppDelegate or early initialization
import TrustKit
func configureTrustKit() {
let trustKitConfig: [String: Any] = [
kTSKSwizzleNetworkDelegates: true,
kTSKPinnedDomains: [
"api.example.com": [
kTSKEnforcePinning: true,
kTSKPublicKeyHashes: [
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
],
kTSKIncludeSubdomains: true,
kTSKReportUris: ["https://report.example.com/pinning"]
]
]
]
TrustKit.initSharedInstance(withConfiguration: trustKitConfig)
}
Android Implementation
Network Security Config (Android 7+)
<!-- 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">
<!-- Primary pin -->
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<!-- Backup pin (REQUIRED) -->
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</domain-config>
</network-security-config>
<!-- AndroidManifest.xml -->
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
OkHttp CertificatePinner
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
val certificatePinner = CertificatePinner.Builder()
.add(
"api.example.com",
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
)
.add(
"*.example.com", // Wildcard support
"sha256/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="
)
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
// Usage with Retrofit
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
React Native (with Fetch)
// Using react-native-ssl-pinning
import { fetch } from 'react-native-ssl-pinning';
const response = await fetch('https://api.example.com/data', {
method: 'GET',
timeoutInterval: 10000,
sslPinning: {
certs: ['cert1', 'cert2'] // Certificate files in assets
}
});
// Or with public key hashes
const response = await fetch('https://api.example.com/data', {
method: 'GET',
pkPinning: true,
sslPinning: {
certs: [
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB='
]
}
});
Web Applications
HPKP Deprecation and Alternatives
HPKP was deprecated due to risks. Modern alternatives:
┌─────────────────────────────────────────────────────────────────┐
│ HPKP (Deprecated 2018) │
├─────────────────────────────────────────────────────────────────┤
│ Public-Key-Pins: pin-sha256="..."; max-age=5184000 │
│ │
│ Risks: │
│ • "HPKP Suicide" - lose keys = site becomes inaccessible │
│ • "RansomPKP" - attacker pins their keys via XSS │
│ • Complex rotation requiring careful planning │
│ │
├─────────────────────────────────────────────────────────────────┤
│ MODERN ALTERNATIVES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Certificate Transparency (CT) Monitoring │
│ - Monitor crt.sh or CT logs for unauthorized certs │
│ - Alert on certificates you didn't request │
│ │
│ 2. Expect-CT Header (Report-Only) │
│ Expect-CT: max-age=86400, report-uri="https://..." │
│ - Requires CT compliance, reports violations │
│ │
│ 3. CAA DNS Records │
│ example.com. CAA 0 issue "letsencrypt.org" │
│ - Restricts which CAs can issue for your domain │
│ │
└─────────────────────────────────────────────────────────────────┘
CAA Records (DNS-Based Pinning)
; Only Let's Encrypt can issue certificates
example.com. CAA 0 issue "letsencrypt.org"
example.com. CAA 0 issuewild "letsencrypt.org"
; Report violations
example.com. CAA 0 iodef "mailto:[email protected]"
Certificate Transparency Monitoring
# Query CT logs for your domain
curl "https://crt.sh/?q=%.example.com&output=json" | jq '.[].id'
# Set up monitoring with certspotter
pip install certspotter
certspotter --domain example.com --webhook https://alerts.example.com/ct
Pin Rotation Strategy
┌─────────────────────────────────────────────────────────────────┐
│ PIN ROTATION TIMELINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Month 1 Month 2 Month 3 Month 4 │
│ ────────────────────────────────────────────────────────────── │
│ │
│ [Pin A: Active] [Pin A: Active] [Pin A: Removed] │
│ [Pin B: Backup] [Pin B: Active] [Pin B: Active] │
│ [Pin C: Added] [Pin C: Backup] │
│ │
│ Steps: │
│ 1. Always have 2+ pins (active + backup) │
│ 2. Add new backup pin BEFORE removing old pin │
│ 3. Wait for app update propagation (2-4 weeks) │
│ 4. Rotate active pin to use new certificate │
│ 5. Remove old pin only after full propagation │
│ │
└─────────────────────────────────────────────────────────────────┘
Automated Pin Rotation Check
#!/usr/bin/env python3
"""Check if pinned certificates are expiring soon."""
import ssl
import socket
from datetime import datetime, timedelta
import hashlib
import base64
def get_cert_pins(hostname, port=443):
"""Extract public key pins from server certificate chain."""
context = ssl.create_default_context()
pins = []
with socket.create_connection((hostname, port)) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
cert_chain = ssock.getpeercert(binary_form=True)
# Note: This gets leaf cert only; for full chain, use different approach
cert = ssl.DER_cert_to_PEM_cert(cert_chain)
# Extract and hash public key
# (Simplified - use cryptography library for production)
return pins
def check_pin_expiration(pins, warning_days=30):
"""Check if any pinned certificates are expiring soon."""
alerts = []
today = datetime.now()
for pin in pins:
days_until_expiry = (pin['expiration'] - today).days
if days_until_expiry < warning_days:
alerts.append({
'pin': pin['hash'],
'expires': pin['expiration'],
'days_remaining': days_until_expiry
})
return alerts
# Check and alert
alerts = check_pin_expiration([
{'hash': 'AAAA...', 'expiration': datetime(2025, 6, 1)},
{'hash': 'BBBB...', 'expiration': datetime(2025, 12, 1)}
])
for alert in alerts:
print(f"WARNING: Pin {alert['pin'][:20]}... expires in {alert['days_remaining']} days")
Emergency Bypass Mechanism
Include a kill switch for emergencies:
// iOS - Remote config based bypass
class PinningConfiguration {
static let shared = PinningConfiguration()
var isPinningEnabled: Bool {
// Check remote config
return RemoteConfig.shared.getBool("pinning_enabled", default: true)
}
var pinnedHashes: [String] {
// Allow remote pin updates for emergencies
if let remotePins = RemoteConfig.shared.getStringArray("pinned_keys") {
return remotePins
}
return defaultPins
}
private let defaultPins = [
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
]
}
// Android - Remote config based bypass
object PinningConfig {
private val remoteConfig = FirebaseRemoteConfig.getInstance()
val isPinningEnabled: Boolean
get() = remoteConfig.getBoolean("pinning_enabled")
val pins: List<String>
get() {
val remotePins = remoteConfig.getString("pinned_keys")
return if (remotePins.isNotEmpty()) {
remotePins.split(",")
} else {
defaultPins
}
}
private val defaultPins = listOf(
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
)
}
Testing Certificate Pinning
Verify Pinning Works
# Use mitmproxy or Charles Proxy to intercept traffic
# If pinning is working, the app should refuse to connect
# Android: Install proxy CA as user cert (won't be trusted for pinned domains)
# iOS: Trust proxy CA in Settings > General > About > Certificate Trust Settings
Debugging Pin Failures
// iOS - Detailed logging
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let serverTrust = challenge.protectionSpace.serverTrust else {
print("No server trust available")
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let count = SecTrustGetCertificateCount(serverTrust)
print("Certificate chain contains \(count) certificates")
for i in 0..<count {
if let cert = SecTrustGetCertificateAtIndex(serverTrust, i) {
let summary = SecCertificateCopySubjectSummary(cert) as String? ?? "Unknown"
print("Certificate \(i): \(summary)")
if let publicKey = SecCertificateCopyKey(cert),
let data = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? {
let hash = SHA256.hash(data: data)
print(" Public key hash: \(Data(hash).base64EncodedString())")
}
}
}
// Continue with validation...
}
Best Practices
- Always include backup pins - At least 2 pins to survive rotation
- Pin intermediate CA - Better balance of security and flexibility
- Use public key pinning - Survives certificate renewal with same key
- Implement kill switch - Remote config to disable in emergencies
- Monitor expiration - Alert well before pinned certs expire
- Test thoroughly - Use proxy tools to verify pinning works
- Plan rotation - Document pin rotation procedure
- Report failures - Log pinning failures for security monitoring
- Consider CT - Certificate Transparency as complementary protection
- Use CAA records - Restrict which CAs can issue for your domain
Next Steps
- TLS Configuration Hardening - Secure cipher suites and protocols
- mTLS Authentication - Client certificate authentication
- TLS Certificate Complete Guide - Comprehensive certificate management