Certificate Pinning
Category: Security
Complexity: Advanced
Prerequisites: Certificate Anatomy, Chain Of Trust, Tls Protocol
Related: Common Vulnerabilities, Private Key Protection, Trust Models
Overview
Certificate pinning is a security technique where applications explicitly trust specific certificates or public keys rather than relying solely on the system's trust store. This hardens security by preventing man-in-the-middle (MITM) attacks even when an attacker has compromised a Certificate Authority or installed rogue root certificates on the device.
Why Pin Certificates?
Traditional TLS validation weaknesses:
- Any CA in the trust store can issue certificates for any domain
- ~100+ root CAs trusted by default on most systems
- Compromise of any single CA threatens all connections
- Government-backed CAs may issue certificates for surveillance
- Rogue certificates have been issued (DigiNotar 2011, CNNIC 2015)
Pinning provides defense-in-depth:
Normal TLS:
App → System Trust Store (100+ CAs) → Accept any valid certificate
With Pinning:
App → Built-in pins → Only accept specific certificates/keys
Pinning Strategies
1. Certificate Pinning
Pin the entire certificate (including validity dates and signature).
Advantages:
- Simple to implement
- Exact match required
- No ambiguity
Disadvantages:
- Requires app update when certificate expires
- Inflexible for certificate rotation
- High operational burden
Implementation:
import hashlib
import ssl
from typing import Set
class CertificatePinner:
"""
Pin entire certificates by their SHA-256 hash
"""
def __init__(self, pinned_certs: Set[str]):
"""
Args:
pinned_certs: Set of SHA-256 hashes of DER-encoded certificates
"""
self.pinned_certs = pinned_certs
def verify_certificate(self, cert_der: bytes) -> bool:
"""
Verify certificate matches one of the pins
"""
cert_hash = hashlib.sha256(cert_der).hexdigest()
if cert_hash not in self.pinned_certs:
raise SecurityError(
f"Certificate hash {cert_hash} not in pinned set. "
f"Expected one of: {self.pinned_certs}"
)
return True
# Usage in application
API_CERT_PINS = {
# Production certificate (expires 2025-12-31)
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
# Backup certificate (expires 2026-06-30)
"d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
}
pinner = CertificatePinner(API_CERT_PINS)
2. Public Key Pinning (RECOMMENDED)
Pin the public key component only, ignoring certificate metadata.
Advantages:
- Survives certificate renewal (same key pair)
- More flexible for operations
- Recommended by OWASP
- Can pin intermediate or root CA keys
Disadvantages:
- Slightly more complex to extract public key
- Must still rotate when keys change
Implementation:
import hashlib
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
class PublicKeyPinner:
"""
Pin Subject Public Key Info (SPKI) using SHA-256
"""
def __init__(self, pinned_spki_hashes: Set[str]):
"""
Args:
pinned_spki_hashes: Set of base64-encoded SHA-256 hashes of SPKI
"""
self.pinned_spki_hashes = pinned_spki_hashes
def extract_spki_hash(self, cert_der: bytes) -> str:
"""
Extract and hash the Subject Public Key Info from certificate
"""
cert = x509.load_der_x509_certificate(cert_der, default_backend())
# Get public key
public_key = cert.public_key()
# Serialize to SubjectPublicKeyInfo format
spki_der = public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# Hash and base64 encode
spki_hash = hashlib.sha256(spki_der).digest()
return base64.b64encode(spki_hash).decode('ascii')
def verify_certificate(self, cert_der: bytes) -> bool:
"""
Verify certificate's public key matches one of the pins
"""
spki_hash = self.extract_spki_hash(cert_der)
if spki_hash not in self.pinned_spki_hashes:
raise SecurityError(
f"Public key hash {spki_hash} not in pinned set. "
f"This could indicate a MITM attack or certificate change."
)
return True
# Usage - pins that survive certificate renewal
API_SPKI_PINS = {
# Primary key (used across multiple certificate renewals)
"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=",
# Backup key (for emergency rotation)
"58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=",
# CA public key (pin the issuer for additional security)
"hI0z9TjTa9Xq+PnBW4J9vKvp+Pq8dqLRFzXsLxJwXqI=",
}
pinner = PublicKeyPinner(API_SPKI_PINS)
3. Certificate Authority Pinning
Pin the intermediate or root CA certificate/key.
Advantages:
- No updates needed for individual certificate rotation
- Reasonable security improvement
- Lower operational burden
Disadvantages:
- Less protection than endpoint pinning
- Still vulnerable if CA is compromised
- Doesn't protect against CA mis-issuance
Use case: Balance between security and operational flexibility.
class CAPinner:
"""
Pin Certificate Authority public keys
"""
def __init__(self, ca_spki_hashes: Set[str]):
self.ca_spki_hashes = ca_spki_hashes
def verify_chain(self, cert_chain: List[bytes]) -> bool:
"""
Verify at least one certificate in chain has pinned key
Args:
cert_chain: List of DER-encoded certificates from leaf to root
"""
pinner = PublicKeyPinner(self.ca_spki_hashes)
# Check each certificate in chain
for cert_der in cert_chain:
try:
spki_hash = pinner.extract_spki_hash(cert_der)
if spki_hash in self.ca_spki_hashes:
return True
except Exception:
continue
raise SecurityError(
"No certificate in chain matches pinned CA keys. "
"Chain may be compromised or using unexpected CA."
)
# Pin your organization's CA
INTERNAL_CA_PINS = {
# Internal Root CA
"r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=",
# Internal Intermediate CA
"YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=",
}
4. Multi-Pin Strategy (BEST PRACTICE)
Combine multiple pins for defense-in-depth and operational flexibility.
Pin multiple points in the trust chain:
class MultiPinValidator:
"""
Validate certificate chain against multiple pin types
"""
def __init__(self, config: PinningConfig):
self.config = config
def validate_chain(self, cert_chain: List[x509.Certificate]) -> bool:
"""
Validate using multiple pinning strategies
"""
results = {
'leaf_pin': False,
'intermediate_pin': False,
'root_pin': False,
}
# Extract leaf, intermediate, and root
leaf_cert = cert_chain[0]
intermediate_certs = cert_chain[1:-1]
root_cert = cert_chain[-1] if len(cert_chain) > 1 else None
# Check leaf certificate pin
if self.config.leaf_pins:
leaf_spki = self._extract_spki_hash(leaf_cert)
results['leaf_pin'] = leaf_spki in self.config.leaf_pins
# Check intermediate certificate pins
if self.config.intermediate_pins and intermediate_certs:
for cert in intermediate_certs:
inter_spki = self._extract_spki_hash(cert)
if inter_spki in self.config.intermediate_pins:
results['intermediate_pin'] = True
break
# Check root certificate pin
if self.config.root_pins and root_cert:
root_spki = self._extract_spki_hash(root_cert)
results['root_pin'] = root_spki in self.config.root_pins
# Apply validation policy
return self._apply_policy(results)
def _apply_policy(self, results: dict) -> bool:
"""
Apply pinning policy (e.g., require leaf OR intermediate pin)
"""
if self.config.policy == 'strict':
# Require leaf pin AND (intermediate OR root) pin
return results['leaf_pin'] and (
results['intermediate_pin'] or results['root_pin']
)
elif self.config.policy == 'balanced':
# Require any two pins to match
return sum(results.values()) >= 2
elif self.config.policy == 'flexible':
# Require at least one pin to match
return any(results.values())
raise ValueError(f"Unknown policy: {self.config.policy}")
# Configuration example
config = PinningConfig(
leaf_pins={
"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=", # Primary
"58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=", # Backup
},
intermediate_pins={
"YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=", # Your CA
},
root_pins={
"r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=", # Your Root CA
},
policy='balanced' # Any two pins must match
)
Platform-Specific Implementation
iOS (Swift)
Using NSPinnedDomains in Info.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>api.example.com</key>
<dict>
<key>NSIncludesSubdomains</key>
<true/>
<key>NSPinnedLeafIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=</string>
</dict>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=</string>
</dict>
</array>
<key>NSPinnedCAIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</string>
</dict>
</array>
</dict>
</dict>
</dict>
</dict>
</plist>
Manual implementation with URLSession:
import Foundation
import Security
class CertificatePinner: NSObject, URLSessionDelegate {
// SHA-256 hashes of pinned public keys (base64 encoded)
private let pinnedKeys: Set<String> = [
"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=",
"58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=",
]
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// Only handle server trust challenges
guard challenge.protectionSpace.authenticationMethod ==
NSURLAuthenticationMethodServerTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Validate pin
if validatePins(serverTrust: serverTrust) {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
// Pin validation failed - reject connection
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
private func validatePins(serverTrust: SecTrust) -> Bool {
// Get certificate chain
let certificateCount = SecTrustGetCertificateCount(serverTrust)
// Check each certificate in chain
for index in 0..<certificateCount {
guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, index) else {
continue
}
// Extract public key
guard let publicKey = SecCertificateCopyKey(certificate) else {
continue
}
// Get public key data
guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
continue
}
// Hash the public key
let publicKeyHash = sha256(data: publicKeyData)
let publicKeyHashBase64 = publicKeyHash.base64EncodedString()
// Check if this key is pinned
if pinnedKeys.contains(publicKeyHashBase64) {
return true
}
}
return false
}
private func sha256(data: Data) -> Data {
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
}
return Data(hash)
}
}
// Usage
let pinner = CertificatePinner()
let sessionConfig = URLSessionConfiguration.default
let session = URLSession(
configuration: sessionConfig,
delegate: pinner,
delegateQueue: nil
)
Android (Kotlin)
Declarative pinning with Network Security Configuration:
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Pin specific domain -->
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2025-12-31">
<!-- Primary key -->
<pin digest="SHA-256">X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=</pin>
<!-- Backup key -->
<pin digest="SHA-256">58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=</pin>
<!-- CA key for flexibility -->
<pin digest="SHA-256">YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</pin>
</pin-set>
</domain-config>
</network-security-config>
<!-- AndroidManifest.xml -->
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
</application>
Programmatic pinning with OkHttp:
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
class SecureApiClient {
private val certificatePinner = CertificatePinner.Builder()
.add(
"api.example.com",
"sha256/X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=", // Primary
"sha256/58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=", // Backup
"sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=" // CA
)
.build()
private val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
fun makeSecureRequest(url: String): String {
val request = Request.Builder()
.url(url)
.build()
return try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Unexpected response: $response")
}
response.body?.string() ?: ""
}
} catch (e: SSLPeerUnverifiedException) {
// Certificate pinning failure
Log.e("SecureApi", "Certificate pinning failed", e)
throw SecurityException("Certificate validation failed - possible MITM attack")
}
}
}
Web Browsers (HTTP Public Key Pinning - DEPRECATED)
WARNING: HPKP is deprecated and removed from modern browsers due to operational risks.
# DO NOT USE - Shown for historical context only
Public-Key-Pins:
pin-sha256="X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=";
pin-sha256="58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=";
max-age=5184000;
includeSubDomains
Why HPKP was deprecated:
- Pin misconfiguration could permanently break websites
- No safe recovery mechanism if all pinned keys lost
- Limited adoption due to risk
- Better alternatives (Certificate Transparency, Expect-CT)
Modern alternative - Certificate Transparency:
Python (requests library)
import requests
import ssl
import hashlib
from requests.adapters import HTTPAdapter
from urllib3.util.ssl_ import create_urllib3_context
class PinnedHTTPAdapter(HTTPAdapter):
"""
HTTP adapter that validates certificate pins
"""
def __init__(self, pinned_spki_hashes, *args, **kwargs):
self.pinned_spki_hashes = pinned_spki_hashes
super().__init__(*args, **kwargs)
def init_poolmanager(self, *args, **kwargs):
# Create SSL context with custom verification
context = create_urllib3_context()
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
# Store original verify function
original_verify = context.check_hostname
# Wrap with pin verification
def verify_with_pins(cert, hostname):
# First do normal verification
if not original_verify(cert, hostname):
return False
# Then verify pins
return self._verify_pins(cert)
context.check_hostname = verify_with_pins
kwargs['ssl_context'] = context
return super().init_poolmanager(*args, **kwargs)
def _verify_pins(self, cert):
"""
Verify certificate's SPKI hash against pins
"""
# Extract SPKI and hash it
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
cert_obj = x509.load_der_x509_certificate(cert, default_backend())
public_key = cert_obj.public_key()
spki_der = public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
spki_hash = hashlib.sha256(spki_der).digest()
spki_hash_b64 = base64.b64encode(spki_hash).decode('ascii')
if spki_hash_b64 not in self.pinned_spki_hashes:
raise ssl.SSLError(
f"Certificate pin validation failed. "
f"Expected one of {self.pinned_spki_hashes}, "
f"got {spki_hash_b64}"
)
return True
# Usage
pinned_hashes = {
"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=",
"58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=",
}
session = requests.Session()
session.mount('https://', PinnedHTTPAdapter(pinned_hashes))
# Make pinned requests
response = session.get('https://api.example.com/data')
Certificate Rotation with Pinning
The Fundamental Challenge
Certificate pinning creates an operational challenge: how do you rotate certificates without breaking deployed applications?
The problem:
Day 0: Deploy app with pinned certificate (expires in 1 year)
Day 365: Certificate expires
Day 366: All apps stop working until users update
Result: Service outage for users who haven't updated
Strategy 1: Multiple Pins (Recommended)
Always pin at least 2 keys: current and future.
class RotationFriendlyPinner:
"""
Pinner designed for graceful rotation
"""
def __init__(self):
# Always maintain current + next key
self.pins = {
'current': "X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=",
'next': "58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=",
}
def validate(self, spki_hash: str) -> bool:
"""Accept any pinned key"""
return spki_hash in self.pins.values()
# Rotation process:
# 1. Deploy app with pins: [current, next]
# 2. When ready to rotate:
# a. Issue new certificate using 'next' key
# b. Generate new 'next+1' key
# c. Deploy app update with pins: [next, next+1]
# d. Old apps still work (they accept 'next' key)
# 3. After sufficient adoption, old key can be retired
Rotation timeline:
Month 0: App v1.0 released
Pins: [Key-A, Key-B]
Server uses: Key-A
Month 11: App v1.1 released
Pins: [Key-B, Key-C]
Server uses: Key-A (still works for v1.0 and v1.1)
Month 12: Sufficient adoption of v1.1 (e.g., 80%)
Server rotates to: Key-B
v1.0 and v1.1 both work
Month 23: App v1.2 released
Pins: [Key-C, Key-D]
Server uses: Key-B
Month 24: High adoption of v1.2
Server rotates to: Key-C
v1.0 stops working (acceptable - 1 year old)
v1.1 and v1.2 work
Strategy 2: Pin CA Instead of Leaf
Pin the CA certificate, avoiding need to update pins for each rotation.
# Pin your organization's CA
CA_PINS = {
"YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=", # Internal CA
}
# Leaf certificates can rotate freely as long as issued by pinned CA
# Trade-off: Less security than leaf pinning
Strategy 3: Dynamic Pin Updates
Update pins dynamically via secure channel.
WARNING: This approach has security implications.
class DynamicPinner:
"""
Update pins from server (use with extreme caution)
"""
def __init__(self, bootstrap_pins: Set[str]):
self.pins = bootstrap_pins
self.pin_update_url = "https://api.example.com/.well-known/pin-updates"
async def update_pins(self):
"""
Fetch new pins from server
CRITICAL: This request must itself be pinned to bootstrap pins
"""
# Use bootstrap pins for this request
async with PinnedSession(self.pins) as session:
response = await session.get(self.pin_update_url)
# Response must be signed by trusted key
pin_update = await self.verify_signature(response)
# Validate new pins
if self.validate_pin_update(pin_update):
self.pins.update(pin_update['new_pins'])
# Persist to local storage
await self.save_pins()
def validate_pin_update(self, update: dict) -> bool:
"""
Validate pin update for security
"""
# Must always include at least one current pin
if not any(pin in self.pins for pin in update['new_pins']):
raise SecurityError(
"Pin update must include at least one current pin"
)
# Must not remove all pins
if len(update['new_pins']) == 0:
raise SecurityError("Cannot remove all pins")
# Signature must be valid
if not update['signature_valid']:
raise SecurityError("Invalid signature on pin update")
return True
Risks of dynamic updates:
- If update mechanism is compromised, attacker can inject pins
- Creates additional attack surface
- Defeats purpose of pinning if not carefully implemented
-
Only use if combined with:
-
Digital signatures on updates
- Never removing all existing pins
- Rate limiting and anomaly detection
Strategy 4: Expiration-Aware Pinning
Build expiration awareness into the app.
from datetime import datetime, timedelta
class ExpirationAwarePinner:
"""
Gracefully handle pin expiration
"""
def __init__(self):
self.pins = {
"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=": {
'expires': datetime(2025, 12, 31),
'status': 'active',
},
"58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=": {
'expires': datetime(2026, 6, 30),
'status': 'future',
},
}
def validate(self, spki_hash: str) -> bool:
"""
Validate pin with expiration awareness
"""
if spki_hash not in self.pins:
return False
pin_info = self.pins[spki_hash]
# Check expiration
if datetime.now() > pin_info['expires']:
# Pin has expired
if self.should_enforce_after_expiration():
# Strict mode - reject
return False
else:
# Grace period - accept but warn
self.log_warning(
f"Using expired pin {spki_hash}. "
f"Expired: {pin_info['expires']}"
)
return True
return True
def should_enforce_after_expiration(self) -> bool:
"""
Decide whether to enforce pinning after expiration
"""
# Check if app is outdated
app_age = datetime.now() - self.app_install_date
if app_age > timedelta(days=180):
# Old app - don't enforce (avoid breaking old clients)
return False
else:
# Recent app - enforce
return True
Operational Considerations
Generating Pins
Extract SPKI hash from certificate file:
#!/bin/bash
# Extract and hash Subject Public Key Info
# For a certificate file
openssl x509 -in cert.pem -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
base64
# For a website's certificate
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
# For entire certificate chain
echo | openssl s_client -connect api.example.com:443 -showcerts 2>/dev/null | \
awk '/BEGIN CERT/,/END CERT/ {print}' | \
while read -r cert; do
echo "$cert" | openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
base64
done
Generate backup pins before deploying:
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import hashlib
import base64
def generate_backup_key_pin() -> tuple[str, bytes]:
"""
Generate a backup key pair and return the pin
Returns:
(pin_hash, private_key_pem)
"""
# Generate key pair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
# Extract public key
public_key = private_key.public_key()
# Serialize to SPKI format
spki_der = public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# Hash and encode
spki_hash = hashlib.sha256(spki_der).digest()
pin = base64.b64encode(spki_hash).decode('ascii')
# Export private key for safekeeping
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.BestAvailableEncryption(b'strong-password')
)
return pin, private_key_pem
# Generate backup key BEFORE deploying app
backup_pin, backup_key = generate_backup_key_pin()
print(f"Backup pin: {backup_pin}")
print("Store backup key in HSM or secure key vault")
Testing Pinning
Test harness for pin validation:
import pytest
from unittest.mock import Mock, patch
class TestCertificatePinning:
"""
Test suite for certificate pinning
"""
def test_valid_pin_accepted(self):
"""Valid pin should be accepted"""
pinner = PublicKeyPinner({"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE="})
# Mock valid certificate with matching pin
valid_cert = self.create_test_cert_with_pin(
"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE="
)
assert pinner.verify_certificate(valid_cert) == True
def test_invalid_pin_rejected(self):
"""Invalid pin should be rejected"""
pinner = PublicKeyPinner({"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE="})
# Mock certificate with different pin
invalid_cert = self.create_test_cert_with_pin(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
)
with pytest.raises(SecurityError):
pinner.verify_certificate(invalid_cert)
def test_multiple_pins_any_valid(self):
"""If multiple pins configured, any valid pin should work"""
pins = {
"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE=",
"58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU=",
}
pinner = PublicKeyPinner(pins)
# Test with second pin
cert = self.create_test_cert_with_pin(
"58qRu/uxh4gFezqAcERupSkRYBlBAvfcw7mEjGPLnNU="
)
assert pinner.verify_certificate(cert) == True
def test_expired_pin_handling(self):
"""Test behavior when pin has expired"""
pinner = ExpirationAwarePinner()
# Mock expired pin
with patch('datetime.datetime') as mock_datetime:
mock_datetime.now.return_value = datetime(2026, 1, 1)
cert = self.create_test_cert_with_pin(
"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE="
)
# Should log warning but accept in grace period
assert pinner.validate(cert) == True
def test_mitm_certificate_rejected(self):
"""Certificate from rogue CA should be rejected despite valid chain"""
pinner = PublicKeyPinner({"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE="})
# Mock certificate with valid signature but wrong pin
mitm_cert = self.create_mitm_cert()
with pytest.raises(SecurityError) as exc:
pinner.verify_certificate(mitm_cert)
assert "possible MITM attack" in str(exc.value).lower()
Integration testing with production endpoints:
#!/bin/bash
# Test pinning against live endpoints
set -e
API_HOST="api.example.com"
EXPECTED_PIN="X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1lkJZdZNg5iE="
echo "Testing certificate pinning for $API_HOST..."
# Get actual pin
ACTUAL_PIN=$(echo | openssl s_client -connect $API_HOST:443 -servername $API_HOST < /dev/null 2>/dev/null | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
base64)
echo "Expected pin: $EXPECTED_PIN"
echo "Actual pin: $ACTUAL_PIN"
if [ "$ACTUAL_PIN" = "$EXPECTED_PIN" ]; then
echo "✓ Pin matches"
exit 0
else
echo "✗ Pin mismatch - pinning will fail in production!"
exit 1
fi
Monitoring and Alerting
Pin validation metrics:
from prometheus_client import Counter, Histogram
# Metrics
pin_validation_total = Counter(
'cert_pin_validation_total',
'Total certificate pin validations',
['result', 'domain']
)
pin_validation_duration = Histogram(
'cert_pin_validation_duration_seconds',
'Time spent validating pins',
['domain']
)
class MonitoredPinner:
"""
Pinner with observability
"""
def __init__(self, pins: Set[str], domain: str):
self.pinner = PublicKeyPinner(pins)
self.domain = domain
def verify_certificate(self, cert_der: bytes) -> bool:
"""
Verify with metrics
"""
with pin_validation_duration.labels(domain=self.domain).time():
try:
result = self.pinner.verify_certificate(cert_der)
pin_validation_total.labels(
result='success',
domain=self.domain
).inc()
return result
except SecurityError as e:
pin_validation_total.labels(
result='failure',
domain=self.domain
).inc()
# Log detailed error
logger.error(
"Certificate pin validation failed",
extra={
'domain': self.domain,
'error': str(e),
'cert_hash': self.pinner.extract_spki_hash(cert_der),
'expected_pins': list(self.pinner.pinned_spki_hashes),
}
)
raise
Alert on pin mismatches:
# Prometheus alerting rule
groups:
- name: certificate_pinning
rules:
- alert: CertificatePinFailure
expr: |
rate(cert_pin_validation_total{result="failure"}[5m]) > 0.01
for: 5m
labels:
severity: critical
annotations:
summary: "Certificate pin validation failures detected"
description: |
Pin validation failing for {{ $labels.domain }}.
Rate: {{ $value | humanize }}
This could indicate:
- Certificate rotation without pin update
- MITM attack in progress
- Misconfigured pinning
- alert: CertificatePinNearExpiry
expr: |
cert_pin_expiry_days < 30
labels:
severity: warning
annotations:
summary: "Pinned certificate expiring soon"
description: |
Pinned certificate for {{ $labels.domain }} expires in {{ $value }} days.
Action required:
1. Generate new key pair
2. Update app with new pin
3. Deploy updated app
4. Rotate certificate after sufficient adoption
Incident Response
Pin validation failure runbook:
# Incident: Certificate Pin Validation Failures
## Symptoms
- Apps unable to connect to API
- "Certificate validation failed" errors
- Spike in pin validation failures
## Triage Steps
### 1. Verify Scope
```bash
# Check error rate by domain
curl '[Prometheus:9090 - Query](http://prometheus:9090/api/v1/query?query=rate(cert_pin_validation_total{result="failure"}[5m]))'
2. Check Certificate Status
3. Determine Root Cause
Scenario A: Legitimate Certificate Rotation - Certificate was rotated but app pins not updated - Impact: All app versions with old pins broken - Resolution: Rollback certificate OR emergency app update
Scenario B: MITM Attack - Unexpected certificate with different pin - Impact: Varies by attack scope - Resolution: Investigate, do not weaken pinning
Scenario C: Pin Misconfiguration - Wrong pins deployed in app update - Impact: New app version broken - Resolution: Emergency app update with correct pins
4. Remediation Actions
If legitimate rotation (Scenario A):
# Option 1: Rollback certificate (fastest)
kubectl rollout undo deployment/api-server
# Option 2: Emergency app update (if rollback not possible)
# 1. Build app with updated pins
# 2. Fast-track through app stores
# 3. Force update if critical
# Option 3: Temporarily disable pinning (LAST RESORT)
# Only if user impact severe and no other option
If MITM attack suspected (Scenario B):
# DO NOT disable pinning
# Investigate:
# - Check certificate chain
# - Verify DNS not hijacked
# - Check for rogue CA certificates on devices
# - Review network logs
Prevention
- Always maintain backup pins in deployed apps
- Test pins before certificate rotation
- Gradual rollout of certificate changes
- Monitor pin validation metrics continuously
- Document rotation procedures Without pinning: Attacker compromises CA → Issues rogue cert → MITM attack succeeds
With pinning: Attacker compromises CA → Issues rogue cert → App rejects cert (wrong pin)
Without pinning: Malware installs root CA → Issues cert for your domain → Intercepts trafficWith pinning: Malware installs root CA → Issues cert → App rejects (not pinned)
Without pinning: Corporate proxy uses trusted CA → Intercepts HTTPS → User unawareWith pinning: Corporate proxy certificate rejected → Connection fails → User alerted
### Threats NOT Mitigated
**Pinning does NOT protect against**:
- Application-level attacks (SQL injection, XSS, etc.)
- Compromised application code
- Stolen API keys or credentials
- Attacks before SSL/TLS handshake
- Physical device compromise (attacker can modify app)
### Operational Risks
**Risk 1: Pin Lockout**
### Best Practices Summary
**DO**:
- ✅ Pin public keys (SPKI), not full certificates
- ✅ Maintain multiple pins (current + backup)
- ✅ Pin both leaf and intermediate/root certificates
- ✅ Test pinning thoroughly before production
- ✅ Monitor pin validation metrics
- ✅ Document rotation procedures
- ✅ Use expiration awareness in apps
- ✅ Generate backup keys before deployment
**DON'T**:
- ❌ Pin only one certificate
- ❌ Use HPKP (deprecated)
- ❌ Deploy without backup pins
- ❌ Rotate certificates without updating pins
- ❌ Use dynamic pin updates without signatures
- ❌ Ignore pin validation failures
- ❌ Disable pinning in production (except absolute emergency)
## Real-World Examples
### Case Study 1: Twitter (2012)
**Challenge**: Protect against compromised CAs after DigiNotar incident
**Solution**:
- Implemented certificate pinning in Twitter iOS app
- Pinned both leaf certificates and CA keys
- Maintained multiple pins for rotation flexibility
**Outcome**:
- Successfully detected and blocked MITM attempts
- Set industry example for mobile app security
### Case Study 2: Google Chrome
**Implementation**: Chrome pins Google domains
```cpp
// Chromium source code (simplified)
static const char* kGooglePins[] = {
"sha256/4BjDjn8v2lWeUFQnqSs0BgbIcrU9LosQWGDWzQ=",
"sha256/GUAL5bejH7czkXcAeJ0vCiRxwMnVBsDlBMBsFtfLF8A=",
// ... multiple backup pins
};
Results:
- Protected hundreds of millions of users
- Detected multiple MITM attempts
- Influenced industry to adopt pinning
Case Study 3: Banking App Implementation
Requirements:
- Protect customer financial data
- Meet PCI-DSS requirements
- Support certificate rotation
Architecture:
Mobile App Pinning Strategy:
├── Primary API pin (current certificate)
├── Backup API pin (prepared for rotation)
├── CA pin (intermediate CA)
└── Root CA pin (ultimate fallback)
Rotation Process:
├── 90 days before expiry: Generate new key pair
├── 60 days before: Deploy app update with new backup pin
├── 30 days before: Monitor app adoption
├── Rotation day: Switch to new certificate
└── 90 days after: Remove old pin from next app version
Results:
- Zero outages during multiple rotations
- Detected 3 MITM attempts in corporate environments
- Achieved PCI-DSS compliance
Tools and Libraries
iOS
- Built-in:
NSPinnedDomains(iOS 14+) - TrustKit: Full-featured pinning framework
- Alamofire: Network library with pinning support
Android
- Built-in: Network Security Configuration (API 24+)
- OkHttp:
CertificatePinnerclass - Conscrypt: Advanced SSL provider
Web
- Certificate Transparency: Modern alternative to HPKP
- Expect-CT: Enforce CT logging
- React Native:
react-native-ssl-pinning
Backend
- Python:
requestswith custom adapter - Node.js:
tls.connect()with checkServerIdentity - Go:
tls.ConfigwithVerifyPeerCertificate
Further Reading
Standards
- RFC 7469: Public Key Pinning Extension for HTTP (HPKP - deprecated)
- RFC 6797: HTTP Strict Transport Security (HSTS)
- RFC 6962: Certificate Transparency
Documentation
- OWASP Certificate Pinning Cheat Sheet
- Apple App Transport Security documentation
- Android Network Security Configuration guide
Research Papers
- "The Risks of SSL Inspection" (2016)
- "Certificate Pinning in Practice" (2014)
- "Analysis of the HTTPS Certificate Ecosystem" (2013)
See Also: Common Vulnerabilities, Trust Models, Tls Protocol, Private Key Protection