Your App Just Bricked Itself
Picture this. You ship a banking app with certificate pinning enabled. Security team is happy. Compliance is happy. Three months later, your CA rotates their intermediate certificate. Completely routine for them. Catastrophic for you.
Every user on the old app version can no longer connect to your API. No graceful fallback, no error message they can act on, just a blank screen or a cryptic network error. And you can't push a fix because the app can't reach your servers to check for updates.
Congratulations. You've built the world's most secure brick.
What Pinning Actually Does on Mobile
On the web, pinning died with HPKP (good riddance). But mobile is different. You control the client, so you can embed expected certificate data directly in the app binary. When the app connects to your server, it checks the presented certificate chain against what it has stored. If nothing matches, connection refused.
Android and iOS handle this differently, and the differences matter more than most docs suggest.
On Android, you've got the Network Security Config since API 24. XML-based, declarative, reasonably sane:
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.yourapp.com</domain>
<pin-set expiration="2026-09-01">
<!-- current intermediate -->
<pin digest="SHA-256">YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</pin>
<!-- backup pin, DIFFERENT CA entirely -->
<pin digest="SHA-256">sRHdihwgkaib1P1gN7SkKPEoHGk6diqd4B/hHeQdaRY=</pin>
</pin-set>
</domain-config>
</network-security-config>
See that expiration attribute? Most tutorials skip it. But it's your escape hatch. After that date,
Android stops enforcing the pins entirely. So if you screw up your pin rotation, at least the app recovers
eventually. Set it aggressively, like 6 months out, and keep shipping updates.
iOS Makes You Work Harder
Apple doesn't give you a built-in pinning config. You're either using URLSession delegate methods
or a library like TrustKit. Most teams use TrustKit because writing your own certificate validation logic is
exactly how you introduce the vulnerabilities you were trying to prevent.
// TrustKit config in AppDelegate
// pin the SPKI hash, not the whole cert
let config: [String: Any] = [
kTSKSwizzleNetworkDelegates: true,
kTSKPinnedDomains: [
"api.yourapp.com": [
kTSKEnforcePinning: true,
kTSKIncludeSubdomains: true,
kTSKPublicKeyHashes: [
"YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=",
"sRHdihwgkaib1P1gN7SkKPEoHGk6diqd4B/hHeQdaRY="
],
// no built-in expiration like Android, you're on your own
kTSKReportUris: ["https://reporting.yourapp.com/pin-failure"]
]
]
]
TrustKit.initSharedInstance(withConfiguration: config)
Notice there's no expiration mechanism. If your pins go stale on iOS, users are stuck until they update. And App Store review can take anywhere from a few hours to a few days, which is an eternity when your app is dead.
Pin the Public Key, Not the Certificate
This is where most teams get it wrong on the first try.
If you pin the leaf certificate (the exact cert your server presents), you're pinning something that changes every 90 days if you use Let's Encrypt, or every year with commercial CAs. Every renewal means a new pin. Every new pin means a new app release. That math doesn't work.
Pin the Subject Public Key Info (SPKI) hash instead. When you renew a certificate but reuse the same private key, the SPKI hash stays the same. You buy yourself time.
# grab the SPKI hash from a live server
openssl s_client -connect api.yourapp.com:443 2>/dev/null | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
base64
But here's the catch. "Reuse the same private key" is itself a security trade-off. Best practice says you should rotate keys periodically. And if your key gets compromised, you must generate a new one. So SPKI pinning doesn't eliminate the rotation problem; it just makes it less frequent.
The Backup Pin Strategy That Actually Works
Always pin at least two keys. Your current one and a backup from a completely different CA.
Some teams generate a backup key pair, store the private key in a vault, and pin its public key hash in the app. They never use it unless the primary fails. A cold spare, basically.
Here's the process that's saved a few production incidents I've seen:
# generate backup key pair (store private key in HSM/vault, never on a server)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out backup-key.pem
# extract SPKI hash for pinning
openssl pkey -in backup-key.pem -pubout -outform DER | \
openssl dgst -sha256 -binary | \
base64
# output: sRHdihwgkaib1P1gN7SkKPEoHGk6diqd4B/hHeQdaRY=
# this hash goes in your app config as the backup pin
# the private key goes in your vault with a big DO NOT DELETE label
When your primary cert rotates or your CA has an incident, you generate a CSR with the backup key, get it signed by the backup CA, and deploy. Zero app updates needed because the backup SPKI hash was already in the binary.
I've seen a fintech company survive a CA migration this way. Went from DigiCert to Sectigo over a weekend, no user impact whatsoever, because they'd pinned a Sectigo-signed backup key hash 8 months earlier.
The Update Gap Problem
Even with perfect pin management, you've got the long-tail problem. Not everyone updates their apps.
Look at your analytics. On Android especially, you'll have users running versions from 18 months ago. Those old versions have old pins. If those pins no longer match your certificate chain, those users are locked out forever, or until they manually update (which they won't, because the app "doesn't work" so why would they open the store).
Some strategies that help:
- Android's
expirationattribute is your friend. Use it. - Force-update mechanisms that bypass pinning. Tricky to implement safely, but possible if you have a separate unpinned endpoint just for version checks.
- Progressive pin rotation. When you add a new pin, keep the old one for at least two release cycles. Give users time to update naturally.
- Monitor pin validation failures server-side. TrustKit's report URI feature is exactly for this. If you see a spike in failures, you know a rotation went wrong before the support tickets start flooding in.
When Not to Bother
Pinning adds real operational complexity. For most apps, it's overkill.
If you're building a social media app, a shopping app, a content platform, you probably don't need it. Certificate Transparency logs catch mis-issuance these days. Browsers and OS vendors respond fast when a CA gets compromised. The attack pinning prevents, a compromised or rogue CA targeting your specific domain, is real but rare.
Where it does make sense: banking, healthcare, government, anything handling financial transactions. If an attacker would specifically invest in compromising a CA to intercept your traffic, pin. If your threat model is "script kiddies on coffee shop WiFi," standard TLS is already enough.
And if your compliance framework requires it? Pin, obviously. But pin intelligently, with backup keys, with expiration dates, with monitoring. The number of teams I've seen ship pinning with a single pin and no rotation plan is genuinely alarming.
A Minimal Rotation Checklist
If you're going to pin, at minimum you need this:
- Two SPKI pins from different CAs in every app release
- A backup private key in cold storage that matches one of those pins
- Pin failure reporting endpoint (unpinned, obviously)
- Expiration dates on Android pins, 6 months ahead of your renewal schedule
- A documented runbook for "CA rotated intermediates and everything is on fire"
- Calendar reminders 30 days before pin expiration
Skip any of these and you're gambling. Maybe it works fine for two years. Maybe your CA rotates intermediates on a Tuesday morning and your on-call engineer spends 14 hours trying to figure out why 40% of users suddenly can't log in.
Pinning is a commitment, not a checkbox. Treat it like one.