Install a debugging proxy’s root certificate on a phone, turn on full trust for it, and the proxy can read the HTTPS traffic of nearly every app on the device. Nothing breaks. For each site the phone asks for, the proxy makes a certificate on the spot and signs it with the root the phone now trusts, and the phone’s checks pass. A few apps refuse to connect at all. They were written to trust a short list of keys rather than every root the device holds. That is certificate pinning.

This essay is about pinning from the side of the app you ship. I built a small public key infrastructure with two public CAs and an interception root, issued nine certificates for one host name, from a routine renewal to a rogue certificate issued by the very CA the app pins, and ran every certificate against four ways of pinning: with curl, and in an iOS app on an iOS 26.5 simulator that trusts all three roots. The results fit in one table, and every script that produced them is at the end.

Pinning has an odd reputation. Apple’s guidance says “In most cases, pinning is not necessary and should be avoided,”1 and Android’s says it “is not recommended for Android apps.”2 Both are right about most apps, for a reason the table makes plain: every kind of pin fails on some event, and the event is often one you do not control. So the useful question is which key you are promising to hold next year, and what happens to your users if you turn out to be wrong.

What the trust store vouches for

When an app opens a TLS connection with the platform’s defaults, the server sends a chain of certificates, and the client checks that the chain ends at a root in the system’s trust store, that the leaf certificate names the host, and that every certificate is within its dates and allowed to do what it is doing. The store holds roots run by many organisations, and any of them may issue a certificate for any name. That breadth is what lets a phone talk to every site on the web. It also means the phone trusts all of those organisations at once, about every name.

A chain can pass every one of those checks and still carry a key that is not yours, in two ways. The first is a CA issuing a certificate for your name to someone else. In 2011 the Dutch CA DigiNotar was broken into and issued certificates for Google’s domains, which were used against Gmail users in Iran; the investigation that followed named about 300,000 of them as the main target, and DigiNotar was declared bankrupt that September.3 The forged certificate was caught because Chrome carried pins for Google’s own sites, the first large demonstration of what a pin is for. In October this year eight intermediate CA keys at eMudhra turned out to be numbers below 256, so anyone could have computed them and signed certificates that every major platform would accept. Eight CA keys that fit in one byte tells that story.

The second way is a root on the device that you did not choose. Debugging proxies, enterprise TLS inspection and some malware all work by getting a root of their own into the store. The platforms have made this harder. On iOS, a root installed through a configuration profile “isn’t automatically trusted for SSL”: the owner has to turn on full trust for it under Settings, General, About, Certificate Trust Settings, although roots installed through device management are trusted without that step.4 On Android, only apps targeting Android 6.0 (API level 23) or lower trust user-added CAs by default, so an app targeting API 24 or later ignores them unless it opts in.5

For the first problem the web has Certificate Transparency. Chrome requires publicly trusted certificates issued after 30 April 2018 to be logged, and Apple requires it for certificates issued after 15 October 2018,6 so a mis-issued certificate for your name has to appear in a public log, where you or a monitoring service can find it. Transparency makes mis-issuance visible after the fact. It does not stop a client from accepting the certificate the moment it is presented, and it does not apply to a root the owner installed.

A pin covers both cases at the moment of the connection, inside your own app. That is what it buys, and everything it costs follows from the same property.

What a pin is

A certificate wraps a public key in names, dates, extensions and a CA’s signature. The certificate changes at every renewal; the key inside it does not have to. So a pin is not a hash of the certificate. It is a hash of the key in the form the certificate carries it: the DER encoding of the SubjectPublicKeyInfo structure, which holds the algorithm and the key bits, hashed with SHA-256 and written in Base64. Apple, Android and curl all name pins this way.

OpenSSL computes one from any certificate:

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

This is the chain this site served on 24 October 2026, with the pin of each certificate:

CertificateIssued byPin
www.roobli.org, valid to 10 December 2026Google Trust Services WE1qz862ofAri0qIdcw7UjgQOD6cAL3pX3a2CvzuWdL/pQ=
Google Trust Services WE1GTS Root R4kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=
GTS Root R4GlobalSign Root CA, as a cross-signmEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=

I could not responsibly pin any of them. The site sits behind Cloudflare, which holds the leaf key and renews the certificate on its own schedule. Cloudflare’s documentation says it “controls the validity periods and certificate authorities” for these certificates, which may come from Let’s Encrypt, Google Trust Services or SSL.com,7 and that with a pin “your domain would go offline each time a certificate rotates.” For anyone who must pin, its advice is to upload a certificate of their own and reuse the key on purpose.8 A pin names a key, and you can only make promises about a key you hold.

flowchart TB
  R["Root CA<br/>self-signed, in the trust store"] -->|signs| I["Issuing CA<br/>a CA pin can name this key,<br/>or the root's"]
  I -->|signs| L["Leaf certificate<br/>the server's own key<br/>a leaf pin names this key"]
  B["Backup key<br/>generated, kept offline,<br/>in no certificate yet"] -.->|pinned beside it| L

Where a pin can point: at a CA key anywhere in the chain, or at the server’s own key, with a second key held back for the day the first has to go.

Nine certificates, four ways to pin

The lab has five authorities. Public Root A and Public Root B each sign an issuing CA, A1 and B1, and stand in for two ordinary public CAs. The Inspection Root stands in for a proxy’s root and signs leaf certificates directly, as such proxies do. The client trusts all three roots, which is the position of a phone whose owner installed a proxy root and enabled it: the case a pin exists for.

Every server certificate is for pinlab.localhost and *.pinlab.localhost, names that resolve to the loopback address. Nine servers run on nine ports:

