Pinning Sounds Simple Until You Ship It
Certificate pinning is one of those security features that looks dead simple in documentation. Lock your app to a specific certificate or public key. Done. What could go wrong?
Everything. I've debugged enough pinning outages to write a horror anthology. Teams pin the leaf certificate that expires in 90 days. They generate SHA-1 hashes when the library expects SHA-256. They ship with exactly one pin and no backup.
Then the certificate rotates. Or the CA changes. And suddenly your production mobile app can't talk to your API. No over-the-air fix. Just millions of bricked installs waiting for users to update.
Mistake #1: Pinning the Leaf Certificate
This is the big one. Pinning the end-entity certificate instead of the intermediate or root CA public key.
Why it matters: leaf certificates rotate frequently. Let's Encrypt is 90 days. Commercial CAs are often 1 year. If you pin the leaf cert, you're signing up to coordinate every cert renewal with an app release.
Miss that window? Your app stops working the second the old cert expires. No gradual rollout. Just instant death.
// This will break when cert renews
const leafCertPin = "sha256/r8udi6Mvb7lYLSjVVcST8u8Afo4Tgb0kh3Y1oa1mXqY=";
// Pin the intermediate CA public key instead
const intermediateCaPin = "sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=";
const backupCaPin = "sha256/Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys=";
if (serverCertChain.some(cert =>
sha256(cert.publicKey) === intermediateCaPin ||
sha256(cert.publicKey) === backupCaPin
)) {
// Good to go
} else {
throw new SecurityException("Certificate pin validation failed");
}
Pin the public key of your intermediate CA. It rotates way less often. Better yet, pin the root CA public key, which might stay stable for 10+ years.
But here's the catch: root certs aren't always sent in the TLS handshake. Most servers only send leaf + intermediate. You need to extract the public key from the intermediate and pin that.
Mistake #2: Wrong Hash Algorithm
You generate a SHA-1 hash. Your HTTP library expects SHA-256. Or you pin the whole certificate instead of just the Subject Public Key Info (SPKI).
Different tools use different formats:
- Android Network Security Config: Wants base64-encoded SHA-256 of the SPKI
- iOS TrustKit: Same, SHA-256 SPKI
- OkHttp CertificatePinner: SHA-256 SPKI with "sha256/" prefix
- Alamofire: Can do full cert or public key, needs explicit config
Getting the right hash requires the right openssl incantation:
# Extract SPKI and hash it (correct way)
openssl x509 -in cert.pem -pubkey -noout | \
openssl pkey -pubin -outform der | \
openssl dgst -sha256 -binary | \
base64
# Output: YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=
# Wrong way (hashing the whole cert)
openssl x509 -in cert.pem -outform der | \
openssl dgst -sha256 -binary | \
base64
# This hash won't match at runtime
Test this before shipping. Connect with a pinned client in staging. Rotate the cert and verify the backup pin works. I've seen teams discover hash mismatches in production when it's too late to fix.
Mistake #3: No Backup Pins
Shipping with exactly one pin is asking for trouble. Your CA gets compromised. Or acquired. Or you migrate infrastructure and need to switch CAs fast.
With one pin, you're stuck. Emergency cert rotation means bricking every installed app until users update.
Always ship with at least two pins: your current CA and a backup. Some teams do three: current intermediate, backup intermediate, and the root.
// Android network_security_config.xml
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set>
<!-- Current Let's Encrypt intermediate -->
<pin digest="SHA-256">YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</pin>
<!-- Backup: DigiCert intermediate -->
<pin digest="SHA-256">Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys=</pin>
<!-- Backup: ISRG Root X1 -->
<pin digest="SHA-256">C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=</pin>
</pin-set>
</domain-config>
</network-security-config>
Pre-generate pins for CAs you might switch to. Keep them in your config even if you're not using them yet. When you need to rotate, you just change the server cert. The app already trusts the new CA.
Mistake #4: Pinning in the Wrong Layer
Some codebases pin at the wrong abstraction level. Pinning in a shared HTTP client sounds good until you realize third-party SDKs use their own network stack.
Your analytics SDK, crash reporter, and ad network all bypass your pinned client. They're making unpinned requests and you didn't notice because your API calls work fine.
On mobile, use OS-level pinning when possible:
- Android: Network Security Config applies system-wide to your app (API 24+)
- iOS: TrustKit swizzles NSURLSession, covers most SDKs
If you're pinning in code, make sure every network library respects it. Or pin at the OS level and let the platform handle it.
Mistake #5: Forgetting Development and Staging
You pin production certs. Your staging environment uses a different CA (maybe even self-signed). Debug builds can't connect to staging because the pins don't match.
Developers work around it by disabling pinning in debug builds. Then someone accidentally ships a debug build to production, or the preprocessor flag doesn't work as expected, and pinning is off in prod.
Better approach: separate pin sets per environment.
// iOS example with multiple environments
let trustKit = TrustKit(configuration: [
kTSKPinnedDomains: [
"api.production.com": [
kTSKPublicKeyHashes: [
"YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=",
"Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys="
]
],
"api.staging.com": [
kTSKPublicKeyHashes: [
"s8dR3Xc9f7YjH3kL2pQw9MnBvCxZ1aTgF5eUoI6rY4s=", // staging CA
]
]
]
])
Or use a debug flag that changes the pinned domains, not whether pinning happens. You want to test the pinning logic in dev, just with different hashes.
Mistake #6: No Monitoring or Graceful Degradation
When pinning fails, most implementations just throw an exception and kill the request. Users see a cryptic network error. Your crash logs show TLS failures but no context.
Add telemetry. Log pin validation failures to your analytics before throwing. Track how often it happens, which pins are being rejected, what the server sent.
Some teams build a "report mode" that logs failures but doesn't block requests. Ship it for a few days before enforcement to catch misconfigurations.
func validatePin(serverCert: Certificate) -> Bool {
let serverPin = sha256(serverCert.publicKeyInfo)
if trustedPins.contains(serverPin) {
analytics.track("pin_validation_success")
return true
} else {
analytics.track("pin_validation_failure", properties: [
"server_pin": serverPin,
"expected_pins": trustedPins,
"cert_issuer": serverCert.issuer
])
// In report-only mode, log but don't block
if reportOnlyMode {
return true
}
return false
}
}
If you're enterprise, consider a remote kill switch. A config endpoint that can disable pinning if things go sideways. Controversial, but better than bricking millions of installs.
What Actually Works
After dealing with enough pinning disasters, here's what I've seen work:
- Pin intermediate CA public keys, not leaf certs
- Always ship 2-3 backup pins
- Use SHA-256 SPKI hashes (verify with openssl before shipping)
- Pin at the OS level when possible (Network Security Config, TrustKit)
- Separate pin sets for prod/staging, don't disable pinning in debug
- Log validation failures with context
- Test cert rotation in staging before prod renewal
- Have a runbook for emergency pin updates (or a kill switch if you're brave)
Pinning isn't inherently fragile. But the implementation details will wreck you if you're not careful. The difference between "defense in depth" and "self-inflicted outage" is about six lines of config.