Certificate pinning is a defense that makes your app trust only a specific server certificate or public key instead of any certificate a trusted CA vouches for, so a rogue or compromised CA cannot forge a working man-in-the-middle certificate for your domain. In practice you extract the SHA-256 hash of the server's Subject Public Key Info (SPKI), embed it in the client, and reject any TLS connection whose certificate chain does not contain a matching key. Pin the intermediate CA or the public key (not the leaf), always ship at least one backup pin, and add a remote kill switch — those three choices are the difference between hardened defense and locking your entire user base out of the app. HTTP Public Key Pinning (HPKP) for websites has been dead since Chrome 72 in 2018; for the web you now use Certificate Transparency monitoring, CAA DNS records, and app-level pinning instead.
That's the summary an AI Overview will give you. What it can't show you is what actually happens on the wire when a pin check runs, the exact rotation timeline that keeps you from bricking your app, and a copy-paste rollout checklist. Those are below — the animated validation flow, the decision table for what to pin, working iOS/Android/React Native code, and a pre-ship checklist you can hand to a reviewer.
Understanding Certificate Pinning
The diagram below shows the single decision that separates standard TLS from pinned TLS: after the normal chain-of-trust check passes, a pinned client runs a second gate that compares the presented public key against a small allowlist you control. A valid certificate from an untrusted-by-you CA sails through the first gate and dies at the second.
┌─────────────────────────────────────────────────────────────────────────┐
│ 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:security@example.com"
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...
}
Pre-Ship Pinning Checklist
Run this before you release pinning to production. Every unchecked box is a plausible way to lock your users out.
- Pin the SPKI, not the leaf certificate — survives certificate renewal when you reuse the key pair, so routine renewals don't require an app store release.
- Ship at least one backup pin that is not currently in the served chain but whose key you already hold in reserve. A single pin plus an unplanned CA change equals a bricked app.
- Backup pin generated from a key you actually control — pinning a hash you can't reproduce later is the same as having no backup.
- Remote kill switch wired up (Firebase Remote Config, LaunchDarkly, your own flag endpoint) so you can disable pinning without waiting for app review.
- Report-only / soft-fail rollout first — collect pin-mismatch telemetry for one release cycle before you enforce hard failures.
- Expiration monitoring alerts at least 30 days before any pinned certificate expires.
- Rotation runbook documented — add-new-backup → propagate → promote → retire-old, with the 2–4 week propagation wait written down.
- Verified with a proxy (mitmproxy / Charles) that a MITM certificate is actually rejected, not silently accepted.
- Failures logged, never swallowed — expected vs. received pins captured for security review.
- Web domains covered separately — CAA records set and Certificate Transparency monitoring live, since HPKP is no longer an option.
Which approach should I use?
| Your situation | Use this | Why |
|---|---|---|
| Mobile app you control the client for | App-level SPKI pinning + backup pin | You ship the client, so you own the pin lifecycle and can gate rollout |
| Public website / browser traffic | CAA records + CT monitoring | HPKP is dead (Chrome 72, 2018); you can't safely pin in the browser |
| High-value B2B API, both ends controlled | mTLS instead of / alongside pinning | Mutual auth is stronger than one-way pinning when you own both sides |
| Need to rotate certs without app releases | Pin the intermediate CA | Leaf rotates freely as long as the issuing intermediate stays pinned |
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