ScenarioIssued byKeyWhat it stands for
LegitimateA1K1the server as it is today
RenewalA1K1a new certificate for the same key
Planned rotationA1backupmoving to the key the app already pins as its backup
Unplanned rekeyA1K2a renewal that quietly generated a new key
CA switchB1K1the same key, certified by another public CA
ExpiredA1K1the right key in a certificate past its end date
InterceptionInspection Rootproxy keya proxy’s certificate, made on the spot
Rogue, another CAB1attacker’s keya public CA issuing your name to someone else
Rogue, the pinned CAA1attacker’s keythe same, from the CA the app pins

The client tries each server four ways. With no pin, it relies on the trust store. With a CA pin, it requires the key of A1 somewhere in the chain. With a leaf pin, it requires K1 or the backup key in the server’s own certificate. With a pin in code, a URLSession delegate checks the leaf against the same two keys after the system has evaluated the chain. On iOS the first three are settings in Info.plist and the fourth is about forty lines of Swift; curl’s --pinnedpubkey gives the leaf pin a second, independent implementation.

ScenarioNo pinCA pin (A1)Leaf pin (K1, backup)Pin in code
Legitimateconnectsconnectsconnectsconnects
Renewalconnectsconnectsconnectsconnects
Planned rotationconnectsconnectsconnectsconnects
Unplanned rekeyconnectsconnectsrefusedrefused
CA switchconnectsrefusedconnectsconnects
Expiredrefusedrefusedrefusedrefused
Interceptionconnectsrefusedrefusedrefused
Rogue, another CAconnectsrefusedrefusedrefused
Rogue, the pinned CAconnectsconnectsrefusedrefused

iOS 26.5 simulator and curl 8.7.1, 24 October 2026. Curl can only pin the leaf, and it matched the leaf pin on every row: error 90 when the key matched no pin, error 60 for the expired certificate.

Read the table by column. Without a pin, the app accepts the proxy and both rogue certificates. Only the expired certificate fails, and it fails in every column, because a pin is checked on top of the normal checks rather than in place of them. Apple’s documentation says the same: “Pinning a certificate for a given domain has no impact on other security requirements.”9

The CA pin stops the proxy and the rogue from the other CA, and lets the server change its key as often as it likes. It breaks the day the server’s certificate comes from a different CA, which is the refused CA switch. And it accepts the rogue from the pinned CA, because that certificate carries exactly the key the pin names. A CA pin is a bet that the CA you pinned will never make the mistake eMudhra made.

The leaf pin, and the code that applies the same rule, stop the proxy and every rogue, survive the renewal, the planned rotation and the CA switch, and break on the unplanned rekey. It trusts the server’s keys and nobody else’s, so it fails only when the server’s key changes without warning.

Each kind of pin breaks on a different event, so the choice between them is a question about who causes each event:

EventWho decides itWhat it does to a pin
A new key at renewalyour ACME client’s defaults, or whoever runs the serverbreaks a leaf pin
A move to another CAyou, your CDN, or the CA retiring an intermediatebreaks a CA pin
Rotation to the backup keyyounothing, if the backup is pinned
Mis-issuance by the pinned CAthe CApasses a CA pin

The safest pin is on a key whose next change you will make yourself, at a time you choose, to a key the app already knows.

The certificates are getting shorter

That would be manageable if renewals were rare. They are becoming frequent. In April 2025 the CA/Browser Forum passed ballot SC-081, which steps down how long a publicly trusted TLS certificate may be valid:10

Issued on or afterLongest validity
before 15 March 2026398 days
15 March 2026200 days
15 March 2027100 days
15 March 202947 days

How long a CA may reuse a domain validation falls on the same dates, to 10 days at the end. Let’s Encrypt, whose certificates already last 90 days, plans to move its default to 64 days in February 2027 and 45 days in February 2028, and has offered certificates that last 160 hours since January 2026.11

Every renewal is a chance for the key to change. Certbot, the most common ACME client, generates a new private key at every renewal unless told otherwise: “Not reusing private keys is the default behavior of Certbot,” and --reuse-key keeps the existing one.12 A leaf pin over a certificate renewed every 47 days with default settings breaks at the first renewal, because that renewal brings a new key.

Pinning a CA’s intermediate does not escape this when the CA is not yours. Let’s Encrypt now issues from a set of intermediates it calls Generation Y, and when it announced them it said “each issuance will choose which intermediate to use at random, to discourage intermediate key pinning.” Its backup intermediates “may begin issuing… at any time, without warning.”13 Pinning one of them means guessing which one the next certificate will come from.

Shorter certificates change what is worth pinning: keys rather than certificates, and keys you control rather than keys a CA rotates. They also mean treating the key behind your API as a long-lived secret with its own rotation plan, rather than as a by-product of renewal.

Declaring pins on iOS

Since iOS 14 and macOS 11, App Transport Security reads pins from Info.plist under NSPinnedDomains. Each entry names a domain and holds NSPinnedCAIdentities, NSPinnedLeafIdentities or both, each a list of dictionaries with one key, SPKI-SHA256-BASE64. A CA identity matches “at least one Certificate Authority (CA) or sub-CA certificate in the chain presented by the server,” which includes the root, and a leaf identity matches the server’s own certificate. One match in a list is enough, which is where the backup goes; when both lists are present, ATS requires a match in each. NSIncludesSubdomains defaults to no, and when set it covers one more label only: api.example.com but not v2.api.example.com.9

The lab’s app pins two of its hosts like this:

