# Certificate Trust, Pinning and Client Certificates

What is here: the Security types for certificates, trust, identities and policies;
pinning, whether by leaf, by issuing CA, by public-key digest or declaratively through
`NSPinnedDomains`; building your own trust policy; mutual TLS; the points where App
Transport Security and trust evaluation overlap; and running a pin set in production.
It applies from iOS 12 and macOS 10.14 through the current releases (checked
against the iOS 26.4 SDK).

What is not here: transport concerns other than checking TLS certificates, managing
certificates on your servers, and ATS as a configuration topic (the `ios-networking`
skill owns that).

## Contents

- [The four types](#the-four-types)
- [Evaluating trust](#evaluating-trust)
- [Custom policies and anchors](#custom-policies-and-anchors)
- [Four ways to pin](#four-ways-to-pin)
- [Certificates and identities](#certificates-and-identities)
- [Mistakes to reject in review](#mistakes-to-reject-in-review)
- [Backup pins and rotation](#backup-pins-and-rotation)
- [Where ATS meets trust](#where-ats-meets-trust)
- [API timeline](#api-timeline)
- [Threads and performance](#threads-and-performance)
- [CI guardrails](#ci-guardrails)
- [Related and sources](#related-and-sources)
- [Checklist](#checklist)

## The four types

| Type | Role | Calls you will use |
| --- | --- | --- |
| `SecCertificate` | One X.509 certificate, DER bytes | create from data, copy the data back, copy a subject summary, and from iOS 12 `SecCertificateCopyKey` |
| `SecTrust` | Certificates plus the policies they are judged by | `SecTrustCreateWithCertificates`; evaluation with `SecTrustEvaluateWithError` (12+) or the async variant (13+) |
| `SecIdentity` | Certificate with its private key, for client authentication | Comes out of `SecPKCS12Import`; goes into `URLCredential(identity:certificates:persistence:)` |
| `SecPolicy` | Which rules apply | `SecPolicyCreateSSL`, `SecPolicyCreateRevocation` |

## Evaluating trust

`SecTrustEvaluateAsyncWithError` takes the trust object, a dispatch queue and a
callback. The callback receives the trust, a success flag and an optional `CFError`;
the function itself returns an `OSStatus` saying whether evaluation could start.

When a cached verdict exists, the callback may run before the call returns. Because
evaluation may fetch intermediates and query revocation servers, start it from a
background queue:

```swift
import Foundation
import Security

struct TrustFailure: Error {
    let status: OSStatus
    let underlying: CFError?
}

func checkTrustOffMain(_ trust: SecTrust, then report: @escaping @Sendable (TrustFailure?) -> Void) {
    let worker = DispatchQueue(label: "tls.trust", qos: .userInitiated)
    nonisolated(unsafe) let handedOver = trust
    worker.async {
        let started = SecTrustEvaluateAsyncWithError(handedOver, worker) { _, ok, cfError in
            report(ok ? nil : TrustFailure(status: errSecNotTrusted, underlying: cfError))
        }
        if started != errSecSuccess {
            report(TrustFailure(status: started, underlying: nil))
        }
    }
}
```

`SecTrust` is not `Sendable`, so moving it onto the worker queue is marked
`nonisolated(unsafe)`: the caller promises not to touch the trust while it is
being evaluated. `report` is `@Sendable` because it runs on that queue.

The iOS 26.4 SDK still has no async/await version of these calls. A continuation
bridges the gap:

```swift
func verifiedTrust(_ trust: SecTrust) async throws {
    let _: Void = try await withCheckedThrowingContinuation { cont in
        checkTrustOffMain(trust) { failure in
            if let failure { cont.resume(throwing: failure) } else { cont.resume() }
        }
    }
}
```

The blocking form, `SecTrustEvaluateWithError`, which returns a `Bool` and fills an
optional `CFError` out-parameter, arrived in iOS 12 and remains supported. Calling it
from a `URLSessionDelegate` challenge handler is fine because that handler is already
off the main thread. Keep it away from the main run loop: it may go to the network.

`SecTrustEvaluate(_:_:)`, the old call that returns a `SecTrustResultType`, has been
deprecated since iOS 13. Reject code that uses it.

If you need the detailed verdict, read it with `SecTrustGetTrustResult` after
evaluating:

| `SecTrustResultType` | Meaning | Action |
| --- | --- | --- |
| `.unspecified` | Chains to an implicitly trusted anchor; the usual success | Proceed |
| `.proceed` | User explicitly trusted it | Proceed |
| `.deny` | User explicitly distrusted it | Reject; never override |
| `.recoverableTrustFailure` | Failed, but settings could change the result | Inspect, reconfigure or reject |
| `.fatalTrustFailure` | Structurally invalid | Reject |
| `.otherError` | Revoked, or an OS error | Reject |
| `.invalid` | Not evaluated yet | Evaluate first |

Only `.unspecified` and `.proceed` count as trusted.

## Custom policies and anchors

```swift
func makeTrust(chain: [SecCertificate], host: String,
               privateRoot: SecCertificate) -> SecTrust? {
    let policy = SecPolicyCreateSSL(true, host as CFString)
    var trust: SecTrust?
    guard SecTrustCreateWithCertificates(chain as CFArray, policy, &trust) == errSecSuccess,
          let trust else { return nil }

    SecTrustSetAnchorCertificates(trust, [privateRoot] as CFArray)
    SecTrustSetAnchorCertificatesOnly(trust, false)
    return trust
}
```

`SecPolicyCreateSSL(true, host)` evaluates a server certificate and checks it against
the hostname. Two traps:

- Setting anchor certificates without `SecTrustSetAnchorCertificatesOnly(trust, false)`
  quietly turns off every system root. Pass `false` unless you really mean to trust
  only your own CA.
- `SecPolicyCreateSSL(true, nil)` skips hostname checking. Any valid certificate for any
  domain passes, which is a man-in-the-middle opening.

## Four ways to pin

### 1. Leaf certificate (do not ship)

Server certificates last from 90 days (common with free ACME CAs) up to 398 days (the
CA/Browser Forum maximum). Every renewal changes the bytes, the pin stops matching, and
nobody can connect until a fixed build clears review and reaches their phone.

```swift
// Anti-pattern: exact leaf comparison
func leafMatches(_ trust: SecTrust, bundled: Data) -> Bool {
    let certs = (SecTrustCopyCertificateChain(trust) as? [SecCertificate]) ?? []
    return certs.first.map { SecCertificateCopyData($0) as Data } == bundled
}
```

Only consider this if you own the entire certificate lifecycle and can push new pins
without app review.

### 2. Intermediate CA

Compare each certificate in the chain with a bundled intermediate. Intermediates are
valid for 5-10 years, so leaf renewals pass. The trade-off: any certificate that CA
issues for your hostname passes too, so a compromise of that CA is in scope.

```swift
func chainContainsIntermediate(_ trust: SecTrust, pinned: Data) -> Bool {
    let certs = (SecTrustCopyCertificateChain(trust) as? [SecCertificate]) ?? []
    return certs.dropFirst().contains { SecCertificateCopyData($0) as Data == pinned }
}
```

### 3. SPKI hash (recommended in code)

The hash of the SubjectPublicKeyInfo stays the same across renewals as long as the key
pair does, which makes it the programmatic approach to choose.

One detail breaks most hand-written implementations: what
`SecKeyCopyExternalRepresentation` gives back is only the key material, lacking the
ASN.1 prefix that turns it into a SubjectPublicKeyInfo. Put the algorithm's prefix in
front before hashing; pins produced with OpenSSL are computed over the full structure
and will otherwise never match.

```swift
import CryptoKit

final class PinnedSessionDelegate: NSObject, URLSessionDelegate {
    private let acceptedPins: Set<String>

    init(acceptedPins: Set<String>) {
        self.acceptedPins = acceptedPins
    }

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge
    ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        let space = challenge.protectionSpace
        guard space.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = space.serverTrust else {
            return (.performDefaultHandling, nil)
        }
        guard SecTrustEvaluateWithError(serverTrust, nil) else {
            return (.cancelAuthenticationChallenge, nil)
        }

        let presented = (SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate]) ?? []
        let pinsSeen = presented.compactMap(SPKIPin.base64SHA256(of:))
        guard pinsSeen.contains(where: acceptedPins.contains) else {
            return (.cancelAuthenticationChallenge, nil)
        }
        return (.useCredential, URLCredential(trust: serverTrust))
    }
}

enum SPKIPin {
    private static let rsa2048Prefix = bytes("30820122300d06092a864886f70d01010105000382010f00")
    private static let p256Prefix = bytes("3059301306072a8648ce3d020106082a8648ce3d030107034200")

    private static func bytes(_ hex: String) -> Data {
        var out = Data()
        var index = hex.startIndex
        while index < hex.endIndex {
            let next = hex.index(index, offsetBy: 2)
            out.append(UInt8(hex[index..<next], radix: 16) ?? 0)
            index = next
        }
        return out
    }

    static func base64SHA256(of certificate: SecCertificate) -> String? {
        guard let key = SecCertificateCopyKey(certificate),
              let keyBytes = SecKeyCopyExternalRepresentation(key, nil) as Data?,
              let info = SecKeyCopyAttributes(key) as? [String: Any] else { return nil }

        let algorithm = info[kSecAttrKeyType as String] as? String
        let size = info[kSecAttrKeySizeInBits as String] as? Int
        let prefix: Data
        switch (algorithm, size) {
        case (String(kSecAttrKeyTypeRSA), 2048): prefix = rsa2048Prefix
        case (String(kSecAttrKeyTypeECSECPrimeRandom), 256): prefix = p256Prefix
        default: return nil
        }
        let spki = prefix + keyBytes
        return Data(SHA256.hash(data: spki)).base64EncodedString()
    }
}
```

The order matters: first the system evaluation must pass, then the chain is walked and
Base64(SHA-256(header + key)) is compared for each certificate; any match accepts with
`URLCredential(trust:)`, no match cancels. Challenges other than server trust get
default handling. The RSA-2048 header is 24 bytes starting `30 82 01 22`, the P-256
header 26 bytes starting `30 59 30 13`. Other key types (RSA-4096, P-384) need their own
headers; the sample refuses them rather than guessing. Older code often hashes with
CommonCrypto's `CC_SHA256`; CryptoKit's `SHA256` does the same job.

Generating a pin from a certificate file:

```sh
openssl x509 -in server.pem -noout -pubkey \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64
```

From a live server, start the pipe with
`echo | openssl s_client -servername api.example.com -connect api.example.com:443`
and feed its output through `openssl x509 -noout -pubkey` before the steps above.

### 4. NSPinnedDomains (iOS 14+)

Apple's recommended, declarative approach. `URLSession` enforces it through ATS, and it
is based on SPKI hashes.

```xml
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSPinnedDomains</key>
    <dict>
        <key>api.example.com</key>
        <dict>
            <key>NSIncludesSubdomains</key>
            <true/>
            <key>NSPinnedCAIdentities</key>
            <array>
                <dict>
                    <key>SPKI-SHA256-BASE64</key>
                    <string>r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=</string>
                </dict>
                <dict>
                    <key>SPKI-SHA256-BASE64</key>
                    <string>YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</string>
                </dict>
            </array>
        </dict>
    </dict>
</dict>
```

The two entries above stand for a primary CA and a backup CA from a different
provider.

- `NSPinnedCAIdentities` is checked against every issuer above the leaf, intermediates
  and root alike; a single hit among the listed hashes satisfies it.
- `NSPinnedLeafIdentities` matches only the leaf.
- `NSIncludesSubdomains` extends the rule to first-level subdomains.
- List both kinds and both must be satisfied: each list needs its own hit (the lists
  combine with AND, entries inside one list with OR).
- Works for `URLSession` and, after fixes, `WKWebView` from iOS 16. Does not apply to
  `SFSafariViewController`. Pins are visible to anyone reading `Info.plist`, and
  changing them needs an app update.

### Which to use

| Approach | When |
| --- | --- |
| Leaf | Never in production |
| Intermediate CA | Apps that use one CA |
| SPKI in code | Pin sets that change at runtime, or custom decision logic |
| `NSPinnedDomains` | Default for most apps |

SPKI-based options (in code or `NSPinnedDomains`) only need updating when the key
itself rotates.

## Certificates and identities

Loading a bundled DER certificate:

```swift
struct BundledCertificate {
    let certificate: SecCertificate
    let subject: String?
    let publicKey: SecKey?
    let der: Data
}

func loadBundledCertificate(named name: String) -> BundledCertificate? {
    guard let folder = Bundle.main.resourceURL,
          let der = try? Data(contentsOf: folder.appendingPathComponent("\(name).cer")),
          let cert = SecCertificateCreateWithData(nil, der as CFData) else { return nil }

    return BundledCertificate(
        certificate: cert,
        subject: SecCertificateCopySubjectSummary(cert) as String?,
        publicKey: SecCertificateCopyKey(cert),
        der: SecCertificateCopyData(cert) as Data
    )
}
```

`SecCertificateCreateWithData` accepts DER only. For PEM, remove the
`-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` lines and Base64-decode
what remains.

Importing a client identity from PKCS#12:

```swift
enum IdentityImportError: Error { case status(OSStatus), empty }

func importClientIdentity(p12: Data, passphrase: String) throws -> SecIdentity {
    let options = [kSecImportExportPassphrase as String: passphrase] as CFDictionary
    var items: CFArray?
    let status = SecPKCS12Import(p12 as CFData, options, &items)
    guard status == errSecSuccess else { throw IdentityImportError.status(status) }

    guard let entries = items as? [[String: Any]],
          let value = entries.first?[kSecImportItemIdentity as String] else {
        throw IdentityImportError.empty
    }
    let ref = value as CFTypeRef
    guard CFGetTypeID(ref) == SecIdentityGetTypeID() else { throw IdentityImportError.empty }
    return ref as! SecIdentity
}
```

Conditional casts to Core Foundation types always succeed at compile time, so the
type-ID check is what actually verifies the value before the cast.

Each import result dictionary holds:

| Key | Value |
| --- | --- |
| `kSecImportItemIdentity` | `SecIdentity` |
| `kSecImportItemCertChain` | `[SecCertificate]` |
| `kSecImportItemTrust` | `SecTrust` |
| `kSecImportItemKeyID` | `Data`, usually the SHA-1 of the public key |

Never ship the PKCS#12 passphrase in the app. Ask the user, or keep it in the
Keychain.

Mutual TLS in a session delegate:

```swift
final class MutualTLSDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
    private let clientIdentity: SecIdentity
    private let issuerChain: [SecCertificate]

    init(clientIdentity: SecIdentity, issuerChain: [SecCertificate]) {
        self.clientIdentity = clientIdentity
        self.issuerChain = issuerChain
    }

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge
    ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        let method = challenge.protectionSpace.authenticationMethod
        if method == NSURLAuthenticationMethodClientCertificate {
            let proof = URLCredential(identity: clientIdentity,
                                      certificates: issuerChain,
                                      persistence: .forSession)
            return (.useCredential, proof)
        }
        if method == NSURLAuthenticationMethodServerTrust {
            if let serverTrust = challenge.protectionSpace.serverTrust,
               SecTrustEvaluateWithError(serverTrust, nil) {
                return (.useCredential, URLCredential(trust: serverTrust))
            }
            return (.cancelAuthenticationChallenge, nil)
        }
        return (.performDefaultHandling, nil)
    }
}
```

`URLSessionDelegate` requires `Sendable`, and `SecIdentity` is not marked
`Sendable`. The class holds only `let` properties, so `@unchecked Sendable` states
a guarantee the compiler cannot check.

Client certificate challenges are delivered at the session level
(`URLSessionDelegate`), not per task. Apps cannot use certificates installed system-wide
by MDM; only identities in the app's own sandbox are available.

Walking the chain across OS versions:

```swift
func certificates(in trust: SecTrust) -> [SecCertificate] {
    guard #unavailable(iOS 15, macOS 12) else {
        return (SecTrustCopyCertificateChain(trust) as? [SecCertificate]) ?? []
    }
    let total = SecTrustGetCertificateCount(trust)
    return (0..<total).compactMap { SecTrustGetCertificateAtIndex(trust, $0) }
}
```

Storing certificates and identities in the Keychain is covered in
[keychain-item-classes.md](keychain-item-classes.md).

## Mistakes to reject in review

| Mistake | Fix |
| --- | --- |
| Result-code `SecTrustEvaluate`, gone since iOS 13 | The error-returning sync call, or its async sibling |
| `NSAllowsArbitraryLoads: true` globally; opens MITM and draws App Review questions | `NSAllowsLocalNetworking` for development, narrow per-domain exceptions in production |
| Custom anchors without `SecTrustSetAnchorCertificatesOnly(_, false)` | Always pair them |
| `SecPolicyCreateSSL` with a nil hostname | Pass the real hostname |
| Pin check without system evaluation, so expired or revoked certificates pass | Evaluate first, then pin |
| Index-based chain access, retired in iOS 15 | Copy the whole chain as an array; keep the index path only for older systems |
| Copying the public key from the trust the pre-iOS 14 way | Take it from the certificate, or use `SecTrustCopyKey` |
| SPKI hash computed without the ASN.1 header | Prepend the header; otherwise nothing matches |
| Trust evaluation on the main queue | Background queue; the UI freezes otherwise |

## Backup pins and rotation

- Always pin at least two keys. With a single pin, a revocation, a CA compromise or an
  unplanned key change takes networking down.
- Backup strategy: generate a spare key pair ahead of time and pin its SPKI hash
  without deploying any certificate for it. If the primary is compromised, get a
  certificate issued for the spare key.
- If every pin fails: show a clear verification error, fall back to offline or cached
  content, never offer a bypass, and log the event. Recovery is an App Store update or
  an updated MDM configuration profile.
- OWASP's guidance is that pinning pays off only for teams that own the server end as
  well as the app, have a safe channel for shipping new pins, and have rehearsed key
  rotation. Certificate Transparency (enforced since
  iOS 12.1.1) together with Apple's revocation checking protects well without the
  operational risk of pinning.

## Where ATS meets trust

- ATS requires, for every `URLSession` connection: TLS 1.2 or later, RSA keys of at
  least 2048 bits or ECC of at least 256 bits, SHA-256 or stronger certificate
  signatures, AES-128 or AES-256, and forward secrecy.
- For connections to IP-address literals, check Apple's `NSAppTransportSecurity`
  documentation for how ATS applies on the deployment target.
- Keys that draw App Review scrutiny: `NSAllowsArbitraryLoads`,
  `NSAllowsArbitraryLoadsForMedia`, `NSAllowsArbitraryLoadsInWebContent`,
  `NSExceptionAllowsInsecureHTTPLoads`, `NSExceptionMinimumTLSVersion`.
- Diagnose on a Mac with `nscurl --ats-diagnostics https://api.example.com`.

## API timeline

| Release | Change |
| --- | --- |
| iOS 12 / macOS 10.14 | `SecTrustEvaluateWithError`; Certificate Transparency enforced (12.1.1) |
| iOS 13 / macOS 10.15 | `SecTrustEvaluateAsyncWithError`; `SecTrustEvaluate` deprecated |
| iOS 14 / macOS 11 | `NSPinnedDomains`; `SecTrustCopyKey` replaces `SecTrustCopyPublicKey` |
| iOS 15 / macOS 12 | `SecTrustCopyCertificateChain` replaces `SecTrustGetCertificateAtIndex` and `SecTrustGetCertificateCount` |
| iOS 18 / macOS 15 | `SecCertificateCopyNotValidBeforeDate` and `SecCertificateCopyNotValidAfterDate` |
| iOS 18.4 / macOS 15.4 | `kSecTrustQWACValidation`, `kSecTrustQCStatements` and the `kSecPolicyApple*` server and client policy constants |
| iOS 26 / macOS 26 | No new trust-evaluation API; still no async/await form of `SecTrustEvaluateAsyncWithError` |

## Threads and performance

- Do not use one `SecTrust` from several threads at once. Separate instances can be
  evaluated in parallel.
- On iOS the whole family of certificate, key and trust functions may be called from
  any thread, including reentrantly.
- On macOS, evaluation can wait on user interaction such as a keychain unlock dialog;
  always evaluate in the background.
- None of the trust, certificate or key reference types conforms to `Sendable`. In
  Swift 6 mode, confine them to an actor or wrap them in an `@unchecked Sendable` box whose use you control.

## CI guardrails

- Fail the build when a production `Info.plist` sets `NSAllowsArbitraryLoads` to true.
- Block any SSL policy created without a hostname outside test targets.
- Require at least two SPKI hashes for every `NSPinnedDomains` entry.
- Flag `SecTrustEvaluate(`, `SecTrustGetCertificateAtIndex(` and
  `SecTrustCopyPublicKey(`.
- Exercise pinning through a certificate rotation in staging.

## Related and sources

[keychain-item-classes.md](keychain-item-classes.md) for storing certificates,
identities and PKCS#12 material; [keychain-fundamentals.md](keychain-fundamentals.md);
[cryptokit-public-key.md](cryptokit-public-key.md) for PEM/DER and curve choice for
client keys. OWASP M5 (insecure communication) and MASVS-NETWORK are mapped in
[compliance-owasp-mapping.md](compliance-owasp-mapping.md).

Sources: WWDC17 session 701 on network security standards; Apple's documentation
pages on trust evaluation and result parsing, on `SecTrustEvaluateAsyncWithError` and
on `NSPinnedDomains`; the Apple Platform Security Guide; the Apple developer news post
on identity pinning; the OWASP pinning cheat sheet; OWASP MASTG.

## Checklist

- [ ] Only current evaluation APIs.
- [ ] Evaluation runs off the main thread.
- [ ] No leaf pinning; SPKI, intermediate CA or `NSPinnedDomains`.
- [ ] At least two pins (a second CA or a pre-generated backup key).
- [ ] System trust evaluated before pins are checked.
- [ ] SPKI hashes include the ASN.1 header.
- [ ] Custom anchors keep system roots unless restriction is intended.
- [ ] SSL policies always carry the hostname.
- [ ] ATS not disabled globally; only targeted exceptions.
- [ ] `SecTrustCopyCertificateChain` (iOS 15+) with a fallback; `SecCertificateCopyKey`
      instead of `SecTrustCopyPublicKey`.
- [ ] No PKCS#12 passphrases in the bundle.
