How does certificate pinning actually work, and what breaks when you get it wrong?
Pinning trades one kind of risk — a rogue CA impersonating your API — for another: rotate the server's certificate without a plan, and you can brick every installed copy of your app.
LEARN BY DOINGPut your intuition to work3 steps
How does certificate pinning actually work, and what breaks when you get it wrong?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The trust problem pinning is actually solving
Standard TLS already verifies that your app is talking to a server holding a certificate signed by some certificate authority the OS trusts — but "some CA the OS trusts" is a much bigger set than most people assume. Hundreds of root and intermediate CAs ship trusted by default across Android and iOS, any enterprise MDM profile can add a corporate root the device will trust silently, and history has more than one incident of a CA mis-issuing a certificate for a domain it had no business signing for. Certificate pinning narrows "any CA the OS trusts" down to "specifically this certificate, or this key, and nothing else" — so even a compromised or overly permissive CA in the trust store can't be used to man-in-the-middle your app's traffic, whether that MITM is a criminal on public wifi or a corporate proxy silently inspecting traffic it was never meant to see.
#Two things you can actually pin, and why one of them wins
You can pin the full leaf certificate itself, or you can pin the SHA-256 hash of just its Subject Public Key Info (SPKI) — the public key, without the surrounding certificate metadata. Pinning the leaf certificate is brittle: every time the cert is renewed (yearly, or far more often with short-lived certs), the pin goes stale and the app breaks unless it ships an update in lockstep with every server-side cert rotation, which is an operational dependency nobody wants. Pinning the SPKI hash is the practice that actually survives real operations, because as long as the server's operator renews the certificate while reusing the same key pair, the pin keeps matching straight through the renewal — the hash only changes when the key itself changes, which is a much rarer, more deliberate event.
#Where you actually hook it in
On Android, the declarative path is a Network Security Config XML file referenced from the manifest — no code, and it survives an HTTP client library swap since it's enforced by the platform's own TLS stack rather than inside OkHttp:
<network-security-config><domain-config><domain includeSubdomains="true">api.acme.com</domain><pin-set expiration="2027-06-01"><pin digest="SHA-256">k3XnEC1nEZXsanA1FBcx9x9wgD/DZKAxDS0S07uMDeo=</pin><pin digest="SHA-256">MHJYVThihUrJcxW6wcqyOISTXIsInsdactHnV8gxwWQ=</pin></pin-set></domain-config></network-security-config>
The equivalent in code, if you're already configuring OkHttp directly:
val certificatePinner = CertificatePinner.Builder().add("api.acme.com", "sha256/k3XnEC1nEZXsanA1FBcx9x9wgD/DZKAxDS0S07uMDeo=").add("api.acme.com", "sha256/MHJYVThihUrJcxW6wcqyOISTXIsInsdactHnV8gxwWQ=").build()val client = OkHttpClient.Builder().certificatePinner(certificatePinner).build()
On iOS, without a third-party library, it's done inside URLSessionDelegate, evaluating the server's certificate against your embedded hashes during the TLS challenge:
func urlSession(_ session: URLSession,didReceive challenge: URLAuthenticationChallenge,completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {guard let trust = challenge.protectionSpace.serverTrust,let certificate = SecTrustGetCertificateAtIndex(trust, 0) else {completionHandler(.cancelAuthenticationChallenge, nil)return}let serverPublicKeyHash = spkiSHA256(of: certificate)let pinnedHashes: Set<String> = ["k3XnEC1nEZXsanA1FBcx9x9wgD/DZKAxDS0S07uMDeo=","MHJYVThihUrJcxW6wcqyOISTXIsInsdactHnV8gxwWQ=",]if pinnedHashes.contains(serverPublicKeyHash) {completionHandler(.useCredential, URLCredential(trust: trust))} else {completionHandler(.cancelAuthenticationChallenge, nil)}}
#The failure mode that actually takes down production
Both of the examples above ship two pins, and that's not incidental. If you pin exactly one key and the server operator ever rotates to a new key — a planned security upgrade, a certificate authority change, an infrastructure migration — every installed copy of your app loses the ability to reach your API at once, with no way to recover except shipping an app update, which then has to clear app store review while your entire user base sees network errors on every screen. The standard mitigation is pinning both the current key and a backup key generated in advance but not yet deployed on the server, so a future rotation onto that backup key is silently accepted by every app already in the wild — the second pin is insurance you buy before you need it, because there's no way to add it after the fact to a version already installed on a hundred thousand devices.
#Getting the boundary right in day-to-day development
Pinning has to be a release-build-only concern in practice, because it's directly incompatible with the MITM debugging proxies (Charles, Proxyman) most mobile teams rely on to inspect their own traffic during development — the standard pattern is a build-config flag that disables pin validation in debug builds while enforcing it unconditionally in release, so nobody accidentally ships a build where pinning silently does nothing. It's also worth pinning at the right layer of your own infrastructure: if requests terminate at a CDN edge, different points of presence can present different certificates for the same hostname, so pin whatever the CDN's edge actually presents in every region, not just the origin certificate you see from your own machine — otherwise "works in staging, breaks for a slice of users in production" becomes a very confusing bug report.