Skip to Content
Course content

197: Certificate Pinning in URLSession

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I remember a lead developer I worked with on a fintech app a few years back. We were confident in our security because every single endpoint was HTTPS. During a third-party penetration test, the auditor smiled, installed a custom root certificate on the test device, and suddenly, all our "encrypted" API traffic was scrolling across their screen in plain text via a proxy. We had forgotten that by default, iOS trusts any certificate signed by a Root Certificate Authority (CA) present in the system's trust store. If a user is tricked into installing a malicious profile, or if a CA is compromised, your HTTPS tunnel is essentially a screen door.

This is where certificate pinning comes in. Instead of telling the app, "Trust anyone the iPhone trusts," we tell it, "Only trust this specific certificate—and no one else." It effectively narrows the window of trust from hundreds of global authorities down to the exact one you control.

Intercepting the Authentication Challenge

To implement pinning in URLSession, you have to move away from the simple shared session. You need a session with a delegate because the decision to trust or reject a server happens during the TLS handshake, specifically within the urlSession(_:didReceive:completionHandler:) delegate method. When the server presents its certificate, iOS triggers this "challenge."

The goal is to extract the server's certificate from the challenge and compare its data against a local copy you've bundled inside your app's main bundle. If the bytes don't match exactly, you kill the connection immediately. I usually recommend using .der encoded certificates for this, as they are binary and easy to compare.

class SecurityDelegate: NSObject, URLSessionDelegate {
    func urlSession(_ session: URLSession, 
                    didReceive challenge: URLAuthenticationChallenge, 
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        
        guard let serverTrust = challenge.protectionSpace.serverTrust,
              let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // Extract the data from the server's certificate
        let serverCertificateData = SecCertificateCopyData(serverCertificate) as Data
        
        // Load your local "pinned" certificate from the app bundle
        if let localCertificatePath = Bundle.main.path(forResource: "api_cert", ofType: "der"),
           let localCertificateData = try? Data(contentsOf: URL(fileURLWithPath: localCertificatePath)) {
            
            if serverCertificateData == localCertificateData {
                // It's a match! We trust this specific certificate.
                completionHandler(.useCredential, URLCredential(trust: serverTrust))
                return
            }
        }

        // Something doesn't match; this could be a MitM attack
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

The Danger of the "Hard Brick"

Now, here is the part where most developers mess up: certificates expire. If you pin a leaf certificate and that certificate expires on the server, your app will stop working for every single user the moment the server rotates to a new one. You can't "push" a new certificate to users instantly; they have to update the app via the App Store. I've seen apps essentially "brick" themselves for three days while waiting for an emergency update to clear Apple's review process.

To avoid this, you have two real options. First, you can pin the Intermediate CA instead of the leaf certificate. This is slightly less secure but much more flexible, as you can rotate leaf certificates without changing the intermediate one. Second, and more commonly in high-security environments, you can perform Public Key Pinning. Instead of comparing the whole certificate, you extract the public key from the certificate and pin that. Since the public key typically stays the same even when a certificate is renewed, you eliminate the expiration headache.

If you go the public key route, you'll need to use SecCertificateCopyKey to get the key and then compare the hashes. It's a bit more boilerplate, but it saves you from the 3:00 AM panic call when a certificate expires on a Sunday.




📋 Practical Task

Exercise: Hardening the Payment Gateway with Certificate Pinning

You are tasked with securing a sensitive payment processing module. The API server uses a specific certificate that you have been provided as payment_gateway.der.

Implement a URLSessionDelegate class named PaymentSessionDelegate that performs the following:

  • Overrides the urlSession(_:didReceive:completionHandler:) method.
  • Extracts the server's certificate data from the serverTrust object.
  • Loads the payment_gateway.der file from the app bundle.
  • Compares the two data blobs. If they match, call the completion handler with .useCredential; otherwise, call it with .cancelAuthenticationChallenge.
  • Ensure the session is initialized using this delegate rather than the URLSession.shared instance.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.