<key>NSAppTransportSecurity</key>
<dict>
  <key>NSPinnedDomains</key>
  <dict>
    <key>ca.pinlab.localhost</key>
    <dict>
      <key>NSPinnedCAIdentities</key>
      <array>
        <dict><key>SPKI-SHA256-BASE64</key><string>OoMk4pryqH5220TQ2FmKeRHm3ev5pXOus9f6GEmJ0lg=</string></dict>
      </array>
    </dict>
    <key>leaf.pinlab.localhost</key>
    <dict>
      <key>NSPinnedLeafIdentities</key>
      <array>
        <dict><key>SPKI-SHA256-BASE64</key><string>U5PGzZgeovRE26eMZw4xCRtJ0s5LrULkQbYioKNo8ZI=</string></dict>
        <dict><key>SPKI-SHA256-BASE64</key><string>8pIhoNh8TtXMCrNsIUGhZf6lkNkgc/VGQSXs4OQspV0=</string></dict>
      </array>
    </dict>
  </dict>
</dict>

The first value is the pin of A1, the second of K1 and the third of the backup key; your own run of the lab makes different keys and different values. ATS applied these pins to the .localhost names, which are qualified names, and only the pinned hosts refused the proxy. A refused connection reaches the app as NSURLErrorDomain error -1200, a failed secure connection, with stream error -9802, and the app cannot override it. As an Apple engineer put it on the developer forums, “If the connection is being blocked by ATS, there’s no way to proceed.”14

Apple’s article on pinning recommends pinning CA keys rather than server keys, pinning more than one key, and planning a fallback experience for the day a pin fails.1 Whether the first is wise depends on whose CA it is, as the previous section showed. The other two are not optional. Mind also what the declaration covers. In a 2021 forum thread Apple staff confirmed that URLSession honours NSPinnedDomains but could not get WKWebView to,15 so a web view that loads your API needs checking on its own, and so does any library in your app that brings its own TLS stack.

Pinning in code, and two bugs that look alike

Code is the right tool when the declaration is not enough: pins that carry an expiry date, failure reports, a networking layer shared with other platforms. The delegate below is the lab’s correct version. It lets the system evaluate the chain first and then compares the leaf’s key with the pins.

func urlSession(
  _ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
  completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
  guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
        let trust = challenge.protectionSpace.serverTrust else {
    return completionHandler(.performDefaultHandling, nil)
  }
  // The system's own checks first: chain, name, dates.
  guard SecTrustEvaluateWithError(trust, nil),
        let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first,
        let key = SecCertificateCopyKey(leaf),
        let pin = spkiPin(of: key),
        pins.contains(pin) else {
    return completionHandler(.cancelAuthenticationChallenge, nil)
  }
  completionHandler(.useCredential, URLCredential(trust: trust))
}

spkiPin is where the first bug lives. SecKeyCopyExternalRepresentation returns the bare key, for a P-256 key the 65 bytes of the point. A pin is a hash over the SubjectPublicKeyInfo structure that wraps those bytes, so the code has to put the structure’s header back before hashing. The header depends only on the key’s type and size:

let spkiHeaders: [String: [UInt8]] = [
  "EC-256": [0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01,
             0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00],
  "EC-384": [0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01,
             0x06, 0x05, 0x2B, 0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00],
  "RSA-2048": [0x30, 0x82, 0x01, 0x22, 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7,
               0x0D, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0F, 0x00],
]
 
func spkiPin(of key: SecKey) -> String? {
  guard let kind = keyKind(key), let header = spkiHeaders[kind],
        let raw = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return nil }
  return Data(SHA256.hash(data: header + [UInt8](raw))).base64EncodedString()
}

Leave the header out and the code hashes something no other tool computes. In the lab that version produced SjXCOVl4c+hrCGRMJOBelLidisx0oa4Giv+4DLWDNAo= for K1, whose real pin is U5PGzZgeovRE26eMZw4xCRtJ0s5LrULkQbYioKNo8ZI=, and refused all nine servers, the legitimate one included. This bug is loud: the app cannot reach its own server, so the first test finds it.

The second bug is quiet. A delegate that compares the pin but never calls SecTrustEvaluateWithError accepts any chain whose leaf carries a pinned key: an expired certificate, a certificate for another name, a chain that ends at no trusted root. It is the bug the guidance warns about most, and on iOS it did not show in the lab. With ATS at its defaults, that delegate still refused the expired certificate with -1200. So did a delegate that accepted every certificate without looking. On a host listed in NSPinnedDomains, even the blind delegate could not get past the pins: it was refused for the proxy, the CA switch and the rogue from the other CA, exactly as the declaration alone.

The sources disagree here. Apple’s documentation says “You cannot loosen server trust requirements for an ATS-protected domain, but you can tighten them.”16 OWASP’s testing guide says a delegate that takes the decision ends up “completely bypassing the default App Transport Security (ATS) checks.”17 On the simulator both held, for different domains. I gave a fifth host an ATS exception, NSExceptionAllowsInsecureHTTPLoads, the setting people add to reach a staging server with a self-signed certificate. On that host the delegate that skips evaluation accepted the expired certificate, and the blind delegate accepted everything, the proxy included.

On iOS the quiet bug is dormant rather than absent. It waits for an edit to Info.plist that nobody connects with the pinning code. On platforms without ATS, where the delegate’s decision is the only one, it is live from the start. Evaluate first, every time, whatever the platform happens to do for you today.

If you would rather not write this at all, TrustKit wraps the same logic for iOS. It is still maintained (version 3.0.8 shipped in October 2026), it refuses a configuration that has no backup pin, and it can report pin failures to a server you name.18

Android, briefly

Android declares pins in its Network Security Configuration, an XML file that the manifest points to:

<network-security-config>
  <domain-config>
    <domain includeSubdomains="true">api.example.com</domain>
    <pin-set expiration="2027-06-30">
      <pin digest="SHA-256">U5PGzZgeovRE26eMZw4xCRtJ0s5LrULkQbYioKNo8ZI=</pin>
      <pin digest="SHA-256">8pIhoNh8TtXMCrNsIUGhZf6lkNkgc/VGQSXs4OQspV0=</pin>
    </pin-set>
  </domain-config>
