Erez Hod

Secure your URLSession network requests using Certificate Pinning

In an age where data breaches and cyber threats are on the rise, securing your app’s communication isn’t just a nice-to-have, it’s a must. This article will guide you through implementing a methodology called “Certificate Pinning” using Swift’s URLSession, a simple yet powerful way to safeguard your iOS and macOS apps from man-in-the-middle attacks. By the end, you’ll not only understand why this technique is critical but also how to integrate it seamlessly into your network requests.

What is Certificate Pinning?

Certificate Pinning is like adding an extra layer of security to how your app or website talks to a server. Imagine your app and the server have a secret handshake to ensure they’re talking to the right person. Normally, they use an SSL certificate for this handshake.

Certificate Pinning takes it even a step further, it “pins” a specific certificate or its public key to the app. This means your app will only trust that exact certificate (or a closely related one), ignoring all others, even if they seem valid.

Why is it important?

Certificate Pinning helps protect against man-in-the-middle attacks. Let’s first understand the problem and then explore the solution.

The Problem

Without pinning, your app accepts any certificate signed by a trusted Certificate Authority (CA). If an attacker tricks a CA into issuing a fake certificate (or uses a compromised CA), they could intercept your app’s communication with the server. This would let them steal sensitive data or tamper with the information being sent.

✉️ Imagine you’re sending a letter, and any “trusted” postman can deliver it. If a fake postman gets a trusted badge, your letter is in the wrong hands.

The Solution

By pinning a certificate, your app ignores fake certificates, even if they’re signed by a trusted CA. It ensures your app only talks to your server and no one else.

On our apps, attackers could read or alter private user-server communication.

Pinning ensures that your app is always securely talking to the right server, keeping users safe from threats like:

  1. Fake Wi-Fi hotspots (e.g., in cafes or airports) pretending to be legitimate.
  2. Attackers intercepting communications on compromised networks.

✅ So, with Certificate Pinning, you only trust specific postmen (e.g: the ones with a unique badge). Even if someone else has a different (valid or invalid) badge, you won’t hand over your letter.

Obtaining and Preparing your SSL Certificates

Step 1: Obtain Your Server’s SSL Certificate

AWS supports SSL certificates issued by popular providers, and you can also use AWS Certificate Manager (ACM) to generate and manage certificates. If you’re hosting on EC2, you’ll likely use a certificate signed by a trusted CA like Let’s Encrypt, DigiCert, or AWS’s own ACM.

You’ll need to download the full certificate chain (a PEM file containing the server certificate, intermediate certificate, and optionally the root certificate). This file can typically be exported via your SSL provider or AWS ACM.

Step 2: Extracting the Pinning Information

Once you have obtained your server’s SSL certificate, we can now begin to extract the required data.

For Certificate Pinning, Use a tool like OpenSSL to extract the certificate’s Base64 representation:

openssl x509 -in server-cert.pem -outform DER | base64

For Public Key Pinning, use OpenSSL to extract and hash the public key:

openssl x509 -pubkey -noout -in server-cert.pem | openssl rsa -pubin -outform DER | openssl dgst -sha256 -binary | base64

These hashed values are what you’ll use in your app for pinning.

✅ Now that we have the certificate(s) we would like to pin, copy it into your Xcode project, preferably as a .cer file, and make sure it is included in the App Bundle, so we can load it when necessary.

Creating an HTTPClient with a URLSession

Onto the more interesting part!

Let’s create an HTTPClient in Swift that we can use to send requests to our server:

public final class HTTPClient: NSObject {
    /// URLSession instance with a custom delegate for handling Certificate Pinning.
    private let session = URLSession(configuration: .default, delegate: nil, delegateQueue: nil)

    // MARK: - Lifecycle

    public override init() {
        super.init()
        loadSupportedCertificatesIfNeeded()
    }

    // MARK: - Request method

    /// Makes an HTTP request and decodes the response into the specified type.
    public func request<Response: Decodable>(/* ... */) async throws -> Response { /* ... */ }
}

For this client, we will need to store an instance of a custom URLSession just so we can tap into its delegate capabilities in order to implement the urlSession(_:didReceive:completionHandler:) delegate method to handle server authentication challenges for Certificate Pinning, since the classic Singleton instance of URLSession.shared does not allow delegate overriding.

