9.3 — Network Security & TLS Pinning

Opening scenario

You launch the app on a corporate Wi-Fi that’s running TLS interception (“for compliance”). Your API requests appear in mitmproxy in plaintext. The attacker — or the curious IT admin — can read every request, response, and auth token in flight. Your code did nothing wrong; TLS just trusted the wrong root. Pinning is the fix.

Context — what TLS gives you (and doesn’t)

Default URLSession requests use TLS 1.3 (or 1.2 with FS ciphers) and validate against the system trust store. That defends against passive eavesdropping on the open internet. It does not defend against:

  • A user-installed root certificate (Charles, mitmproxy, corporate MDM)
  • A compromised public CA issuing a fraudulent cert
  • An attacker on the local network with a malicious DNS + freshly-issued LE cert
  • State-level adversaries with CA influence

Certificate pinning locks your app to a specific certificate (leaf), certificate chain (intermediate or root), or public key, refusing any TLS connection whose chain doesn’t include the expected pin.

Pinning strategies

StrategyWhat you pinProsCons
Leaf cert pinningThe server’s specific TLS certStrongestBreaks on every cert renewal (~90 days for LE, 1 yr for paid)
Intermediate / CA pinningOne level upSurvives leaf renewalBreaks if you change CA
Public-key pinning (SPKI)Hash of the public key (in leaf or intermediate)Survives cert renewal as long as key reusedRequires planning the rotation in advance

Most teams pin the SPKI hash of the leaf or intermediate, with one or two backup pins for emergency rotation. The backup pin corresponds to a key already generated and stored offline, ready to swap in.

Implementing pinning with URLSessionDelegate

import CryptoKit
import Foundation

final class PinnedDelegate: NSObject, URLSessionDelegate {
    // Base64(SHA-256(SubjectPublicKeyInfo DER))
    private let pins: Set<String> = [
        "AAAA…primary",
        "BBBB…backup",
    ]

    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
        }

        // First let the system validate the chain normally.
        var error: CFError?
        guard SecTrustEvaluateWithError(trust, &error) else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // Then walk the chain looking for any cert whose SPKI matches a pin.
        let count = SecTrustGetCertificateCount(trust)
        for i in 0..<count {
            guard let cert = SecTrustGetCertificateAtIndex(trust, i),
                  let publicKey = SecCertificateCopyKey(cert),
                  let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?
            else { continue }

            let hash = SHA256.hash(data: publicKeyData)
            let pin = Data(hash).base64EncodedString()
            if pins.contains(pin) {
                completionHandler(.useCredential, URLCredential(trust: trust))
                return
            }
        }

        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

let session = URLSession(
    configuration: .default,
    delegate: PinnedDelegate(),
    delegateQueue: nil
)

Two subtleties:

  1. Always run SecTrustEvaluateWithError first. Pinning augments trust validation, not replaces it. Skipping the system check bypasses revocation, expiry, hostname validation.
  2. Pin the SPKI, not the full cert bytes. SPKI hashes survive certificate renewals when the same key is reused.

For real apps, use TrustKit (production-tested wrapper with reporting, fallback, multiple pins per host). Don’t ship the raw delegate above without extensive testing.

Computing pins for your server

# Get the leaf cert from your server
openssl s_client -servername api.acme.com -connect api.acme.com:443 < /dev/null \
  | openssl x509 -outform DER \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

For SPKI hash (recommended):

openssl s_client -servername api.acme.com -connect api.acme.com:443 < /dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

Pin the primary key AND a backup key you’ve pre-generated. Store the backup private key offline (HSM or sealed envelope); rotate when needed without a forced app update.

Rotation strategy — the part teams forget

Pinning will break the day a cert silently rotates. Avoid the outage:

  1. Pin the primary key + at least one backup key.
  2. Ship a remote config flag (pinning.enabled) so you can disable from server in catastrophe.
  3. Monitor failed pinning attempts (count + sample) via analytics, separated from generic network failures.
  4. Roll the cert to use the backup key, then mint a new backup key, then ship an app update with the new pin set. Never rotate to a key you haven’t pre-pinned.

