iOS NSURLSession mTLS: Client certificate not sent, error -1206

Hi everyone,

I'm trying to establish a connection to a server that requires mutual TLS (mTLS) using NSURLSession in an iOS app. The server is configured with a self-signed root CA (in the project, we are using ca.cer) and requires clients to present a valid certificate during the TLS handshake.

What I’ve done so far:

Server trust is working:

I manually trust the custom root CA using SecTrustSetAnchorCertificates and SecTrustEvaluateWithError.

I also configured the necessary NSAppTransportSecurity exception in Info.plist to allow the server certificate to pass ATS.

This is confirmed by logs showing: Server trust succeeded

The .p12 identity is correctly created: Contains the client certificate and private key.

Loaded using SecPKCS12Import with the correct password.

I implemented the delegate method:

func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
                completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

    if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
        // Server trust override code (working)
        ...
    }

    if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate {
        print("🔐 Client cert challenge triggered")
        if let identity = loadIdentity() {
            let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession)
            completionHandler(.useCredential, credential)
        } else {
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
        return
    }

    completionHandler(.performDefaultHandling, nil)
}

The session is correctly created using my custom delegate:

        let delegate = MTLSDelegate(identity: identity, certificates: certs)
        let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)

Despite everything above, the client certificate is never sent, and the request fails with:

Error Domain=NSURLErrorDomain Code=-1206 "The server requires a client certificate."

From logs, it's clear the delegate is being hit for NSURLAuthenticationMethodServerTrust, but not for NSURLAuthenticationMethodClientCertificate.

Answered by DTS Engineer in 851953022

Ah, right, this is one of those weird CFNetwork edge cases. You can actually see the root cause with your curl command:

% curl https://ss3.at.docu-tools.com --cert-type P12 --cert client_full.p12 --pass 1234 --cacert ca.pem -v 
…
* [HTTP/2] [1] OPENED stream for https://ss3.at.docu-tools.com/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: ss3.at.docu-tools.com]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.7.1]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: ss3.at.docu-tools.com
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
< HTTP/2 403 
…

So the TLS handshake works, after which curl issues the GET command, and that fails with a 403. In curl that’s considered an HTTP error rather than a transport error. However, CFNetwork, for its own obscure reasons, translates an HTTP error 403 after a client certificate authentication challenge into a NSURLErrorClientCertificateRequired transport error.

This has come up numerous times before. If you search forums for NSURLErrorClientCertificateRequired, you’ll see a bunch of different variants of it. IMO the best explanation is the one here.

Given how long this has been around, I very much doubt it’ll be fixed. Indeed, the very specific nature of this transformation is strong evidence that was a deliberate change, making it a feature not a bug )-:

As to what you should do about that, it depends on whether you actually need the 403 response or not. If you don’t, this shouldn’t be a problem in practice; just supply a client identity that the server accepts. If you do, things get trickier.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

From logs, it's clear the delegate is being hit for NSURLAuthenticationMethodServerTrust, but not for NSURLAuthenticationMethodClientCertificate.

I presume that you’re basing the above on the fact that your Client cert challenge triggered message wasn’t printed. I’d like to check that however. If you add this line to the very beginning of your challenge handler, what do you see?

print("did receive challenge, method: \(challenge.protectionSpace.authenticationMethod)")

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

@DTS Engineer ,

The print was actually working, I could see 🔐 Client cert challenge triggered in the logs.

I also added the print command you asked and this is what I got:

did receive challenge, method: NSURLAuthenticationMethodServerTrust, this one works

did receive challenge, method: NSURLAuthenticationMethodClientCertificate, this one fails.

I have created a github repository, where you can download the project and it also includes the certificates I'm using. Here's the link: https://github.com/renanstig/mtlspoc

One more thing that might be interesting is that when I run this command here on my terminal, I get connected without any issues, that's how I know that my p12 is actually working fine. Obviously, I had to convert my ca.cer into a ca.pem to be able to run it.

curl https://ss3.at.docu-tools.com \           
  --cert-type P12 \
  --cert client_full.p12 \
  --pass 1234 \
  --cacert ca.pem \
  -v
Accepted Answer

Ah, right, this is one of those weird CFNetwork edge cases. You can actually see the root cause with your curl command:

