Certificate Pinning in iOS: The Right Way

TLS protects data in transit, but it has a trust problem. Your app trusts hundreds of Certificate Authorities by default — any of which can issue a valid certificate for your domain. A compromised CA, a rogue enterprise proxy, or a state-level adversary with a trusted root can MITM your traffic with a perfectly valid certificate chain. Your app wouldn't blink.

Certificate pinning fixes this by hardcoding which certificates or public keys your app trusts, rejecting everything else — even if the system trust store says it's fine.

What Exactly to Pin

You have three choices, each with different trade-offs:

1. Leaf Certificate Pinning — Pin the exact server certificate. Maximum security. But when the certificate rotates (typically every 90 days with Let's Encrypt, yearly otherwise), you must ship an app update or your users get locked out.

2. Intermediate CA Pinning — Pin the intermediate certificate that signed the leaf. More resilient to rotation since the intermediate lives longer (5–10 years). Still strong — an attacker needs to compromise that specific CA, not just any CA.

3. Public Key (SPKI) Pinning — Pin the Subject Public Key Info hash rather than the full certificate. The key survives certificate renewals as long as you reuse the same key pair. This is the recommended approach.

Extracting the SPKI Hash

To pin a public key, you need the SHA-256 hash of the Subject Public Key Info (SPKI) DER encoding. Extract it from your server:

# Extract the SPKI pin from a live server
openssl s_client -connect api.yourapp.com:443 -servername api.yourapp.com 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | base64

# Output: dGhpcyBpcyBhIHNhbXBsZSBwaW4gaGFzaA==

# Extract from a .cer / .pem file
openssl x509 -in certificate.pem -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | base64

Approach 1: URLSession Delegate (Foundation-Level)

The most control. You intercept the TLS handshake via URLSessionDelegate and validate the server's certificate chain yourself:

import Foundation
import CryptoKit

final class PinnedSessionDelegate: NSObject, URLSessionDelegate {

    // Base64-encoded SHA-256 hashes of the SPKI for each trusted key.
    // Include the current key AND a backup key (from your next CSR).
    private let pinnedHashes: Set<String> = [
        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",  // primary
        "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",  // backup
    ]

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust
        else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // Step 1: Standard TLS validation (expiry, hostname, chain)
        let policy = SecPolicyCreateSSL(true, challenge.protectionSpace.host as CFString)
        SecTrustSetPolicies(serverTrust, policy)

        var cfError: CFError?
        guard SecTrustEvaluateWithError(serverTrust, &cfError) else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // Step 2: Extract public keys from the certificate chain
        let chainLength = SecTrustGetCertificateCount(serverTrust)
        var foundMatch = false

        for index in 0..<chainLength {
            guard let certificate = SecTrustCopyCertificateChain(serverTrust)
                    .map({ $0 as! [SecCertificate] })?[safe: index],
                  let publicKey = SecCertificateCopyKey(certificate)
            else { continue }

            // Step 3: Get the SPKI DER representation
            guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as? Data
            else { continue }

            // Step 4: Hash and compare
            let hash = SHA256.hash(data: publicKeyData)
            let hashBase64 = Data(hash).base64EncodedString()

            if pinnedHashes.contains(hashBase64) {
                foundMatch = true
                break
            }
        }

        if foundMatch {
            completionHandler(.useCredential, URLCredential(trust: serverTrust))
        } else {
            // Pin mismatch: kill the connection
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
}

Usage:

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

// Every request through this session is now pinned
let (data, _) = try await session.data(from: apiURL)

The SPKI Header Problem

There's a subtlety that trips up many implementations. SecKeyCopyExternalRepresentation returns the raw key data, not the full SPKI structure. For RSA keys, this is the PKCS#1 encoding. For EC keys, it's the raw point. But SPKI pinning (as defined by RFC 7469) expects the DER-encoded SubjectPublicKeyInfo, which includes an algorithm identifier header.

You need to prepend the correct ASN.1 header before hashing:

enum SPKIHeader {
    // ASN.1 headers for SubjectPublicKeyInfo wrapping
    static let rsa2048: [UInt8] = [
        0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09,
        0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
        0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00
    ]

    static let rsa4096: [UInt8] = [
        0x30, 0x82, 0x02, 0x22, 0x30, 0x0d, 0x06, 0x09,
        0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
        0x01, 0x05, 0x00, 0x03, 0x82, 0x02, 0x0f, 0x00
    ]

    static let ecDSAp256: [UInt8] = [
        0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86,
        0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a,
        0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03,
        0x42, 0x00
    ]

    static let ecDSAp384: [UInt8] = [
        0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86,
        0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x05, 0x2b,
        0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00
    ]

    static func header(for key: SecKey) -> [UInt8]? {
        guard let attrs = SecKeyCopyAttributes(key) as? [CFString: Any],
              let keyType = attrs[kSecAttrKeyType] as? String,
              let keySize = attrs[kSecAttrKeySizeInBits] as? Int
        else { return nil }

        switch (keyType, keySize) {
        case (kSecAttrKeyTypeRSA as String, 2048): return rsa2048
        case (kSecAttrKeyTypeRSA as String, 4096): return rsa4096
        case (kSecAttrKeyTypeECSECPrimeRandom as String, 256): return ecDSAp256
        case (kSecAttrKeyTypeECSECPrimeRandom as String, 384): return ecDSAp384
        default: return nil
        }
    }
}

func spkiHash(for key: SecKey) -> String? {
    guard let header = SPKIHeader.header(for: key),
          let keyData = SecKeyCopyExternalRepresentation(key, nil) as? Data
    else { return nil }

    var spkiData = Data(header)
    spkiData.append(keyData)

    let hash = SHA256.hash(data: spkiData)
    return Data(hash).base64EncodedString()
}

Now replace the hash computation in the delegate with spkiHash(for:) for RFC-compliant SPKI pins that match what openssl outputs.

Approach 2: Production-Grade Pinning Manager

In a real app, you need more than just pin checking. You need pin expiry handling, multi-domain support, reporting, and a fallback strategy:

import Foundation
import CryptoKit
import os.log

struct PinConfig {
    let domain: String
    let pins: Set<String>          // Base64 SPKI SHA-256 hashes
    let includeSubdomains: Bool
    let expiresAt: Date?            // nil = never expires
    let reportURI: URL?             // endpoint to report failures
}

final class CertificatePinningManager: NSObject, URLSessionDelegate {

    private let configs: [PinConfig]
    private let logger = Logger(subsystem: "com.app.network", category: "pinning")

    init(configs: [PinConfig]) {
        self.configs = configs
        super.init()
    }

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        let host = challenge.protectionSpace.host

        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust,
              let config = findConfig(for: host)
        else {
            completionHandler(.performDefaultHandling, nil)
            return
        }

        // Check pin expiry — fall back to default TLS if expired
        if let expiry = config.expiresAt, Date() > expiry {
            logger.warning("Pins expired for \(host), falling back to default TLS")
            completionHandler(.performDefaultHandling, nil)
            return
        }

        // Standard chain validation first
        let policy = SecPolicyCreateSSL(true, host as CFString)
        SecTrustSetPolicies(serverTrust, policy)

        var error: CFError?
        guard SecTrustEvaluateWithError(serverTrust, &error) else {
            logger.error("TLS validation failed for \(host): \(error.debugDescription)")
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // Pin validation
        let serverHashes = extractHashes(from: serverTrust)
        let matched = !serverHashes.isDisjoint(with: config.pins)

        if matched {
            completionHandler(.useCredential, URLCredential(trust: serverTrust))
        } else {
            logger.critical("PIN MISMATCH for \(host). Server: \(serverHashes)")
            reportPinFailure(host: host, serverHashes: serverHashes, config: config)
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }

    private func findConfig(for host: String) -> PinConfig? {
        // Exact match first
        if let exact = configs.first(where: { $0.domain == host }) {
            return exact
        }
        // Subdomain match
        return configs.first { config in
            config.includeSubdomains && host.hasSuffix("." + config.domain)
        }
    }

    private func extractHashes(from trust: SecTrust) -> Set<String> {
        var hashes = Set<String>()
        guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate]
        else { return hashes }

        for cert in chain {
            guard let key = SecCertificateCopyKey(cert),
                  let hash = spkiHash(for: key)
            else { continue }
            hashes.insert(hash)
        }
        return hashes
    }

    private func reportPinFailure(host: String, serverHashes: Set<String>, config: PinConfig) {
        guard let reportURI = config.reportURI else { return }

        let report: [String: Any] = [
            "hostname": host,
            "known-pins": Array(config.pins),
            "served-hashes": Array(serverHashes),
            "timestamp": ISO8601DateFormatter().string(from: Date()),
        ]

        var request = URLRequest(url: reportURI)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: report)

        // Use a plain session (no pinning) for the report
        URLSession.shared.dataTask(with: request).resume()
    }
}

Usage:

let pinning = CertificatePinningManager(configs: [
    PinConfig(
        domain: "api.yourapp.com",
        pins: [
            "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",  // current
            "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",  // backup
        ],
        includeSubdomains: true,
        expiresAt: Calendar.current.date(byAdding: .year, value: 1, to: Date()),
        reportURI: URL(string: "https://report.yourapp.com/pin-failure")
    )
])

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

Approach 3: Alamofire's ServerTrustManager

If you're already using Alamofire, it ships with a battle-tested pinning API:

import Alamofire

// Pin with public keys (recommended)
let evaluators: [String: ServerTrustEvaluating] = [
    "api.yourapp.com": PublicKeysTrustEvaluator(
        keys: Bundle.main.af.publicKeys,  // reads .cer files from the bundle
        performDefaultValidation: true,
        validateHost: true
    ),
    "cdn.yourapp.com": PinnedCertificatesTrustEvaluator(
        certificates: Bundle.main.af.certificates,
        performDefaultValidation: true,
        validateHost: true
    )
]

let manager = ServerTrustManager(evaluators: evaluators)
let session = Session(serverTrustManager: manager)

session.request("https://api.yourapp.com/v1/data")
    .validate()
    .responseDecodable(of: Response.self) { response in
        // pinned and validated
    }

This works by bundling .cer files in your app target. Alamofire extracts the public keys from them for SPKI pinning. The advantage: you swap the .cer file before certificate rotation without changing code.

Handling Certificate Rotation Without Breaking Users

This is where most pinning implementations fail in production. The server rotates its certificate, the new key doesn't match the pin, and every user is locked out until they update the app. Here's how to avoid that:

1. Always pin at least two keys: The current key and a backup. Generate a backup CSR/key pair, store the private key securely, and pin its public key hash in the app before you ever use it on the server.

2. Pin the intermediate, not the leaf: If you pin your CA's intermediate certificate, leaf rotation is invisible. The intermediate changes far less frequently.

3. Implement pin expiry: Include an expiry date in your pin config. After that date, fall back to standard TLS validation. This ensures users on old app versions don't get permanently locked out.

4. Remote pin updates via signed config: The most resilient approach. Fetch updated pins from a known endpoint, verify the payload's signature (using a key embedded in the app), and update the pin set dynamically:

struct RemotePinConfig: Codable {
    let pins: [String: [String]]   // domain -> [hash]
    let version: Int
    let expiresAt: Date
    let signature: String           // Ed25519 signature of the payload
}

actor PinStore {
    private var remotePins: [String: Set<String>] = [:]
    private let embeddedVerifyKey: Curve25519.Signing.PublicKey

    func update(from config: RemotePinConfig, rawPayload: Data) throws {
        // Verify signature before trusting remote pins
        let sig = try Curve25519.Signing.PublicKey(
            rawRepresentation: Data(base64Encoded: config.signature)!
        )
        guard embeddedVerifyKey.isValidSignature(
            Data(base64Encoded: config.signature)!,
            for: rawPayload
        ) else {
            throw PinError.invalidSignature
        }

        guard config.expiresAt > Date() else {
            throw PinError.configExpired
        }

        remotePins = config.pins.mapValues { Set($0) }
    }

    func pins(for domain: String) -> Set<String>? {
        return remotePins[domain]
    }
}

Detecting Pinning in a Hostile Environment

Jailbroken devices and reverse engineers use tools like SSL Kill Switch, Frida, and objection to bypass pinning by hooking into SecTrustEvaluateWithError or swizzling the URLSession delegate. To make bypassing harder:

  • Avoid obvious method names: Don't name your class CertificatePinner. Frida scripts grep for these
  • Validate in multiple layers: Pin at the URLSession level AND validate the server's certificate hash inside your response parsing logic
  • Inline the check: Instead of a separate delegate method, perform validation inside the data task completion where it's harder to hook in isolation
  • Integrity checks: Use SecTrustCopyCertificateChain to verify the chain depth and issuer match your expectations
// Secondary validation inside the response handler
func validateResponse(_ response: URLResponse, trust: SecTrust) -> Bool {
    guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
          chain.count >= 2  // at minimum: leaf + intermediate
    else { return false }

    // Verify the intermediate issuer is who we expect
    let intermediate = chain[1]
    let issuerData = SecCertificateCopyNormalizedIssuerSequence(intermediate)
    let expectedIssuerHash = "your-precomputed-issuer-hash"

    guard let data = issuerData as? Data else { return false }
    let hash = SHA256.hash(data: data)
    return Data(hash).base64EncodedString() == expectedIssuerHash
}

Testing Your Pinning

Pinning that's never tested is pinning that doesn't work. Verify it properly:

# 1. Use mitmproxy to MITM your own traffic
# Install mitmproxy, set your device's proxy, install its CA cert
mitmproxy --listen-port 8080

# Your app should REFUSE to connect when mitmproxy intercepts.
# If data flows through, your pinning is broken.

# 2. Use Charles Proxy with SSL Proxying enabled
# Same idea: install Charles root cert on device,
# enable SSL Proxying for your domain.
# Pinned connections should fail. Non-pinned should work.
// Unit test: verify pin rejection with a bad certificate
func testPinningRejectsBadCertificate() async throws {
    let manager = CertificatePinningManager(configs: [
        PinConfig(
            domain: "api.yourapp.com",
            pins: ["definitely-not-a-real-hash"],
            includeSubdomains: false,
            expiresAt: nil,
            reportURI: nil
        )
    ])

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

    do {
        let url = URL(string: "https://api.yourapp.com/health")!
        _ = try await session.data(from: url)
        XCTFail("Should have rejected the connection")
    } catch {
        // Expected: connection rejected due to pin mismatch
        XCTAssertTrue(error is URLError)
    }
}

Common Mistakes

  • Pinning only the leaf certificate: It rotates. You're one renewal away from a production outage
  • No backup pin: If your primary key is compromised and you need to rotate, users on the old app version are stranded
  • Skipping standard TLS validation: Pinning is an addition to TLS validation, not a replacement. Always call SecTrustEvaluateWithError first
  • Forgetting the SPKI header: Raw key data without the ASN.1 wrapper produces a different hash than openssl. Your pins won't match
  • No expiry or fallback: Without a kill switch, a pin misconfiguration bricks the app for every user
  • Pinning in debug builds: You'll fight your own proxy tools. Use #if !DEBUG or a build flag to disable pinning in development

Key Takeaways

  • Pin the SPKI hash (public key), not the full certificate — it survives renewal
  • Always include a backup pin from a pre-generated key pair
  • Prepend the correct ASN.1 header before hashing for RFC 7469 compliance
  • Implement pin expiry to prevent permanent lockouts on old app versions
  • Run standard TLS validation first, then check pins on top
  • Consider remote pin updates signed with an embedded key for maximum flexibility
  • Test pinning with mitmproxy or Charles — if your proxy can read the traffic, pinning is not working