From Apple’s documentation:

urlSession(_:didReceive:completionHandler:) Requests credentials from the delegate in response to a session-level authentication request from the remote server.

And now, we can implement this said delegate method like so:

// MARK: - URLSessionDelegate & Certificate Pinning

private extension HTTPClient: URLSessionDelegate {
    /// Handles server authentication challenges for Certificate Pinning.
    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        // We will write our pinning business logic here.
    }
}

Loading Our Certificate

It is time for the… money time! (apologies for the dad pun).

Let’s setup a new class member array of type [Data] that will contain all of our supported certificates and also, make it lazy in case we’d want to disable Certificate Pinning in other cases.

Additionally, we’ll create a loadSupportedCertificatesIfNeeded() method that will be called on init.

Although most apps will require only one certificate, I opted to make this an array just in case you’d want to load more than one certificate for more than one server:

public final class HTTPClient: NSObject {
    /// URLSession instance with a custom delegate for handling Certificate Pinning.
    private let session = URLSession(configuration: .default, delegate: nil, delegateQueue: nil)

    /// Contains all of our certificate data for pinning, if applicable.
    private lazy var certificates: [Data] = []

    /// Indicates whether to enable Certificate Pinning or not.
    /// In debug builds, pinning is disabled to simplify development.
    private var isCertificatePinningEnabled: Bool {
        #if DEBUG
        false
        #else
        true
        #endif
    }

    // MARK: - Lifecycle

    public override init() {
        super.init()
        loadSupportedCertificatesIfNeeded()
    }

    // MARK: - Request method

    /// Makes an HTTP request and decodes the response into the specified type.
    public func request<Response: Decodable>(/* ... */) async throws -> Response { /* ... */ }
}

// MARK: - URLSessionDelegate & Certificate Pinning

private extension HTTPClient: URLSessionDelegate {
    /// Loads all supported certificates for Certificate Pinning purposes.
    func loadSupportedCertificatesIfNeeded() {
        guard isCertificatePinningEnabled else { return }

        let bundle = Bundle(for: type(of: self))
        let certificateNames = ["server-certificate"] // Add your certificate filenames here.

        do {
            certificates = try certificateNames.compactMap { name in
                guard let url = bundle.url(forResource: name, withExtension: "cer") else {
                    print("Certificate \(name) not found in bundle.")
                    return nil
                }
                return try Data(contentsOf: url)
            }
        } catch {
            print("Error loading certificates: \(error.localizedDescription)")
        }
    }

    /// Handles server authentication challenges for Certificate Pinning.
    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        // We will write our pinning business logic here.
    }
}

Implementing the Pinning Business Logic

Finally, we can implement the urlSession(_:didReceive:completionHandler:) delegate method.

Note that this flow is quite confusing, so I will explain it line by line on the code example below using code comments:

