Short answer. Browsers abandoned header-based pinning (HPKP): Chrome removed it in version 72 (January 2019) and Firefox in version 72 (January 2020), and Certificate Transparency now covers most of what it was meant to catch. Pinning survives in mobile apps and API clients that talk to servers you control. If you pin, pin the SHA-256 hash of a public key (SPKI), not a certificate; pin keys of CAs you will keep using (an intermediate or root) rather than your leaf key, which most ACME clients replace at every renewal; always ship at least one backup pin from a different CA or an offline key; and use the platform mechanism: Android's Network Security Configuration, with an expiration, or iOS's NSPinnedDomains. Both Google and Apple advise against pinning unless you have a concrete reason.
What a pin is
Ordinary certificate validation accepts any chain to any root in the trust store. A pin adds one more condition: at least one key in the validated chain must be on your list. The list holds hashes of the SubjectPublicKeyInfo (SPKI): the algorithm identifier and public key, DER-encoded. Hashing the key rather than the certificate means a renewed certificate with the same key keeps matching, and a pin on a CA key matches every certificate that CA issues.
Pins are computed from certificates with OpenSSL. For the chain letsencrypt.org served on 20 October 2026 (eclipse-temurin:25.0.4.1_1-jdk-noble, OpenSSL 3.0.13):
openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org -showcerts </dev/null 2>/dev/null \
| awk '/-----BEGIN CERTIFICATE-----/{n++; f=sprintf("c%d.pem", n); p=1} p{print >> f} /-----END CERTIFICATE-----/{p=0; close(f)}'
pin() { openssl x509 -in "$1" -noout -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64; }
for f in c*.pem; do printf '%s %s\n' "$(pin "$f")" "$(openssl x509 -in "$f" -noout -subject | sed 's/^subject=//')"; done
x8ltqh+zNPYG7RZS+IjFymndoy6YAWLPaSkptqebzfc= CN = letsencrypt.org
s/tdAOmUzd8syaTuqfgGvFcn6DzA5Cmb+Vby1ST+U3Y= C = US, O = Let's Encrypt, CN = YE2
sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w= C = US, O = ISRG, CN = Root YE
diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI= C = US, O = Internet Security Research Group, CN = ISRG Root X2
The four candidates behave very differently over time, and choosing among them is most of the work. The SPKI pin generator computes the same values in the browser and prints configuration snippets.
Why browsers gave up: HPKP
HTTP Public Key Pinning (RFC 7469, 2015) let a site send a Public-Key-Pins header with key hashes and a max-age; the browser then refused any chain without one of those keys for that long. Two failure modes killed it:
- Self-inflicted outages. A lost key, a changed CA or a typo locked every returning visitor out until
max-ageran out, with no way to undo it from the server. - Hostile pinning. An attacker who briefly controlled a site (or its certificate issuance) could send pins for keys only the attacker held, turning a short compromise into a long denial of service.
Chrome's removal entry cites "very low adoption" and the "risks of denial of service and hostile pinning" (chromestatus); support was removed in Chrome 72 on desktop and Android, and Firefox followed in version 72. Browsers still ship a small built-in pin list for their vendors' own domains, but no site can add itself.
What replaced it is detection rather than prevention. Since 2018, Chrome and Apple platforms accept publicly trusted TLS certificates only with Signed Certificate Timestamps from Certificate Transparency logs, so every certificate for your name is public and can be monitored; CAA records tell CAs which of them may issue for your domain. Together they make the misissuance that HPKP targeted visible within hours, without the lockout risk.
What to pin
| Pin | Survives | Breaks when |
|---|---|---|
| Leaf key | Renewals that keep the key | Every renewal with a new key, which is the default in certbot |
| Issuing intermediate (YE2) | Leaf renewals and key changes | The CA rotates intermediates; Let's Encrypt has several in rotation and backups it "may begin issuing … from at any time, without warning" |
| Root (ISRG Root X2) | Intermediate rotation | You change CA, or the CA is distrusted |
| Key you hold offline | Anything, while it stays unused | Only if you lose it |
The leaf row deserves emphasis because it is the default trap. certbot --help all (v5.8.0) describes --reuse-key as off by default and says "Not reusing private keys is the default behavior of Certbot". A leaf pin therefore breaks at the next renewal, and renewals keep getting closer together: the CA/Browser Forum caps TLS certificates at 200 days since March 2026, 100 days from March 2027 and 47 days from March 2029, and Let's Encrypt's default moves to 64 days in February 2027.
The intermediate row is the subtle one. letsencrypt.org was issued from YE2, but Let's Encrypt lists YE1 and YE2 (ECDSA) and YR1 and YR2 (RSA) in active rotation, with YE3 and YR3 as backups (chains of trust). A pin set with only YE2 fails on the next certificate from YE1.
A root pin survives all of that and fails only when you switch CA. That happens more often than expected: when Chrome distrusted Entrust-issued certificates whose earliest SCT was after 11 November 2024 (Google Security Blog), every Entrust customer had to move to another CA within months. An app pinned to Entrust roots without a backup would have needed an update first.
The usual compromise: pin the root (or both roots) of your current CA, plus the root of a second CA you could switch to, plus optionally a key you generated and keep offline for an emergency certificate. With Let's Encrypt as the current CA and Google Trust Services as the fallback, from Debian's ca-certificates 20260816:
C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M= subject=C=US, O=Internet Security Research Group, CN=ISRG Root X1 notAfter=Jun 4 11:04:38 2035 GMT
diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI= subject=C=US, O=Internet Security Research Group, CN=ISRG Root X2 notAfter=Sep 17 16:00:00 2040 GMT
mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c= subject=C=US, O=Google Trust Services LLC, CN=GTS Root R4 notAfter=Jun 22 00:00:00 2036 GMT
hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc= subject=C=US, O=Google Trust Services LLC, CN=GTS Root R1 notAfter=Jun 22 00:00:00 2036 GMT
The ISRG Root X2 pin matches the last certificate letsencrypt.org sends (X2 cross-signed by X1) because it is the same key as the self-signed X2 in trust stores. Pinning a root of another CA you do not use yet costs nothing and is your exit.
Android: Network Security Configuration
Android 7.0 and later read pins from res/xml/network_security_config.xml, referenced by android:networkSecurityConfig in the manifest (documentation):
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-10-01">
<!-- ISRG Root X2 and X1: the current CA, ECDSA and RSA chains -->
<pin digest="SHA-256">diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</pin>
<pin digest="SHA-256">C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=</pin>
<!-- GTS Root R4 and R1: the CA we would switch to -->
<pin digest="SHA-256">mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=</pin>
<pin digest="SHA-256">hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=</pin>
</pin-set>
</domain-config>
</network-security-config>
The rules that matter, from the documentation:
- A chain is accepted when it "contains at least one of the pinned public keys"; the only digest is
SHA-256. - After
expiration(yyyy-MM-dd), pinning is no longer performed. It keeps old installs working after you have forgotten them; the documentation also warns that it "may enable attackers to bypass your pinned certificates" on devices with stale apps. Pick a date about one release cycle after the date you will next update the pins. - Trust anchors in
<debug-overrides>bypass pins when the app is debuggable, which is how you inspect traffic in development;overridePins="true"on a<certificates>element does the same in release builds and should not ship. - Android 16 added Certificate Transparency checks to the same file (
<certificateTransparency enabled="true"/>), and from Android 17 they are on by default for apps that target it, except for connections that use custom trust anchors. For many apps that is the protection they wanted from pinning.
Google's own guidance is blunt: pinning "is not recommended for Android apps", and if you do it, include "multiple backup pins, including at least one key that's fully in your control, and a sufficiently short expiration period" (security with network protocols).
OkHttp
OkHttp's CertificatePinner works on Android and on the JVM, where Network Security Configuration does not exist. The test pins letsencrypt.org to ISRG Root X2 with X1 as a backup, then to a key no CA has certified, which is what an app sees after the server moves to an unpinned CA:
// OkHttp CertificatePinner against letsencrypt.org: a correct CA-key pin, a backup pin, and a wrong pin.
import java.io.IOException;
import okhttp3.CertificatePinner;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
public class Pin {
static void get(String label, CertificatePinner pinner) {
OkHttpClient client = new OkHttpClient.Builder().certificatePinner(pinner).build();
Request request = new Request.Builder().url("https://letsencrypt.org/robots.txt").build();
try (Response response = client.newCall(request).execute()) {
System.out.println(label + ": HTTP " + response.code());
} catch (IOException e) {
System.out.println(label + ": " + e.getClass().getName() + ": " + e.getMessage());
}
}
public static void main(String[] args) {
// ISRG Root X2 key, plus a backup pin for ISRG Root X1 (RSA): either satisfies the pin set.
get("pinned to ISRG Root X2 (+ X1 backup)", new CertificatePinner.Builder()
.add("letsencrypt.org", "sha256/" + args[0])
.add("letsencrypt.org", "sha256/" + args[1])
.build());
// A key that is not in the chain: what a client sees after the site moves to another CA.
get("pinned to an unrelated key", new CertificatePinner.Builder()
.add("letsencrypt.org", "sha256/" + args[2])
.build());
}
}
pinned to ISRG Root X2 (+ X1 backup): HTTP 200
pinned to an unrelated key: javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
Peer certificate chain:
sha256/x8ltqh+zNPYG7RZS+IjFymndoy6YAWLPaSkptqebzfc=: CN=letsencrypt.org
sha256/s/tdAOmUzd8syaTuqfgGvFcn6DzA5Cmb+Vby1ST+U3Y=: CN=YE2, O=Let's Encrypt, C=US
sha256/sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=: CN=Root YE, O=ISRG, C=US
sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=: CN=ISRG Root X2, O=Internet Security Research Group, C=US
Pinned certificates for letsencrypt.org:
sha256/VIJvW/e8yv/LvjcOADWb4C9aNclxEiQeW0tr4SvazsA=
The failure message lists the pins of the chain OkHttp validated, ending at the trust anchor, and OkHttp's own documentation suggests exactly this as the setup method: start with a broken pin on a trusted network and copy the expected values from the exception. The pins match the OpenSSL output above. The same comment carries a warning worth repeating to whoever approves the change: "Do not use certificate pinning without the blessing of your server's TLS administrator!" CertificatePinner also runs only after normal validation, so it cannot make a self-signed certificate acceptable.
One debugging trap we hit: the container had no IPv6 route, and without -Djava.net.preferIPv4Stack=true the second call reported java.net.SocketException: Network is unreachable. OkHttp 5 tries addresses in parallel ("fast fallback"), and the pinning failure on the IPv4 address ended up as a suppressed exception behind the IPv6 connect error; with fastFallback(false) the pinning failure was the primary exception. When pinning breaks in production, log getSuppressed() too.
iOS and macOS: NSPinnedDomains
Since iOS 14 and macOS 11, App Transport Security pins keys declared in Info.plist. Apple's article "Identity Pinning" describes two lists per domain: NSPinnedCAIdentities (the key must appear in an intermediate or root of the chain) and NSPinnedLeafIdentities (the server's own key), each entry a SPKI-SHA256-BASE64 value computed with the same OpenSSL pipeline:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>letsencrypt.org</key>
<dict>
<key>NSPinnedCAIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</string>
</dict>
</array>
</dict>
</dict>
</dict>
We checked the behavior on this machine (macOS 27.0.1, Apple M4 Max, Swift 6.4): a command-line tool that fetches https://letsencrypt.org/robots.txt with URLSession, built three times with swiftc main.swift -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist: without pins, with the X2 pin above, and with the unrelated key from the OkHttp test:
HTTP 200
HTTP 200
error: NSURLErrorDomain -1200: A TLS error caused the secure connection to fail.
The app receives the generic NSURLErrorSecureConnectionFailed (-1200), not a pinning-specific code, so record which pin set each app version shipped with; without that, a pin mismatch after a server change looks like any other TLS failure in crash reports. NSIncludesSubdomains extends a pin to one level of subdomains only (math.example.org, not advanced.math.example.org). Apple's advice matches Google's: "In most cases, pinning is not necessary and should be avoided", and if you pin, pin CA keys and more than one.
Rotating pins without locking anyone out
A pin set is a promise about the future, and the app that holds it may not update for a year. A rotation that works:
- Ship the new pin before you need it. Add the next CA's root or the next key to the pin set in a release, and wait until the versions without it are a share of users you can afford to break.
- Only then change the server. Switch CA or key; old pins still match on old app versions as long as they contain any key from the new chain.
- Remove retired pins in a later release.
- Keep an emergency path. An offline key that is pinned but never deployed lets you get a certificate for it from any CA in an emergency. Some apps also fetch pin updates from a signed remote configuration; that file then needs its own signing key, kept outside the TLS infrastructure.
- Set expirations on Android and treat them as a deadline for the next rotation, not as a formality.
- Test failure. Run the app against a proxy with a certificate from your trust store but outside the pin set, and confirm it fails closed and says something useful.
Corporate TLS inspection proxies break pinned apps by design: their certificates chain to an enterprise root that is not pinned. On Android, user-installed CAs are not trusted by apps targeting API level 24 or later unless the configuration adds them, and pins still apply to them unless overridePins is set.
Common mistakes
- Pinning only the leaf with an ACME client that rotates keys. The next renewal is an outage.
- Pinning one intermediate of a CA that rotates several. It works until the CA picks another one.
- No backup pin, or a backup from the same CA. When that CA is distrusted, both pins fail together.
- Pinning a certificate hash instead of the SPKI. Every renewal breaks it, even with the same key.
- Hand-written trust managers. Replacing the platform's validation to "add pinning" often removes hostname or chain checks by accident. Use Network Security Configuration,
CertificatePinnerorNSPinnedDomains, which pin on top of normal validation. - Expecting pins to protect against a compromised device. On a rooted or jailbroken device, the user (or malware) can patch pinning out. Pinning protects users from the network, not your API from your users.
Sources
- RFC 7469: Public Key Pinning Extension for HTTP and Chrome's removal entry
- Android: Network security configuration (pins, expiration, overrides, Certificate Transparency) and Security with network protocols
- Apple: Identity Pinning: How to configure server certificates for your app and Certificate Transparency policy
- OkHttp
CertificatePinnersource and documentation comment - Let's Encrypt: Chains of trust and Decreasing certificate lifetimes to 45 days
- CA/Browser Forum Baseline Requirements 2.3.1, §6.3.2 (validity periods)
- Google Security Blog: Entrust certificate distrust (June 2024)