Hi everyone ![]()
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
Strong authentication: a stolen bearer token alone isn’t enough, because the attacker also needs the private key.
Blocks unknown clients early: connections without a valid certificate are rejected during the TLS handshake.
Protects against MITM when combined with server certificate validation or pinning.
No extra dependencies: everything is in Foundation and Security.
Works with existing URLSessioncode 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
.p12password 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 withkSecAttrAccessibleWhenUnlockedThisDeviceOnly. - 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
.useCredentialwithout evaluating it. - Check how the
.p12was encrypted. IfSecPKCS12Importfails, 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! ![]()