Lab 9.2 — Certificate Pinning
Goal: Add TrustKit-style SPKI pinning to a sample API client, then verify with mitmproxy that the pinning blocks man-in-the-middle interception.
Time: ~2 hours
Prerequisites:
- Phase 9 chapter 9.3 (Network Security & TLS Pinning)
mitmproxyinstalled (brew install mitmproxy)openssl(preinstalled on macOS)- An iOS device or simulator on the same Wi-Fi as your Mac
Setup
- Clone the starter:
git clone https://github.com/bl9/swift-engineer-labs.git
cd swift-engineer-labs/09-security/certificate-pinning/starter
open APIPinningLab.xcodeproj
- The starter app calls
https://httpbin.org/getvia a plainURLSession. No pinning, no delegate.
Tasks
Task 1 — Extract the SPKI hash for httpbin.org
openssl s_client -servername httpbin.org -connect httpbin.org:443 < /dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| openssl enc -base64
Note the result — that’s your primary pin. Also extract pins for at least one intermediate or root in the chain as backup, so cert renewal doesn’t break you:
openssl s_client -servername httpbin.org -showcerts -connect httpbin.org:443 < /dev/null 2>/dev/null \
| awk '/BEGIN CERT/,/END CERT/' \
| csplit -s -f cert- - '/BEGIN CERT/' '{*}'
# For each cert-NN, extract the SPKI hash
for f in cert-*; do
openssl x509 -in "$f" -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| openssl enc -base64
done
Task 2 — Implement the pinning delegate
import CryptoKit
final class PinnedDelegate: NSObject, URLSessionDelegate {
let pins: Set<String>
init(pins: Set<String>) { self.pins = pins }
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust
else {
completionHandler(.performDefaultHandling, nil)
return
}
var error: CFError?
guard SecTrustEvaluateWithError(trust, &error) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let count = SecTrustGetCertificateCount(trust)
for i in 0..<count {
guard let cert = SecTrustGetCertificateAtIndex(trust, i),
let publicKey = SecCertificateCopyKey(cert),
let keyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?
else { continue }
let hash = Data(SHA256.hash(data: keyData)).base64EncodedString()
if pins.contains(hash) {
completionHandler(.useCredential, URLCredential(trust: trust))
return
}
}
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
Task 3 — Wire the session
let session = URLSession(
configuration: .default,
delegate: PinnedDelegate(pins: ["PRIMARY...", "BACKUP..."]),
delegateQueue: nil
)
Run the app; the request should succeed. Network tab in Xcode should show 200 OK from httpbin.
Task 4 — Verify with mitmproxy
# Terminal 1
mitmproxy --listen-port 8080
On your device:
- Settings → Wi-Fi → tap (i) → Configure Proxy → Manual → Server: your Mac’s LAN IP, Port: 8080
- Visit
http://mitm.itin Safari, follow instructions to install the mitmproxy CA profile, then Settings → General → About → Certificate Trust Settings → enable for mitmproxy
Now re-run your app. Without pinning the request would appear in mitmproxy’s flow list. With pinning correctly implemented, the request should fail — URLError with cancelled or secureConnectionFailed. Verify both:
- Temporarily disable pinning → request appears in mitmproxy
- Re-enable pinning → request fails before reaching mitmproxy (you’ll see a TLS handshake failure in mitmproxy’s event log, not in the flow list)
Task 5 — Add pinning failure metrics
Add an analytics callback that reports pinning-failure events distinct from other network errors. Differentiate by checking the URLError code and the certificate-validation context. Verify your local logger captures pinning-specific failures during the mitmproxy test.
Task 6 — Remote kill-switch
Add a PinningConfig struct fetched from a remote endpoint (mock it with a hardcoded true for the lab). When enforce = false, the delegate should call .performDefaultHandling and let the system trust prevail. Verify the switch works by toggling the value, restarting, and confirming mitmproxy can now intercept.
Stretch goals
- TrustKit integration — replace the hand-rolled delegate with TrustKit’s pinning policy. Compare LOC and feature set.
- Multi-host pinning — pin different hosts to different pin sets. The hand-rolled delegate currently doesn’t dispatch by host; add that.
- Synthetic monitoring — write a unit test that uses URLProtocol stubbing to feed in a server trust object built from a fake cert, and verifies the delegate correctly rejects it.
- App Attest — additionally require an App Attest assertion on each request as a second wall.
Notes
- After the lab, remove the proxy settings from your device. Leaving mitmproxy trusted is itself an M5 violation.
- httpbin.org occasionally rotates certs. If the lab stops working in a few months, refresh your pin extraction.
- The hand-rolled delegate above is for learning; production apps should use TrustKit for the additional features (per-host config, reporting, backup pin support out of the box).
Next: Lab 9.3 — Security Audit