</network-security-config>

A chain is valid only if it contains at least one of the pinned keys, at any level, and the documentation says “you should always include a backup key.” The expiration attribute is the feature iOS lacks. After that date pinning is switched off for the domain, so an app that was never updated falls back to ordinary validation instead of failing; the documentation also warns that this may let attackers past the pins once the date has passed, which is the trade the attribute makes.5

User-added CAs do not get past the pins. A trust anchor can carry overridePins="true", which skips pinning for chains signed by those CAs, but its default is false everywhere except inside debug-overrides, which applies only to debuggable builds. And since apps targeting API 24 or later do not trust user-added CAs at all by default, the casual proxy root that motivates most pinning on iOS is already shut out on Android. Android 16 added Certificate Transparency checks that an app can opt into, and Android 17 turns them on by default; they are not applied to connections that use custom trust anchors.5

The platform’s stance matches Apple’s. Pinning “is not recommended for Android apps,” and if you pin anyway, the advice is “multiple backup pins, including at least one key that’s fully in your control.”2

The web tried pinning and gave it up

Browsers once let any site pin itself. HTTP Public Key Pinning, RFC 7469 from April 2015,19 had a site send a header naming its keys and a lifetime, and the browser refused any other key for that long. It was a pin set by the server instead of shipped in code, and it failed in exactly the way pins fail.

In October 2016 Smashing Magazine renewed its certificate with a new key, while its HPKP header, max-age=31536000; includeSubDomains, had told returning visitors’ browsers to accept only the old keys for a year. Between 21 and 25 October the site was unavailable to most of its visitors. Adding the new key to the header did not help, because browsers that had stored the old pins never got as far as reading the new header, and going back was impossible because the old certificate had expired. The team’s write-up is titled “Be Afraid Of HTTP Public Key Pinning (HPKP).”20

The other danger was hostile pinning: an attacker who controlled a site’s server even briefly could pin keys of their own and lock the real owner out for as long as the header said. Chrome deprecated HPKP in version 67 in 2018 and removed it in version 72 in January 2019, citing “very low adoption” and the “risks of denial of service and hostile pinning.”21 Firefox removed it in version 72 in January 2020.22 The web turned to Certificate Transparency instead, and in 2022 Chrome deprecated even the Expect-CT header, because by then it required transparency for every public site.23

An app escapes the hostile half of that story, because its pins ship in code you sign, not in a header an intruder can rewrite. It keeps the other half. The pins in an installed app last until the user updates it, and some users never do. Smashing Magazine’s outage came from a one-year max-age set by mistake. An app’s pins have a max-age of however long its oldest installed version stays in use.

Rotating without locking anyone out

The planned rotation row of the table is the procedure in miniature: the app pinned K1 and a backup key, the server moved to the backup, and every client connected. Written out, with keys you hold:

  1. Make the backup key before you need it. Generate it on a machine that is not the server, store it offline like any other signing secret, and put its pin in the app beside the current one. It appears in no certificate yet.
  2. Ship both pins, and wait. A version that pins only the current key is a version you can never rotate away from. Let the versions with both pins reach the users you mean to keep.
  3. Rotate to the backup. Get a certificate for the backup key from any CA; a leaf pin does not care which, as the CA switch row shows. Every version that carries the backup pin keeps working.
  4. Make the next backup and ship it. The new pair is the key now in service and a fresh backup. New versions no longer pin the old key, and you retire it from the server when you choose.
  5. Decide what happens to the stragglers. Versions older than step 2 carry only the old pin. Either keep a server on the old key for them, or show them an update screen served from a host their pins do not cover.

Three more habits make the plan survivable. Write the renewal settings down: with certbot that means --reuse-key for the life of the key, plus a check in your deploy that the key being served still matches a pin. Give the pins an end: on Android that is the expiration attribute, while NSPinnedDomains has no expiry, so on iOS a pin with an end date has to live in code, and a version years out of date should fall back to ordinary validation rather than to nothing. And watch both ends: report pin failures to an endpoint outside the pinned domains, as TrustKit does, and monitor Certificate Transparency for certificates issued in your names, which is what Cloudflare recommends in place of pinning.8

If you deliver pins remotely so you can change them without a release, sign the pin list with a key built into the app, and fetch it from a host that does not depend on the pins it carries. Otherwise the day a pin locks your users out is also the day they cannot fetch its replacement.

What a pin cannot do

A pin protects your users from third parties. It does not protect your app from its users. On a jailbroken or rooted device, or in a repackaged app, the pinning comes off: SSL Kill Switch 3 hooks the system’s trust evaluation functions, objection lists pinning bypass among its features, and Frida has published scripts for the purpose.24 If your server must know that it is talking to your genuine app, that is a different problem with different tools: on iOS, App Attest, “a service that you use to validate the instance of your app running on a device,”25 and on Android, Play Integrity, both checked on the server.

A pin does not help if your server itself is compromised, since the attacker then holds the pinned key. It does not help under a CA pin when the pinned CA is the one that fails, as the last row of the table shows. It covers only the connections that go through a stack that enforces it. And it costs something real: it stops the owner of the device, and the researchers who study apps, from seeing what the app sends. OWASP’s mobile standard lists pinning among its higher-assurance controls, as MASVS-NETWORK-2: “The app performs identity pinning for all remote endpoints under the developer’s control.”26 Its catalogue of weaknesses says just as plainly, “Pinning is not foolproof.”

Should your app pin?

Pin when all of these hold: the app talks to hosts you run; you hold, or will hold, the keys you pin; you can ship a backup pin and follow the rotation plan above; and the data on that connection is worth the risk of locking some users out, which is true of banking, health records and the tokens that sign people in. Do not pin when a CDN or platform chooses the key and the CA for you, which is where this site sits; when nobody will own the rotation plan; or when your only concern is a proxy root on Android, which modern apps already ignore.