TLS misconfigurations to look for

  • Allowed ATS exceptionsNSAllowsArbitraryLoads = true for any production domain is a red flag. Document every exception.
  • Self-signed dev certs leaking into release — a URLSession delegate that returns .useCredential unconditionally during dev must be #if DEBUG-gated, and ideally guarded by build configuration not branch.
  • Outdated TLS versions — ATS requires TLS 1.2 with FS; the NSExceptionMinimumTLSVersion key can downgrade. Audit every exception.
  • Plain http:// anywhere — should fail ATS unless explicitly excepted.

In the wild

The Signal iOS app pins to the Signal Foundation’s intermediate CA. The Reddit iOS app uses leaf pinning with TrustKit. WhatsApp pins SPKI hashes via the same library. The pattern across high-stakes consumer apps is consistent: SPKI hashes + two pins + remote kill-switch.

Common misconceptions

  1. “TLS is enough.” It defends against passive observers, not against user-trusted roots or compromised CAs. Pinning is the closer.
  2. “Pinning the leaf is best because it’s most specific.” Most specific = most fragile. The cert rotates and your app breaks. Pin SPKI of leaf OR an intermediate, with a backup.
  3. “Pinning prevents jailbroken-device interception.” Doesn’t help against an attacker who repackages your IPA with the pinning code removed. Combine with jailbreak detection and code integrity checks for that threat model.
  4. “Pinning solves all MITM.” Pinning fails open if URLSessionDelegate is bypassed (e.g., WKWebView requests, third-party SDKs that have their own networking). Audit every networking surface.
  5. “We can pin once and forget.” Pinning is an operational commitment. Plan the rotation, monitor failures, ship a remote kill-switch.

Seasoned engineer’s take

Pinning is a discipline, not a feature. The decision to pin commits the team to a quarterly rotation drill and a monitoring pipeline. Apps that pin without that operational muscle ship outages every 12 months when their cert silently rolls. Before adding pinning, decide who owns the rotation runbook and how you’ll be alerted when 90 % of users start failing. Without that owner, pinning is a footgun.

TIP: Add a synthetic pinning-failure metric to your dashboards (separate from general network errors) so a misconfigured rotation surfaces in minutes, not customer complaints.

WARNING: Never ship pinning without a server-side kill switch. The day you need to rotate to an unplanned cert, your only options without the switch are “force-update every user” or “leave them with a broken app.”

Interview corner

Junior: “What’s certificate pinning?” Restricting your app to trust only specific certificates or public keys instead of every cert chained to a system-trusted CA. Defends against malicious CAs and user-installed proxy roots.

Mid: “How do you avoid an outage when the pinned cert rotates?” Pin the SubjectPublicKeyInfo hash of the leaf or intermediate, not the leaf cert bytes — SPKI survives cert renewal as long as the same key pair is reused. Always pin a primary plus at least one backup whose private key is generated and stored offline. Ship a remote kill switch, and monitor pinning-specific failures.

Senior: “Walk me through deploying pinning for the first time on a 5M-user app.” First, instrument: ship a release that measures what the SPKI pins would be on every request, reporting back via analytics but never enforcing. Confirm the distribution looks like what you expect — one primary pin, no surprises from CDN edge variations. Second, ship enforcement gated by a remote flag, defaulted off; flip it to 1 % of users, then 10 %, then 50 %, monitoring the pinning-failure metric distinct from generic network errors. Third, write the rotation runbook before enabling fully: backup pin location, rotation steps, kill-switch URL, on-call rotation. Only at 100 % rollout with a runbook in place do I consider pinning shipped. The technology takes a day; the operational discipline takes a quarter.

Red-flag answer: “We just pin and forget.” Reveals lack of operational thinking; this is how teams ship 6-month outages.

Lab preview

Lab 9.2 walks you through adding TrustKit-based pinning to a sample API client, then attempting a mitmproxy intercept and verifying it’s blocked.


Next: 9.4 — Authentication, Biometrics & Secure Enclave