Certificate Common Name (CN) pinning for Apple platforms.
Looking for the Android version? See CNPinning-Android
CNPinning-Apple lets a URLSession reject TLS connections unless the server's
certificate chain presents the Common Names you expect. Instead of pinning a
public key or a certificate's SHA-256 hash (which break the moment a certificate
is rotated), you pin against the Common Names of the certificates in the
chain — using exact, prefix, numeric-suffix, or suffix matching so that
routine certificate renewals (e.g. R10 → R11, Apple Public Server ECC CA 1 - G3)
don't break your app.
Validation always runs in addition to the system's normal TLS evaluation: a connection only succeeds when both the OS trusts the chain and the chain's Common Names match one of your pinned chains. The library is fail-secure — anything it cannot verify for the specified configuration is rejected.
For the design rationale, threat model, and how this compares to public-key and certificate-hash pinning, see the white paper.
- How it works
- Requirements
- Installation
- Quick start
- Configuration via Info.plist
- Programmatic configuration
- Match types
- Discovering a server's CN chain
- Subdomain handling
- Validation behavior
- Async/await API
- Enterprise pinning
- Errors
- Security notes
- Testing
- License
- You describe, per domain, one or more certificate chains you are willing to trust. A chain is an ordered list of "links," one per certificate, written root → intermediate(s) → leaf order (the order you read a chain top-down).
- At connection time your
URLSessionhands the delegate a TLS challenge containing the server's certificate chain. CNPinningManagerextracts the Common Name from each certificate, then checks whether the presented chain matches all the links of any chain you defined for that host, as specified within each link's configuration.- The number of certificates presented must equal the number of links in a chain. Every certificate is checked — including the leaf.
- If a chain matches → the connection proceeds with default handling. If it does not → the connection is cancelled.
Note on ordering: developers define chains root → leaf because that's how humans read them, but
URLSessiondelivers the chain leaf → root. The library reverses your definition internally so the two line up. You always write root → leaf.
URLSession caches responses on-device via the urlCache on its URLSessionConfiguration.
A cached response is returned without a new TLS handshake — so no challenge
reaches the delegate, and no pinning occurs. Building your URLSession
with an ephemeral URLSessionConfiguration, at least during development,
avoids this and will save you uncounted time tracking down why a host isn't being pinned.
CNPinning applies pinning only to hosts you explicitly configure. Connections to hosts without a configured pin set proceed with the platform's normal TLS validation — they are allowed, not blocked. This matches the behavior of Apple's and Android's built-in pinning and standard pinning libraries: pinning adds constraints to the hosts you choose to pin; it does not restrict connections to only-pinned hosts.
A consequence to be aware of: if a host you intend to pin is misconfigured — a hostname typo, a configuration that doesn't load, a pin set not applied to that host — connections to it are allowed (unpinned), not blocked. Pinning misconfigurations fail open. Verify that your pin sets are actually applied to the hosts you intend to protect; do not assume a host is pinned without confirming its configuration is loaded and matched.
| Platform | Minimum |
|---|---|
| macOS | 12.0 |
| iOS | 15.0 |
| tvOS | 15.0 |
| watchOS | 8.0 |
| visionOS | 1.0 |
- Swift 6 language mode (
swift-tools-version: 6.3).
In Xcode: File → Add Package Dependencies…, enter the repository URL, and add the CNPinning-Apple library product to your app target.
dependencies: [
.package(url: "https://github.com/austinsoftcom/CNPinning-Apple.git", from: "1.1.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "CNPinning-Apple", package: "CNPinning-Apple")
]
)
]Then import CNPinning_Apple (note the underscore — Swift replaces the hyphen in
the module name).
There are three pieces: a pinning manager, a URLSessionDelegate that
calls it, and a URLSession that carries the manager.
import Foundation
import CNPinning_Apple
final class PinningURLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// If there's no manager, or the manager handled the challenge, we're done.
guard let pinningManager = session.cnPinningManager,
pinningManager.validate(challenge: challenge, completionHandler: completionHandler) else {
// Host is not pinned (or no manager). Choose your policy here — this
// example refuses anything that isn't explicitly pinned.
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
}
}import CNPinning_Apple
let pinnedSession: URLSession = {
do {
// Reads the pinning configuration from your app's Info.plist.
let pinningManager = try CNPinningManager()
let delegate = PinningURLSessionDelegate()
let session = URLSession(
configuration: .default,
delegate: delegate,
delegateQueue: nil
)
session.cnPinningManager = pinningManager // attach the manager to the session
return session
} catch {
fatalError("Unable to initialize the pinned URLSession: \(error)")
}
}()pinnedSession.dataTask(with: URL(string: "https://austinsoft.com/")!) { data, response, error in
// `error` is URLError(.cancelled) and `data` is nil when pinning rejects the connection.
}.resume()session.cnPinningManager is an associated object on URLSession provided by the
library — set it once and the delegate retrieves it on every challenge.
Most apps configure pinning declaratively in Info.plist and create the
manager with try CNPinningManager(). The structure is:
CNPinningManager (Dictionary)
└─ PinnedDomains (Dictionary)
└─ <host> (Dictionary)
├─ includesSubdomains (Boolean)
└─ chainSet (Array of chains)
└─ <chain> (Array of links, root → leaf)
└─ <link> (Dictionary)
├─ type (String: exact | prefix | prefixWithNumber | suffix)
└─ value (String)
Example pinning captive.apple.com and austinsoft.com:
<key>CNPinningManager</key>
<dict>
<key>PinnedDomains</key>
<dict>
<key>captive.apple.com</key>
<dict>
<key>includesSubdomains</key>
<false/>
<key>chainSet</key>
<array>
<!-- one chain, written root → leaf -->
<array>
<dict>
<key>type</key> <string>exact</string>
<key>value</key> <string>DigiCert Global Root G</string>
</dict>
<dict>
<key>type</key> <string>prefixWithNumber</string>
<key>value</key> <string>Apple Public Server ECC CA 1 - G</string>
</dict>
<dict>
<key>type</key> <string>suffix</string>
<key>value</key> <string>.apple.com</string>
</dict>
</array>
</array>
</dict>
<key>austinsoft.com</key>
<dict>
<key>includesSubdomains</key>
<false/>
<key>chainSet</key>
<array>
<array>
<dict>
<key>type</key> <string>prefixWithNumber</string>
<key>value</key> <string>ISRG Root X</string>
</dict>
<dict>
<key>type</key> <string>prefixWithNumber</string>
<key>value</key> <string>R</string>
</dict>
<dict>
<key>type</key> <string>suffix</string>
<key>value</key> <string>austinsoft.com</string>
</dict>
</array>
</array>
</dict>
</dict>
</dict>Notes:
chainSetis a set of chains, which are arrays. List more than one chain when a host may be served from different certificate authorities (e.g. during a CA migration) — any chain matching the trust chain is enough.includesSubdomainsis required in Info.plist (omitting it throwsCNParseError.missingValue("includesSubdomains")).- If a link omits
type, it defaults toexact(and a warning is logged to stderr in DEBUG builds).
You can also build the manager in code — useful for tests, for configuration fetched at runtime, or if you simply prefer not to use Info.plist.
import CNPinning_Apple
let pinningManager = try CNPinningManager(
configuration: [
"austinsoft.com": try CNConfiguration(
includesSubdomains: false,
[
CNChain([ // written root → leaf
CNChainLink(.prefixWithNumber, "ISRG Root X"),
CNChainLink(.prefixWithNumber, "R"),
CNChainLink(.exact, "austinsoft.com"),
])
]
)
]
)NOTE: You can configure both a domain and a specific host (e.g.
apple.comandwww.apple.com) with different chains; an exact host match always wins over aincludesSubdomainsmatch (see Subdomain handling).
| Type | Purpose |
|---|---|
CNPinningManager |
Holds all per-host configurations and performs validation. |
CNConfiguration |
One host's settings: includesSubdomains + one or more chains. |
CNChain |
One acceptable certificate chain, written root → leaf. |
CNChainLink |
A single certificate's match rule: a LinkType and a value. |
CNConfiguration rejects an empty chainSet (CNParseError.noChainsDefined)
and configurations that have duplicate chains (CNParseError.duplicateChain(index)).
Each link matches one certificate's Common Name with one of these
CNChainLink.LinkType values:
| Type | Matches when the CN… | Example value | Matches | Doesn't match |
|---|---|---|---|---|
exact |
equals the value exactly | austinsoft.com |
austinsoft.com |
www.austinsoft.com |
prefix |
begins with the value | DigiCert C |
DigiCert C4 |
DigiCert |
prefixWithNumber |
begins with the value, and the remainder is all digits (≥1) | R |
R10, R11 |
R, RX |
suffix |
ends with the value | .apple.com |
www.apple.com |
apple.com |
prefixWithNumber is the key to surviving certificate rotation: CAs commonly
roll names like R10 → R11 or Apple Public Server ECC CA 1 - G3 → … - G4.
Pin the stable prefix and let the trailing generation number float.
To pin a host you need the Common Names of every certificate in its chain, in
root → leaf order. The repository ships a helper, printPins.py, that
connects to a host and prints exactly that — one Common Name per line, already in
the order you write a CNChain.
python3 printPins.py <hostname> # one Common Name per line (default)
python3 printPins.py <hostname> --format plist # ready-to-paste Info.plist fragmentRequirements:
- Python 3 (uses only the standard library).
opensslon yourPATH— the script shells out toopenssl s_clientto fetch the chain and toopenssl x509to read each certificate's subject.
The script connects to <hostname> on port 443 and prints the Common Name of
each certificate in the chain, root → leaf, one per line. It also fills in the
true root: if the topmost certificate the server sends was issued by a root that
wasn't transmitted (common, since clients already hold roots), that root's
Common Name is printed first.
$ python3 printPins.py austinsoft.com
ISRG Root X1
R13
austinsoft.comRead top-to-bottom this is root → intermediate → leaf — the same order a
CNChain is written. Translate each line into a link, choosing match types that
tolerate routine certificate renewals:
CNChain([
CNChainLink(.prefixWithNumber, "ISRG Root X"), // ISRG Root X1, X2, …
CNChainLink(.prefixWithNumber, "R"), // R10, R11, …
CNChainLink(.exact, "austinsoft.com"), // the leaf CN
])Guidelines when translating:
- Include every line the script prints (leaf included) — the link count must equal the number of certificates the server presents, or the chain never matches.
- Prefer
prefixWithNumber/prefix/suffixoverexactfor CA certificates so renewals don't break your app; reserveexactfor values you control and expect to be stable (often the leaf). - If the host might be served by more than one CA, run the script periodically
and capture each possibility as a separate chain in the
chainSet.
--format plist does the translation for you: it prints a <key>/<dict> entry,
ready to paste directly inside CNPinningManager > PinnedDomains in your app's
Info.plist. It applies the same heuristic described above — a trailing run of
digits becomes a prefixWithNumber link on the stable prefix; everything else is
pinned exact.
$ python3 printPins.py austinsoft.com --format plist
<key>austinsoft.com</key>
<dict>
<key>includesSubdomains</key>
<false/>
<key>chainSet</key>
<array>
<array>
<dict>
<key>type</key>
<string>prefixWithNumber</string>
<key>value</key>
<string>ISRG Root X</string>
</dict>
<dict>
<key>type</key>
<string>prefixWithNumber</string>
<key>value</key>
<string>R</string>
</dict>
<dict>
<key>type</key>
<string>exact</string>
<key>value</key>
<string>austinsoft.com</string>
</dict>
</array>
</array>
</dict>Pass --includes-subdomains to set includesSubdomains to true in the output
(it defaults to false).
The fragment reflects only the single chain the host currently presents, with
conservative match types. Review it before shipping — for example, you may
want to relax a leaf entry to a suffix match, or add a second chain for the
host's other key type (see below).
Roots and intermediates change too. Every certificate in the chain — including
the root — must match a link, and CA certificate names are not permanent. When a
CA rotates a root or intermediate, re-run printPins.py and add the new chain to
the chainSet (keeping the old one) so connections keep working across the
transition until the old hierarchy is retired.
Why prefix matching instead of a certificate/key hash? Certificate lifetimes are collapsing — Let's Encrypt leaf certificates last up to 100 days (with a ~6-day short-lived option), and the CA/Browser Forum's maximum drops to 47 days in March 2029. A hash or key pin potentially breaks on every renewal; pinning the stable CA-name prefix survives rotation while still rejecting an unexpected authority. The full rationale, threat model, and trade-offs are in the design white paper.
When resolving the configuration for a host, CNPinningManager:
- Uses an exact host match if one exists (regardless of
includesSubdomains). - Otherwise, among configurations whose
includesSubdomainsistrue, picks the one whose domain is a suffix of the host (hostends with.+domain), preferring the longest (most specific) matching domain.
So with apple.com (includesSubdomains: true) and www.apple.com
(includesSubdomains: true) both configured, a request to sub.www.apple.com
resolves to the www.apple.com configuration because it's the more specific
suffix.
A host with no matching configuration is treated as not pinned (see below).
The completion-handler API returns a Bool indicating whether the manager
handled the challenge:
func validate(
challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) -> Bool| Situation | Return value | completionHandler called with |
Meaning |
|---|---|---|---|
| Host is not pinned | false |
(not called) | You decide what to do (default handling or cancel). |
| Chain matches | true |
.performDefaultHandling |
Proceed with normal OS trust evaluation. |
| Cannot get trust / chain / Common Names | true |
.cancelAuthenticationChallenge |
Fail-secure reject. |
| Chain does not match | true |
.cancelAuthenticationChallenge |
Pin failed; reject. |
Because validate does not call the completion handler for unpinned hosts,
your delegate must decide the policy for hosts you didn't pin. Two common choices:
// Strict: refuse anything not explicitly pinned.
guard let manager = session.cnPinningManager,
manager.validate(challenge: challenge, completionHandler: completionHandler) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}// Permissive: pin the hosts you listed, fall back to the OS for everything else.
guard let manager = session.cnPinningManager else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
if !manager.validate(challenge: challenge, completionHandler: completionHandler) {
completionHandler(.performDefaultHandling, nil)
}For Swift concurrency, use the throwing async variant:
func validate(
challenge: URLAuthenticationChallenge
) async throws -> (URLSession.AuthChallengeDisposition, URLCredential?)- Matched chain → returns
(.performDefaultHandling, nil). - Match failed or unverifiable → returns
(.cancelAuthenticationChallenge, nil). - Host not pinned → throws
CNPinningError.notPinned(so you can decide the fallback policy in acatch).
do {
let (disposition, credential) = try await manager.validate(challenge: challenge)
completionHandler(disposition, credential)
} catch CNPinningError.notPinned {
completionHandler(.performDefaultHandling, nil) // or .cancelAuthenticationChallenge
}An enterprise can extend an app's pinning at runtime with a signed policy, without an app
update — useful when a managed device must trust a corporate TLS-inspection chain. Build the manager
with an authenticationHost (the login host, exempt from enterprise overrides so a policy fetched
from it can never re-pin it) and a policySigningKey (the SecKey public key every policy must
verify against):
import CNPinning_Apple
// From Info.plist:
let manager = try CNPinningManager(
authenticationHost: "auth.example.com",
policySigningKey: enterprisePublicKey // a SecKey
)
// …or with a programmatic configuration:
let manager = try CNPinningManager(
authenticationHost: "auth.example.com",
policySigningKey: enterprisePublicKey,
configuration: [ /* app-baked pins */ ]
)After a successful login, hand the manager the signed policy you fetched (the raw JWS bytes):
try manager.applyEnterprisePolicy(with: signedPolicy) // first time (throws if one is already active)
try manager.refreshEnterprisePolicy(with: signedPolicy) // replace an active policy
manager.signOut() // drop the policy (e.g. on logout)An applied policy contributes its mappings only while the current time is within its iat/exp
window; once expired (or not yet valid) it is ignored during evaluation, falling back to the
app-baked pins. The active policy's window is available via manager.enterprisePolicyIssuedAt and
manager.enterprisePolicyExpiry (both Date?, nil when no policy is applied).
The policy is a JWS (RS256/384/512, ES256/384/512, or PS256/384/512) whose verified payload is
JSON: an iat/exp pair plus one entry per host (or the "*" wildcard), each an array of chains in
the same root-to-leaf shape used elsewhere. iat/exp are JWT NumericDate claims — seconds
since the Unix epoch (RFC 7519):
{
"iat": 1718000000,
"exp": 1718600000,
"*": [
[ { "type": "exact", "value": "Enterprise Root CA" },
{ "type": "suffix", "value": ".example.com" } ]
]
}When a host is pinned, an enterprise configuration (an exact host match, else the "*" wildcard) is
tried before the app-baked configuration, so either an app chain or an enterprise chain may
satisfy the pin. Verification uses SecKeyVerifySignature; ECDSA signatures use the ASN.1/DER (X9.62)
encoding, which matches CNPinning-Android, so one signed policy verifies on both platforms.
| Case | Cause |
|---|---|
noInfoPlist |
No Info.plist is available (or it is empty) when constructing the manager from try CNPinningManager(). |
atsConflict |
App Transport Security is also configured for pinning (NSAppTransportSecurity → NSPinnedDomains), which would conflict with CN pinning. |
missingValue(String) |
A required key is missing (CNPinningManager, PinnedDomains, includesSubdomains, chainSet, or a link's value). |
invalidType(String) |
A pinned-domain entry isn't a dictionary. |
invalidLinkType(String) |
A link's type isn't one of the four supported values. |
noChainsDefined |
A configuration's chainSet is empty. |
duplicateChain(Int) |
The same chain appears twice in a chainSet (index of the duplicate). |
| Case | Cause |
|---|---|
notPinned |
The challenged host has no matching pinned configuration. |
enterpriseNotConfigured |
applyEnterprisePolicy/refreshEnterprisePolicy was called on a manager built without an authenticationHost and policySigningKey. |
existingEnterpriseConfiguration |
applyEnterprisePolicy was called while a policy is already active — call signOut() first, or use refreshEnterprisePolicy. |
missingEnterpriseConfiguration |
refreshEnterprisePolicy was called before any policy was applied. |
invalidJWSFormat |
The signed policy is not a valid JWS, or its signature does not verify against the policySigningKey. |
- Pinning runs on top of the system's TLS evaluation — a successful pin
returns
.performDefaultHandling, so the OS still validates expiry, revocation, hostname, and trust. Pinning can never loosen the OS checks; it only adds to them. - The library is fail-secure: a missing server trust, an unreadable or incomplete
chain, or a certificate in the chain without a readable Common Name all result
in rejection. Use
printPins.pyto be certain your chain is complete. - Plan for rotation. Prefer
prefixWithNumber/prefix/suffixfor CA links, and list multiple chains in achainSetso a CA migration doesn't require an app update. - CNPinning validates at the TLS handshake; responses served from the (bundleID-keyed) URLCache for a URLSession are returned without a handshake and thus without re-running the pin evaluation. This is correct (the cached response was pin-validated on its original fetch), but a consumer whose security model requires every response to be pin-validated should account for it (e.g., disable or limit URLCache for pinned endpoints, or use an ephemeral configuration for security-critical requests).
See the white paper for the complete threat model and the reasoning behind these trade-offs.
The library abstracts every system call behind an internal OSCalls struct
(fetching the Info.plist, the server trust, the certificate chain, and each
Common Name). This makes the matching logic fully testable with injected,
deterministic data — see the project's Tests directory, which exercises parsing,
each match type, subdomain resolution, fail-secure paths, and live end-to-end
pinning against a real host.
swift testA runnable SwiftUI example app lives in
Examples/TestCNPinningApp: it loads its pinning
configuration from Info.plist and makes a pinned request when you tap Test.
MIT License. Copyright © 2026 AustinSoft.com. See LICENSE.txt.