/// Handles server authentication challenges for Certificate Pinning.
func urlSession(
    _ session: URLSession,
    didReceive challenge: URLAuthenticationChallenge,
    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
    // Check if Certificate Pinning is enabled in your app's settings.
    guard isCertificatePinningEnabled else {
        // If Certificate Pinning is NOT enabled, let the system handle the challenge.
        // `.performDefaultHandling` means we let URLSession use its default security policy.
        completionHandler(.performDefaultHandling, nil)
        return
    }

    // Ensure this challenge is a server trust challenge.
    // Server trust challenges are specific to validating the server's SSL certificate.
    guard
        let trust = challenge.protectionSpace.serverTrust,
        SecTrustGetCertificateCount(trust) > 0,
        let serverCertificates = SecTrustCopyCertificateChain(trust) as? [SecCertificate]
    else {
        // If the challenge is not a server trust challenge or there are no certificates in the chain,
        // we cancel the authentication challenge.
        completionHandler(.cancelAuthenticationChallenge, nil)
        return
    }

    // Extract and convert the server's certificates into `Data` objects
    // for comparison with pinned certificates.
    let serverCertificatesData = serverCertificates.map { SecCertificateCopyData($0) as Data }

    // Check if any of the server's certificates match the pinned certificates.
    if serverCertificatesData.contains(where: { certificates.contains($0) }) {
        // A match was found! Use `.useCredential` to trust the server and proceed with the request.
        completionHandler(.useCredential, URLCredential(trust: trust))
    } else {
        // No match was found. Cancel the authentication challenge to reject the server.
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

The Final HTTPClient Code

Phew… our hard work is over - here’s the final HTTPClient with everything packaged into one file:

public final class HTTPClient: NSObject {
    /// URLSession instance with a custom delegate for handling Certificate Pinning.
    private let session = URLSession(configuration: .default, delegate: nil, delegateQueue: nil)

    /// Contains all of our certificate data for pinning, if applicable.
    private lazy var certificates: [Data] = []

    /// Indicates whether to enable Certificate Pinning or not.
    /// In debug builds, pinning is disabled to simplify development.
    private var isCertificatePinningEnabled: Bool {
        #if DEBUG
        false
        #else
        true
        #endif
    }

    // MARK: - Lifecycle

    public override init() {
        super.init()
        loadSupportedCertificatesIfNeeded()
    }

    // MARK: - Request method

    /// Makes an HTTP request and decodes the response into the specified type.
    public func request<Response: Decodable>(/* ... */) async throws -> Response { /* ... */ }
}

// MARK: - URLSessionDelegate & Certificate Pinning

private extension HTTPClient: URLSessionDelegate {
    /// Loads all supported certificates for Certificate Pinning purposes.
    func loadSupportedCertificatesIfNeeded() {
        guard isCertificatePinningEnabled else { return }

        let bundle = Bundle(for: type(of: self))
        let certificateNames = ["server-certificate"] // Add your certificate filenames here.

        do {
            certificates = try certificateNames.compactMap { name in
                guard let url = bundle.url(forResource: name, withExtension: "cer") else {
                    print("Certificate \(name) not found in bundle.")
                    return nil
                }
                return try Data(contentsOf: url)
            }
        } catch {
            print("Error loading certificates: \(error.localizedDescription)")
        }
    }

    /// Handles server authentication challenges for Certificate Pinning.
    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        // Check if Certificate Pinning is enabled in your app's settings.
        guard isCertificatePinningEnabled else {
            // If Certificate Pinning is NOT enabled, let the system handle the challenge.
            // `.performDefaultHandling` means we let URLSession use its default security policy.
            completionHandler(.performDefaultHandling, nil)
            return
        }

        // Ensure this challenge is a server trust challenge.
        // Server trust challenges are specific to validating the server's SSL certificate.
        guard
            let trust = challenge.protectionSpace.serverTrust,
            SecTrustGetCertificateCount(trust) > 0,
            let serverCertificates = SecTrustCopyCertificateChain(trust) as? [SecCertificate]
        else {
            // If the challenge is not a server trust challenge or there are no certificates in the chain,
            // we cancel the authentication challenge.
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // Extract and convert the server's certificates into `Data` objects
        // for comparison with pinned certificates.
        let serverCertificatesData = serverCertificates.map { SecCertificateCopyData($0) as Data }

        // Check if any of the server's certificates match the pinned certificates.
        if serverCertificatesData.contains(where: { certificates.contains($0) }) {
            // A match was found! Use `.useCredential` to trust the server and proceed with the request.
            completionHandler(.useCredential, URLCredential(trust: trust))
        } else {
            // No match was found. Cancel the authentication challenge to reject the server.
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
}

Wrapping Up

Implementing Certificate Pinning in your iOS or macOS app isn’t just about ticking a security checkbox—it’s about taking a proactive step to safeguard user data and build trust. In this article, we’ve explored the nuances of Certificate Pinning, from understanding its importance to integrating it into a living and breathing HTTPClient. By ensuring your app communicates only with trusted servers, you’re shielding users from common threats like man-in-the-middle attacks.

While the implementation might seem complex at first, the peace of mind it brings—knowing your app is protected against interception—is well worth the effort. Keep in mind that security is an ongoing journey, and techniques like Certificate Pinning are powerful tools in your arsenal.

👨🏻‍💻 Stay curious, stay secure, and happy coding!

Get Weekly Insights

Receive the latest articles, tips, and updates on development for Apple platforms right in your inbox.