If I pinned an API of my own, I would pin the server’s keys: a current key and an offline backup, with key reuse configured at renewal. I would use a CA pin only as a fallback, only for a CA I have a contract with and whose intermediates it promises to keep, and then for two such CAs, so that one of them changing is not an outage. The pin is a promise about next year’s key. Make it about a key you will still hold.

Try it yourself

Everything above came from the scripts below, run on macOS 26.5 with Xcode 26.5, OpenSSL 4.0.3 and the system’s curl 8.7.1. The keys are generated fresh on every run, so your pins will differ from mine; the pattern of the results should not. Make an empty folder and put the scripts in it.

Pins of a live site

pin.sh prints the pin of a key file, of a certificate file, or of the leaf a live server presents.

#!/bin/sh
# Prints the SPKI pin of a key file, a certificate file, or (with -c host:port)
# the leaf a live server presents: base64(sha256(DER SubjectPublicKeyInfo)).
set -eu
case "$1" in
  -c) openssl s_client -connect "$2" -servername "${2%:*}" </dev/null 2>/dev/null | openssl x509 -pubkey -noout ;;
  *.key) openssl pkey -in "$1" -pubout ;;
  *) openssl x509 -in "$1" -pubkey -noout ;;
esac | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
chmod +x pin.sh
./pin.sh -c www.roobli.org:443

Nine certificates

make-certs.sh builds the two public CAs, the inspection root, five keys and the nine server certificates in pki/. The expired certificate uses -not_before and -not_after; if your OpenSSL lacks those options, leave that line out.

The script: save it as make-certs.sh
#!/bin/sh
# Builds the pinning lab's PKI: two public CAs, an interception root, and
# server certificates for *.pinlab.localhost under eight scenarios.
set -eu
cd "$(dirname "$0")" && mkdir -p pki && cd pki
 
key() { openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$1.key" 2>/dev/null; }
 
root() { # name, subject
  key "$1"
  openssl req -x509 -new -key "$1.key" -subj "/CN=$2" -days 3650 -out "$1.pem" \
    -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
}
 
ca() { # name, subject, parent
  key "$1"
  openssl req -new -key "$1.key" -subj "/CN=$2" -out "$1.csr"
  openssl x509 -req -in "$1.csr" -CA "$3.pem" -CAkey "$3.key" -days 1825 -out "$1.pem" \
    -extfile /dev/stdin <<X
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=critical,keyCertSign,cRLSign
X
}
 
leaf() { # name, key, issuer, [extra x509 options]
  name=$1 k=$2 issuer=$3; shift 3
  openssl req -new -key "$k.key" -subj "/CN=pinlab.localhost" -out "$name.csr"
  openssl x509 -req -in "$name.csr" -CA "$issuer.pem" -CAkey "$issuer.key" "$@" -out "$name.pem" \
    -extfile /dev/stdin <<X
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=serverAuth
subjectAltName=DNS:pinlab.localhost,DNS:*.pinlab.localhost
X
}
 
root  public-a-root  "Pinlab Public Root A"
ca    public-a-ca    "Pinlab Public CA A1" public-a-root
root  public-b-root  "Pinlab Public Root B"
ca    public-b-ca    "Pinlab Public CA B1" public-b-root
root  inspect-root   "Pinlab Inspection Root"
 
key k1; key k2; key backup; key proxy; key rogue
 
leaf legit    k1     public-a-ca -days 30
leaf renewed  k1     public-a-ca -days 30
leaf rekeyed  k2     public-a-ca -days 30
leaf rotated  backup public-a-ca -days 30
leaf expired  k1     public-a-ca -not_before 20250101000000Z -not_after 20250201000000Z
leaf switched k1     public-b-ca -days 30
leaf misissue rogue  public-b-ca -days 30
leaf sameca   rogue  public-a-ca -days 30
leaf proxied  proxy  inspect-root -days 30
 
cat public-a-root.pem public-b-root.pem inspect-root.pem > trusted-roots.pem
rm -f ./*.csr ./*.srl

Nine servers

serve.sh starts one openssl s_server per scenario on the loopback address, and ./serve.sh stop ends them.

The script: save it as serve.sh
#!/bin/sh
# Starts one TLS server per scenario on 127.0.0.1. ./serve.sh stop ends them.
set -eu
cd "$(dirname "$0")/pki"
if [ "${1:-}" = stop ]; then
  [ -f ../servers.pid ] && kill $(cat ../servers.pid) 2>/dev/null; rm -f ../servers.pid; exit 0
fi
: > ../servers.pid
serve() { # port, certificate, key, [chain]
  set -- "$1" "$2" "$3" ${4:+-cert_chain "$4"}
  port=$1 cert=$2 k=$3; shift 3
  openssl s_server -quiet -www -accept "127.0.0.1:$port" -cert "$cert.pem" -key "$k.key" "$@" \
    >/dev/null 2>&1 &
  echo $! >> ../servers.pid
}
serve 8441 legit    k1     public-a-ca.pem
serve 8442 proxied  proxy
serve 8443 renewed  k1     public-a-ca.pem
serve 8444 rekeyed  k2     public-a-ca.pem
serve 8445 rotated  backup public-a-ca.pem
serve 8446 expired  k1     public-a-ca.pem
serve 8447 switched k1     public-b-ca.pem
serve 8448 misissue rogue  public-b-ca.pem
serve 8449 sameca   rogue  public-a-ca.pem
sleep 1

With curl

curl-lab.sh connects to each server twice: trusting the three roots, then also pinning K1 with the backup key as a second pin.

The script: save it as curl-lab.sh
#!/bin/sh
# Connects to each scenario twice: trusting all three roots (a device whose
# store includes an inspection root), then also pinning the leaf key K1 with
# the pre-made backup key as a second pin.
cd "$(dirname "$0")"
pins="sha256//$(./pin.sh pki/k1.key);sha256//$(./pin.sh pki/backup.key)"
printf "%-10s %-22s %s\n" scenario "trust store only" "store + pins"
for s in 8441:legit 8442:proxied 8443:renewed 8444:rekeyed 8445:rotated 8446:expired 8447:switched 8448:misissue 8449:sameca; do
  url="https://pinlab.localhost:${s%%:*}/"
  run() { curl -s -o /dev/null --cacert pki/trusted-roots.pem "$@" "$url" && echo accepted || echo "refused (curl $?)"; }
  printf "%-10s %-22s %s\n" "${s#*:}" "$(run)" "$(run --pinnedpubkey "$pins")"
done
chmod +x make-certs.sh serve.sh curl-lab.sh
./make-certs.sh && ./serve.sh && ./curl-lab.sh
scenario   trust store only       store + pins
legit      accepted               accepted
proxied    accepted               refused (curl 90)
renewed    accepted               accepted
rekeyed    accepted               refused (curl 90)
rotated    accepted               accepted
expired    refused (curl 60)      refused (curl 60)
switched   accepted               accepted
misissue   accepted               refused (curl 90)
sameca     accepted               refused (curl 90)

On an iOS simulator

The app connects to every scenario under every policy and prints one row per scenario. Besides the four ways of pinning in the table, it runs the two buggy delegates (noHeader, noEvaluation), the blind delegate on a pinned host (blind), and the last two again on a host with an ATS exception (looseNoEval, looseBlind). Save it as app/main.swift.

The app: save it as app/main.swift
// PinLab: connects to every lab scenario under every pinning policy and
// prints one row per scenario. Run it in a simulator that trusts the lab's
// three roots; ./run-ios.sh does that.
 
import CryptoKit
import Foundation
import Security
import UIKit
 
let scenarios: [(port: Int, name: String)] = [
  (8441, "legit"), (8442, "proxied"), (8443, "renewed"), (8444, "rekeyed"),
  (8445, "rotated"), (8446, "expired"), (8447, "switched"), (8448, "misissue"), (8449, "sameca"),
]
 
// The pins the code-based policies accept, read from Info.plist so the
// declarative and the code policies share one source.
let codePins = Bundle.main.object(forInfoDictionaryKey: "PinLabLeafPins") as? [String] ?? []
 
// SubjectPublicKeyInfo headers: SecKeyCopyExternalRepresentation returns the
// bare key, and the pin is a hash over the full SPKI structure.
let spkiHeaders: [String: [UInt8]] = [
  "EC-256": [0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01,
             0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00],
  "EC-384": [0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01,
             0x06, 0x05, 0x2B, 0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00],
  "RSA-2048": [0x30, 0x82, 0x01, 0x22, 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7,
               0x0D, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0F, 0x00],
]
 
func keyKind(_ key: SecKey) -> String? {
  guard let attributes = SecKeyCopyAttributes(key) as? [CFString: Any],
        let type = attributes[kSecAttrKeyType] as? String,
        let bits = attributes[kSecAttrKeySizeInBits] as? Int else { return nil }
  if type == (kSecAttrKeyTypeECSECPrimeRandom as String) { return "EC-\(bits)" }
  if type == (kSecAttrKeyTypeRSA as String) { return "RSA-\(bits)" }
  return nil
}
 
func leafKey(_ trust: SecTrust) -> SecKey? {
  guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
        let leaf = chain.first else { return nil }
  return SecCertificateCopyKey(leaf)
}
 
func pin(_ bytes: [UInt8]) -> String {
  Data(SHA256.hash(data: bytes)).base64EncodedString()
}
 
// The correct shape: the system's own evaluation first, then the pin.
func spkiPin(of key: SecKey) -> String? {
  guard let kind = keyKind(key), let header = spkiHeaders[kind],
        let raw = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return nil }
  return pin(header + [UInt8](raw))
}
 
enum Policy: String, CaseIterable {
  case plain, ca, leaf, code, noHeader, noEvaluation, blind, looseNoEval, looseBlind
 
  // Each policy gets its own host name, because Info.plist pins by domain.
  var host: String {
    switch self {
    case .plain: "plain.pinlab.localhost"
    case .ca: "ca.pinlab.localhost"
    case .leaf: "leaf.pinlab.localhost"
    case .code, .noHeader, .noEvaluation: "code.pinlab.localhost"
    case .blind: "blind.pinlab.localhost"
    case .looseNoEval, .looseBlind: "loose.pinlab.localhost"
    }
  }
}
 
final class Checker: NSObject, URLSessionDelegate {
  let policy: Policy
  var computed: String?
  init(_ policy: Policy) { self.policy = policy }
 
  func urlSession(
    _ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
  ) {
    guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
          let trust = challenge.protectionSpace.serverTrust else {
      return completionHandler(.performDefaultHandling, nil)
    }
    let accept = { completionHandler(.useCredential, URLCredential(trust: trust)) }
    let refuse = { completionHandler(.cancelAuthenticationChallenge, nil) }
 
    switch policy {
    case .plain, .ca, .leaf:
      completionHandler(.performDefaultHandling, nil)
 
    case .code:
      guard SecTrustEvaluateWithError(trust, nil), let key = leafKey(trust),
            let pin = spkiPin(of: key) else { return refuse() }
      computed = pin
      codePins.contains(pin) ? accept() : refuse()
 
    case .noHeader:
      // Bug: hashes the bare key, so no pin computed with openssl ever matches.
      guard SecTrustEvaluateWithError(trust, nil), let key = leafKey(trust),
            let raw = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return refuse() }
      let pin = pin([UInt8](raw))
      computed = pin
      codePins.contains(pin) ? accept() : refuse()
 
    case .noEvaluation, .looseNoEval:
      // Bug: compares the pin but never asks whether the chain is valid.
      guard let key = leafKey(trust), let pin = spkiPin(of: key) else { return refuse() }
      computed = pin
      codePins.contains(pin) ? accept() : refuse()
 
    case .blind, .looseBlind:
      // Accepts anything, to see whether Info.plist pins still apply.
      accept()
    }
  }
}
 
func attempt(_ policy: Policy, _ port: Int) async -> String {
  let checker = Checker(policy)
  let configuration = URLSessionConfiguration.ephemeral
  configuration.timeoutIntervalForRequest = 5
  let session = URLSession(configuration: configuration, delegate: checker, delegateQueue: nil)
  defer { session.invalidateAndCancel() }
  do {
    let url = URL(string: "https://\(policy.host):\(port)/")!
    let (_, response) = try await session.data(from: url)
    return (response as? HTTPURLResponse)?.statusCode == 200 ? "ok" : "odd"
  } catch let error as NSError {
    let stream = error.userInfo["_kCFStreamErrorCodeKey"].map { "/\($0)" } ?? ""
    return "\(error.code)\(stream)"
  }
}
 
func say(_ line: String) {
  FileHandle.standardOutput.write(Data((line + "\n").utf8))
}
 
enum Lab {
  static func run() async {
    let policies = Policy.allCases
    say("scenario  " + policies.map { $0.rawValue.padding(toLength: 13, withPad: " ", startingAt: 0) }.joined())
    for (port, name) in scenarios {
      var row = name.padding(toLength: 10, withPad: " ", startingAt: 0)
      for policy in policies {
        row += (await attempt(policy, port)).padding(toLength: 13, withPad: " ", startingAt: 0)
      }
      say(row)
    }
    // What the bare-key bug computes for the legitimate server, beside the real pin.
    let probe = Checker(.noHeader)
    let session = URLSession(configuration: .ephemeral, delegate: probe, delegateQueue: nil)
    _ = try? await session.data(from: URL(string: "https://code.pinlab.localhost:8441/")!)
    say("bare-key hash of K1: \(probe.computed ?? "none")")
    let good = Checker(.code)
    let session2 = URLSession(configuration: .ephemeral, delegate: good, delegateQueue: nil)
    _ = try? await session2.data(from: URL(string: "https://code.pinlab.localhost:8441/")!)
    say("SPKI hash of K1:     \(good.computed ?? "none")")
  }
}
 
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
  ) -> Bool {
    Task.detached {
      await Lab.run()
      exit(0)
    }
    return true
  }
}

run-ios.sh writes the Info.plist from the lab’s real pins, compiles the app for the simulator without an Xcode project, creates a throwaway iPhone simulator, adds the three roots to that simulator’s trust store, runs the app and deletes the simulator again. It does not touch your Mac’s own trust settings or your other simulators.

The script: save it as run-ios.sh
#!/bin/sh
# Builds PinLab for the iOS Simulator, runs it on a throwaway device that
# trusts the lab's three roots, prints its table and deletes the device.
set -eu
cd "$(dirname "$0")"
CA=$(./pin.sh pki/public-a-ca.pem); K1=$(./pin.sh pki/k1.key); BACKUP=$(./pin.sh pki/backup.key)
identity() { printf '<dict><key>SPKI-SHA256-BASE64</key><string>%s</string></dict>' "$1"; }
 
rm -rf build && mkdir -p build/PinLab.app
cat > build/PinLab.app/Info.plist <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>CFBundleIdentifier</key><string>org.example.pinlab</string>
  <key>CFBundleExecutable</key><string>PinLab</string>
  <key>CFBundleName</key><string>PinLab</string>
  <key>CFBundlePackageType</key><string>APPL</string>
  <key>CFBundleVersion</key><string>1</string>
  <key>CFBundleShortVersionString</key><string>1.0</string>
  <key>CFBundleSupportedPlatforms</key><array><string>iPhoneSimulator</string></array>
  <key>MinimumOSVersion</key><string>18.0</string>
  <key>UIDeviceFamily</key><array><integer>1</integer></array>
  <key>LSRequiresIPhoneOS</key><true/>
  <key>PinLabLeafPins</key><array><string>$K1</string><string>$BACKUP</string></array>
  <key>NSAppTransportSecurity</key><dict>
    <key>NSPinnedDomains</key><dict>
      <key>ca.pinlab.localhost</key><dict>
        <key>NSPinnedCAIdentities</key><array>$(identity "$CA")</array></dict>
      <key>leaf.pinlab.localhost</key><dict>
        <key>NSPinnedLeafIdentities</key><array>$(identity "$K1")$(identity "$BACKUP")</array></dict>
      <key>blind.pinlab.localhost</key><dict>
        <key>NSPinnedCAIdentities</key><array>$(identity "$CA")</array></dict>
    </dict>
    <key>NSExceptionDomains</key><dict>
      <key>loose.pinlab.localhost</key><dict>
        <key>NSExceptionAllowsInsecureHTTPLoads</key><true/></dict>
    </dict>
  </dict>
</dict></plist>
PLIST
plutil -lint build/PinLab.app/Info.plist >/dev/null
 
xcrun --sdk iphonesimulator swiftc -parse-as-library -O \
  -target arm64-apple-ios18.0-simulator app/main.swift -o build/PinLab.app/PinLab
codesign --force --sign - build/PinLab.app 2>/dev/null
 
RUNTIME=$(xcrun simctl list runtimes | awk '/iOS/ {id=$NF} END {print id}')
DEVICE=$(xcrun simctl create PinLab "iPhone 17 Pro" "$RUNTIME")
trap 'xcrun simctl shutdown "$DEVICE" >/dev/null 2>&1; xcrun simctl delete "$DEVICE"' EXIT
xcrun simctl boot "$DEVICE"
for root in public-a-root public-b-root inspect-root; do
  xcrun simctl keychain "$DEVICE" add-root-cert "pki/$root.pem"
done
xcrun simctl install "$DEVICE" build/PinLab.app
xcrun simctl launch --console-pty "$DEVICE" org.example.pinlab 2>/dev/null | tr -d '\r' | grep -E '^(scenario|legit|proxied|renewed|rekeyed|rotated|expired|switched|misissue|sameca|bare-key|SPKI) '
chmod +x run-ios.sh
./serve.sh && ./run-ios.sh; ./serve.sh stop
scenario  plain        ca           leaf         code         noHeader     noEvaluation blind        looseNoEval  looseBlind   
legit     ok           ok           ok           ok           -999         ok           ok           ok           ok           
proxied   ok           -1200/-9802  -1200/-9802  -999         -999         -999         -1200/-9802  -999         ok           
renewed   ok           ok           ok           ok           -999         ok           ok           ok           ok           
rekeyed   ok           ok           -1200/-9802  -999         -999         -999         ok           -999         ok           
rotated   ok           ok           ok           ok           -999         ok           ok           ok           ok           
expired   -1200/-9802  -1200/-9802  -1200/-9802  -999         -999         -1200/-9802  -1200/-9802  ok           ok           
switched  ok           -1200/-9802  ok           ok           -999         ok           -1200/-9802  ok           ok           
misissue  ok           -1200/-9802  -1200/-9802  -999         -999         -999         -1200/-9802  -999         ok           
sameca    ok           ok           -1200/-9802  -999         -999         -999         ok           -999         ok           
bare-key hash of K1: SjXCOVl4c+hrCGRMJOBelLidisx0oa4Giv+4DLWDNAo=
SPKI hash of K1:     U5PGzZgeovRE26eMZw4xCRtJ0s5LrULkQbYioKNo8ZI=

ok means the request completed. -1200/-9802 is ATS refusing the connection, and -999 is the delegate cancelling the challenge. The first boot of a new simulator takes about a minute.

Six questions to check yourself

Each answer opens with a click.

1. Why is a pin a hash of the public key rather than of the certificate?

Because the certificate changes at every renewal, even when the key does not. A hash of the certificate would break on the routine renewal row of the table; a hash of the SubjectPublicKeyInfo survives it.

2. The app pins the key of its issuing CA. Which certificate in the table does it accept that it should not?

The rogue certificate from the pinned CA itself. It chains through the pinned key, so the pin is satisfied. Only a pin on the server’s own key refuses it.

3. A leaf pin survived a move to another CA. Why?

The server kept its key, K1, and asked the other CA to certify it. A leaf pin names the key, not the issuer.

4. Why is skipping SecTrustEvaluateWithError dangerous even though the lab's app refused the expired certificate?

Because ATS did the refusing, not the code. Add an ATS exception for the domain, as the lab’s loose host does, and the same code accepts the expired certificate. On a platform without ATS it does so from the start.

5. What does the backup pin cost, and what does it buy?

It costs one key generated early and kept offline. It buys a rotation that every installed version survives, as the planned rotation row shows.

6. Why did HPKP's failure not argue against pinning in apps altogether?

HPKP let a server set pins, so an intruder could pin keys of their own; an app’s pins ship in signed code. The lockout risk remains, because old versions keep old pins, which is what backup pins and pin expiry are for.

Footnotes

  1. Apple, “Identity Pinning: How to configure server certificates for your app”, 14 January 2021. ↩ ↩2

  2. Android Developers, “Security with network protocols”. ↩ ↩2

  3. The forged certificates, the 300,000 targeted Gmail users named in Fox-IT’s investigation, and DigiNotar’s bankruptcy on 20 September 2011 are summarised at Wikipedia: DigiNotar; Google’s account is on the Google security blog. ↩

  4. Apple Support, “Trust manually installed certificate profiles in iOS, iPadOS, and visionOS”. ↩

  5. Android Developers, “Network security configuration”. ↩ ↩2 ↩3

  6. Chrome’s announcement on the ct-policy list; Apple’s policy on Apple Support. ↩

  7. Cloudflare, “Certificate authorities”. ↩

  8. Cloudflare, “Certificate pinning”. ↩ ↩2

  9. Apple, NSPinnedDomains. ↩ ↩2

  10. CA/Browser Forum, Ballot SC-081v3, April 2025. ↩

  11. Let’s Encrypt, “Decreasing Certificate Lifetimes to 45 Days”, and Profiles. ↩

  12. Certbot user guide. ↩

  13. Let’s Encrypt, the Gen Y hierarchy, 24 November 2025; current chains at Chains of Trust. ↩

  14. Apple Developer Forums, thread 731368, June 2023. ↩

  15. Apple Developer Forums, thread 681734, June 2021. ↩

  16. Apple, “Performing manual server trust authentication”. ↩

  17. OWASP MASTG, MASTG-TEST-0396. ↩

  18. TrustKit. ↩

  19. RFC 7469, “Public Key Pinning Extension for HTTP”, April 2015. ↩

  20. Mathias Biilmann Christensen, “Be Afraid Of HTTP Public Key Pinning (HPKP)”, Smashing Magazine, 26 October 2016. ↩

  21. Chrome 67 deprecations and removals; the removal. ↩

  22. Firefox 72 for developers. ↩

  23. Chrome Platform Status: Expect-CT. ↩

  24. SSL Kill Switch 3; objection; Frida scripts. ↩

  25. Apple, DCAppAttestService. ↩

  26. OWASP, MASVS-NETWORK-2; MASWE-0028. ↩