# Mutual TLS (mTLS) for iOS 26 with Swift Concurrency

**URL:** <https://forums.swift.org/t/mutual-tls-mtls-for-ios-26-with-swift-concurrency/90484>\
**Category:** Compiler\
**Tags:** concurrency\
**Created:** [November 5, 2026, 3:46am UTC](https://forums.swift.org/t/mutual-tls-mtls-for-ios-26-with-swift-concurrency/90484 "2026-11-05T03:46:30Z")\
**Posts on this page:** 4\
**Page:** 1

<div class="post-metadata">

**Author:** ![dheerendra](https://sea2.discourse-cdn.com/swift/user_avatar/forums.swift.org/dheerendra/32/47241_2.png) [@dheerendra](https://forums.swift.org/u/dheerendra)\
**Post date:** [November 5, 2026, 3:46am UTC](https://forums.swift.org/t/mutual-tls-mtls-for-ios-26-with-swift-concurrency/90484/1 "2026-11-05T03:46:30Z")

</div>

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 `URLSession` code** through a delegate.

* * *

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

swift

```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

```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

```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

```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! 🙏

---

<div class="post-metadata">

**Author:** ![patryk](https://sea2.discourse-cdn.com/swift/user_avatar/forums.swift.org/patryk/32/39534_2.png) [@patryk](https://forums.swift.org/u/patryk)\
**Post date:** [November 5, 2026, 3:12pm UTC](https://forums.swift.org/t/mutual-tls-mtls-for-ios-26-with-swift-concurrency/90484/2 "2026-11-05T15:12:21Z")

</div>

> [@dheerendra](#):
>
> - **Handle server trust too.** Once you implement the delegate, you’re responsible for the server trust challenge…

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 😁

---

<div class="post-metadata">

**Author:** ![dheerendra](https://sea2.discourse-cdn.com/swift/user_avatar/forums.swift.org/dheerendra/32/47241_2.png) [@dheerendra](https://forums.swift.org/u/dheerendra)\
**Post date:** [November 6, 2026, 4:00am UTC](https://forums.swift.org/t/mutual-tls-mtls-for-ios-26-with-swift-concurrency/90484/3 "2026-11-06T04:00:23Z")

</div>

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:

```swift
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!

---

<div class="post-metadata">

**Author:** ![dheerendra](https://sea2.discourse-cdn.com/swift/user_avatar/forums.swift.org/dheerendra/32/47241_2.png) [@dheerendra](https://forums.swift.org/u/dheerendra)\
**Post date:** [November 6, 2026, 4:03am UTC](https://forums.swift.org/t/mutual-tls-mtls-for-ios-26-with-swift-concurrency/90484/4 "2026-11-06T04:03:47Z")

</div>

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.