% curl https://ss3.at.docu-tools.com --cert-type P12 --cert client_full.p12 --pass 1234 --cacert ca.pem -v 
…
* [HTTP/2] [1] OPENED stream for https://ss3.at.docu-tools.com/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: ss3.at.docu-tools.com]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.7.1]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: ss3.at.docu-tools.com
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
< HTTP/2 403 
…

So the TLS handshake works, after which curl issues the GET command, and that fails with a 403. In curl that’s considered an HTTP error rather than a transport error. However, CFNetwork, for its own obscure reasons, translates an HTTP error 403 after a client certificate authentication challenge into a NSURLErrorClientCertificateRequired transport error.

This has come up numerous times before. If you search forums for NSURLErrorClientCertificateRequired, you’ll see a bunch of different variants of it. IMO the best explanation is the one here.

Given how long this has been around, I very much doubt it’ll be fixed. Indeed, the very specific nature of this transformation is strong evidence that was a deliberate change, making it a feature not a bug )-:

As to what you should do about that, it depends on whether you actually need the 403 response or not. If you don’t, this shouldn’t be a problem in practice; just supply a client identity that the server accepts. If you do, things get trickier.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

@DTS Engineer , thank you for your help, but I think there's still something wrong happening.

I asked the guys from the backend to prevent the response 403 and if you run the CURL on terminal, you will get now a Hello world now.

I'm still getting the same error when I debug my POC. Do you have any idea why is that happening?

I asked the guys from the backend to prevent the response 403

Cool!

I'm still getting the same error when I debug my POC. Do you have any idea why is that happening?

Hmmm, it’s now working for me. I’ve included my test code below. I’m using Xcode 16.4, targeting the iOS 18.5. When I run my app and tap my test button, it prints:

will run task
did receive challenge, method: NSURLAuthenticationMethodServerTrust
did receive challenge, method: NSURLAuthenticationMethodClientCertificate
did run task, status: 200, bytes: 12

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"


Here’s the test function:

@MainActor
func test() async {
    do {
        print("will run task")
        let caCert = Bundle.main.certificateNamed("ca")!
        let clientIdentity = Bundle.main.identityNamed("client_full", password: "1234")!
        let delegate = SessionDelegate(caCert: caCert, clientIdentity: clientIdentity)
        let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: .main)
        let url = URL(string: "https://ss3.at.docu-tools.com")!
        let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 60.0)
        let (data, response) = try await session.data(for: request)
        let httpResponse = response as! HTTPURLResponse
        print("did run task, status: \(httpResponse.statusCode), bytes: \(data.count)")
        #warning("we leak session here")
    } catch let error as NSError {
        print("did not run task, error: \(error.domain) / \(error.code)")
    }
}

Here’s my delegate:

@MainActor
final class SessionDelegate: NSObject, URLSessionDelegate {

    init(caCert: SecCertificate, clientIdentity: SecIdentity) {
        self.caCert = caCert
        self.clientIdentity = clientIdentity
    }

    let caCert: SecCertificate
    let clientIdentity: SecIdentity
    
    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        print("did receive challenge, method: \(challenge.protectionSpace.authenticationMethod)")
        switch challenge.protectionSpace.authenticationMethod {
        case NSURLAuthenticationMethodServerTrust:
            return (.useCredential, .init(trust: challenge.protectionSpace.serverTrust!))
        case NSURLAuthenticationMethodClientCertificate:
            return (.useCredential, .init(identity: clientIdentity, certificates: nil, persistence: .forSession))
        default:
            return (.performDefaultHandling, nil)
        }
    }
}

My Bundle.certificateNamed(_:) helper is from this thread. And Bundle.identityNamed(_:password:) is from this thread.

Oh, and the project has ATS disabled completely via NSAllowsArbitraryLoads. Once you get this working, you can explore a less drastic ATS exception (-:

@DTS Engineer ,

Thank you for your help. After I compared your code with mine, I realized that due to all the modifications I did in the code, I forgot to change the completion handler of the client challenge to .useCredential, that's why I was still seeing the issue.

I was able able to remove NSAllowsArbitraryLoads, as well and the app connection worked as expected.

iOS NSURLSession mTLS: Client certificate not sent, error -1206
 
 
Q