Chapter 13 — SSL pinning
How TLS trust normally works (and where it can fail)
- A malicious or compromised CA could issue a valid cert for your domain to an attacker. It has
- A corporate/proxy MITM. Many workplaces install a custom root CA on managed devices so a proxy can
- A user-installed root. Malware or a tricked user can add a root CA, after which an attacker can
flowchart LR
App["Chirp app"] -->|"thinks it's talking to Chirp"| MITM["😈 MITM
(valid-looking cert)"] MITM -->|"relays, reading everything"| Real[(Real Chirp server)] App -.->|"pinning: 'that's not MY server's key' → refuse"| Block((✋))
(valid-looking cert)"] MITM -->|"relays, reading everything"| Real[(Real Chirp server)] App -.->|"pinning: 'that's not MY server's key' → refuse"| Block((✋))
What pinning adds
- Certificate pinning — pin the whole certificate. Simple, but the certificate expires and rotates
- Public-key pinning — pin the certificate's public key (specifically the SHA-256 of its
The recommended way: declarative pinning in Info.plist (iOS 14+)
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>chirp-api.example.com</key>
<dict>
<key>NSIncludesSubdomains</key><true/>
<key>NSPinnedCAIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>YOUR_PRIMARY_PUBLIC_KEY_HASH_HERE=</string>
</dict>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>YOUR_BACKUP_PUBLIC_KEY_HASH_HERE=</string> <!-- backup pin, below -->
</dict>
</array>
</dict>
</dict>
</dict>
The manual way: a URLSession delegate (for understanding & control)
// Chirp/Networking/PinningDelegate.swift
import Foundation
import CryptoKit
final class PinningDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
private let pinnedKeyHashes: Set<String> // base64 SHA-256 of SPKI, primary + backup
init(pinnedKeyHashes: Set<String>) { self.pinnedKeyHashes = pinnedKeyHashes }
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
// Only handle server-trust challenges; pass everything else to default handling.
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust else {
return completionHandler(.performDefaultHandling, nil)
}
// 1. First, let the OS do the NORMAL validation (chain, hostname, expiry).
var error: CFError?
guard SecTrustEvaluateWithError(trust, &error) else {
return completionHandler(.cancelAuthenticationChallenge, nil) // not even CA-valid → reject
}
// 2. THEN our extra check: does the leaf's public key match a pin?
guard let leaf = leafCertificate(trust),
let keyHash = publicKeyHash(of: leaf),
pinnedKeyHashes.contains(keyHash) else {
return completionHandler(.cancelAuthenticationChallenge, nil) // valid cert, WRONG key → reject
}
// 3. Both checks pass → trust this connection.
completionHandler(.useCredential, URLCredential(trust: trust))
}
// Extract the leaf certificate and hash its public key (SPKI).
private func leafCertificate(_ trust: SecTrust) -> SecCertificate? {
(SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first
}
private func publicKeyHash(of certificate: SecCertificate) -> String? {
guard let key = SecCertificateCopyKey(certificate),
let keyData = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return nil }
// NOTE: a fully-correct SPKI hash prepends the ASN.1 header for the key type.
// Getting this exactly right is fiddly — see the caveat below.
let hash = SHA256.hash(data: rsa2048ASN1Header + keyData)
return Data(hash).base64EncodedString()
}
}
Getting the SPKI hash byte-exact is error-prone. The "hash the public key" step must prepend the correct ASN.1 header (which differs for RSA vs EC keys) so your computed hash matches what tools like
openssland the Info.plist mechanism produce. Getting this subtly wrong means the app rejects your own server. For production hand-rolled pinning, use a vetted library (TrustKit is the standard) or — better — the Info.plist approach above, which does it for you. Implement it by hand once to understand it; ship the declarative version.
The danger nobody warns you about: pinning can brick your app
- Always ship a backup pin. Pin the key you use now and a second key you'll rotate to next
- Pin the intermediate CA, not just the leaf. Pinning an intermediate (which rotates far less often)
- Have a plan for rotation. Ship the new backup pin in an app update before you rotate the
- Consider a remote kill switch so you can disable pinning in an emergency (carefully — a
When to pin — and when not to
- Pin when the stakes justify the operational risk: banking, health, messaging with strong privacy
- Don't pin reflexively for a typical consumer app. For most apps, App Transport Security (on by
What we built
- Explained the default TLS trust model and how it fails under a MITM (rogue/compromised CA,
- Added SSL pinning — verifying the server is specifically yours — and chose public-key pinning
- Implemented it two ways: the recommended declarative
NSPinnedDomains(iOS 14+) and a manual - Confronted the operational danger — pinning can brick your app on cert rotation — and the mandatory
Mental model to take away
- TLS proves the connection is encrypted and CA-trusted; pinning additionally proves it's your
- Prefer public-key pinning (survives renewal) and the declarative Info.plist mechanism (or
- A pin is a kill switch: always ship a backup pin and a rotation plan, consider pinning the