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.
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"