Mutual TLS (mTLS) for iOS 26 with Swift Concurrency

Hi everyone :waving_hand:

I recently set up mutual TLS (client certificate authentication) in an iOS 26 app and wanted to share what I learned. It covers the steps, the code, and a few things that tripped me up. I hope it helps anyone doing the same thing.

What is mTLS?

In normal TLS, only the server proves who it is. In mutual TLS, the client also sends a certificate, so both sides authenticate each other at the transport layer before any HTTP data is exchanged.

Where mTLS is useful

  • Banking and fintech apps that need strong device or user authentication on top of tokens
  • Enterprise and MDM-managed apps that connect to internal APIs behind mTLS gateways
  • Zero-trust setups where every client must prove its identity
  • IoT companion apps that talk to devices or brokers that require client certificates
  • Healthcare and government apps with strict compliance requirements
  • Backends behind Envoy, NGINX, Istio or AWS API Gateway that already enforce mTLS

Pros

  • :white_check_mark: Strong authentication: a stolen bearer token alone isn’t enough, because the attacker also needs the private key.
  • :white_check_mark: Blocks unknown clients early: connections without a valid certificate are rejected during the TLS handshake.
  • :white_check_mark: Protects against MITM when combined with server certificate validation or pinning.
  • :white_check_mark: No extra dependencies: everything is in Foundation and Security.
  • :white_check_mark: Works with existing URLSession code through a delegate.

Step 1: Load the client identity from a PKCS#12 file

swift

import Foundation
import Security

enum MTLSError: Error {
    case importFailed(OSStatus)
    case identityNotFound
}

func loadIdentity(p12Data: Data, password: String) throws -> SecIdentity {
    let options = [kSecImportExportPassphrase as String: password] as CFDictionary
    var items: CFArray?

    let status = SecPKCS12Import(p12Data as CFData, options, &items)
    guard status == errSecSuccess else { throw MTLSError.importFailed(status) }

    guard let array = items as? [[String: Any]],
          let identityRef = array.first?[kSecImportItemIdentity as String] else {
        throw MTLSError.identityNotFound
    }
    return identityRef as! SecIdentity
}

Step 2: Handle authentication challenges with the async delegate API

swift

final class MTLSDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
    // Immutable after init, so it is safe to share across threads.
    private let identity: SecIdentity

    init(identity: SecIdentity) {
        self.identity = identity
    }

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge
    ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        switch challenge.protectionSpace.authenticationMethod {

        case NSURLAuthenticationMethodClientCertificate:
            let credential = URLCredential(
                identity: identity,
                certificates: nil,          // add intermediate certs here if the server needs them
                persistence: .forSession
            )
            return (.useCredential, credential)

        case NSURLAuthenticationMethodServerTrust:
            guard let trust = challenge.protectionSpace.serverTrust,
                  SecTrustEvaluateWithError(trust, nil) else {
                return (.cancelAuthenticationChallenge, nil)
            }
            return (.useCredential, URLCredential(trust: trust))

        default:
            return (.performDefaultHandling, nil)
        }
    }
}

Step 3: Make requests

swift

let identity = try loadIdentity(p12Data: p12Data, password: password)
let delegate = MTLSDelegate(identity: identity)
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)

let (data, response) = try await session.data(from: URL(string: "https://api.example.com/secure")!)

Optional: Public key pinning

swift

import CryptoKit

func publicKeyHash(of trust: SecTrust) -> String? {
    guard let key = SecTrustCopyKey(trust),
          let keyData = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return nil }
    return Data(SHA256.hash(data: keyData)).base64EncodedString()
}
// Compare this against your known hash in the ServerTrust branch

Lessons learned

  • Don’t hardcode the .p12 password or ship production private keys in the app bundle. Provision certificates at runtime (for example through MDM or an enrollment API) and store them in the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly.
  • Send intermediate certificates if needed. If the server rejects the client certificate, pass the intermediate certs in URLCredential(identity:certificates:persistence:).
  • Handle server trust too. Once you implement the delegate, you’re responsible for the server trust challenge, so don’t just return .useCredential without evaluating it.
  • Check how the .p12 was encrypted. If SecPKCS12Import fails, the file may use encryption settings the OS doesn’t accept. Re-exporting with different OpenSSL options (for example -legacy) often fixes it.
  • Plan for certificate expiry. Build rotation in from the start.

I’d love to hear how others handle mTLS in production, especially certificate provisioning, rotation, and Secure Enclave–backed keys. Thanks! :folded_hands:

1 Like

Isn’t this assumptions a bit misleading?, you had to explicitly handle ServerTrust “case” because you have chosen so when switching over it. Returning performDefaultHandling should have the same effect as it validates certificate validity itself - not if it was spoofed. If we are performing certificate pinning, then yes - we are responsible for the trust challenge, and calling SecTrustEvaluateWithError would be a good idea :grin:

Thanks @patryk, good catch. "Responsible" was the wrong word.

If the server-trust challenge reaches the default: branch and returns .performDefaultHandling, the system evaluates the chain the same way it would without a delegate, including ATS. In my sample, the explicit NSURLAuthenticationMethodServerTrust case calls SecTrustEvaluateWithError and then returns .useCredential. That does the same thing as the default, so it's redundant as written.

You only need to handle the server-trust challenge yourself when you change the default behaviour:

  • Pinning: check the public key hash after a successful evaluation, as in the optional pinning snippet.
  • Private or internal CA: call SecTrustSetAnchorCertificates (plus SecTrustSetAnchorCertificatesOnly) before evaluating.
  • Custom policies: for example, a different hostname for SecPolicyCreateSSL.

Once you take over, though, you have to evaluate the trust properly. Don't just return .useCredential(URLCredential(trust:)). That's the mistake I was trying to warn about, but I worded it too broadly.

So for plain mTLS without pinning, the delegate can be just:

switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodClientCertificate:
    return (.useCredential, URLCredential(identity: identity, certificates: nil, persistence: .forSession))
default:
    return (.performDefaultHandling, nil)
}

Also worth knowing: if you only need static pinning, NSPinnedDomains in Info.plist (iOS 14+) handles it declaratively, so you don't need the server-trust branch at all.

I'll update the lesson in the original post. Thanks again!

Correction to my "Handle server trust too" lesson: only handle the server-trust challenge yourself if you customize it (pinning, private CA); otherwise return .performDefaultHandling. See the reply above.