Security / transport security / certificate validation
Certificate Pinning Runs After Validation, or Not at All
That a pin is a certificate the client trusts, so pinning replaces certificate validation and makes interception impossible. Both halves are wrong. Pinning is an additional intersection test against the chain the platform already built and accepted: OkHttp's CertificatePinner.check() runs on the cleaned chain and returns immediately, doing nothing, if no configured pattern matches the hostname; Chromium ships enable_pkp_bypass_for_local_trust_anchors_ = true, so a pin violation on a chain that ends at a locally-installed root returns PKPStatus::BYPASSED rather than VIOLATED; and Android's <pin-set expiration="..."> stops enforcing pins on the date it names, silently. The other half of the misconception is what is hashed: RFC 7469 §2.4 pins the DER-encoded SubjectPublicKeyInfo, not the certificate, explicitly "to enable operators to generate new certificates containing old public keys" — so the renewal that breaks a pinned client is the one where the key changed, and a certificate fingerprint pasted in as a pin matches nothing on the first connection.
Pinning does not decide whether a certificate is acceptable. It decides whether an already-acceptable certificate is yours. The client builds a chain and validates it exactly as it always did — dates, signatures, a path to a trusted root, the hostname — and only if all of that passes does it ask one more question: is one of the public keys I was compiled with somewhere in that chain? A pin is a filter on a set that has already been accepted, which is why it cannot save a connection its trust store rejected, and why every shipping implementation has documented conditions under which it silently asks nothing at all.
Four terms before the panel. A SubjectPublicKeyInfo — SPKI — is the field inside an X.509 certificate that holds the public key together with its algorithm identifier. It is the part of the certificate that stays the same when the certificate around it is reissued. DER is the binary encoding those fields are serialised in, and it is what gets hashed, so the same key always produces the same bytes. A certificate signing request — CSR — is the file you send a certificate authority containing that public key and the names you want on the certificate; generating a new one with a fresh key is what the panel calls a fresh key pair. And a pin is the SHA-256 hash of the SPKI, base64-encoded. Not a hash of the certificate. RFC 7469 §2.4 says why in one sentence: "We pin public keys, rather than entire certificates, to enable operators to generate new certificates containing old public keys."
Below is a client with a pin set compiled into it, connecting to
api.example.com. It starts in the state a working deployment is
in: the leaf key is pinned, nothing on the server has changed yet, and the
real server is answering. The verdict reads SERVER REACHED, the pin
check reads MATCH, and the client compared 3 hashes vs 1 pin.
Now change what happened on the server to renewed — fresh key
pair and watch the same client lock itself out of its own API.
One pin checker, with each documented skip rule switchable and labelled
with the implementation it comes from — no single product ships all of
them. The pattern semantics are OkHttp's
CertificatePinner.Pin.matchesHostname. The local-anchor
bypass is Chromium's CheckPins, whose
enable_pkp_bypass_for_local_trust_anchors_ defaults to
true. The expiration switch is Android's
<pin-set expiration>. Fingerprints below are generated
from the key names for the panel; they are illustrative, not real keys.
Each row is one certificate in the chain the client's trust manager built, and each pin below it is one SHA-256 hash the client will look for. this key is in the pin set · pinned and no longer present · in the chain but not pinned, which is the normal state of most of a chain.
Two things to notice on that excursion. The connection did not fail because anything was wrong with the new certificate — it is signed by the same certificate authority, in date, for the right name, and every browser on earth accepts it. It failed because the client is holding a hash of a key that no longer exists, and it will keep failing until the app is redeployed. And the reader is now looking at the difference between the two renewal options: the same key pair, CSR reused still reads MATCH. The certificate changed and the pin did not care, because the pin was never about the certificate.
What is actually being compared
Forty lines of OkHttp, the most widely deployed pin checker in existence,
are the whole mechanism. CertificatePinner.check() takes a
hostname and a function that produces the cleaned peer certificate
chain, and its own documentation comment states the contract: "Confirms that
at least one of the certificates pinned for hostname is in
peerCertificates. Does nothing if there are no certificates
pinned for hostname. OkHttp calls this after a successful TLS
handshake, but before the connection is used."
Three facts are packed into that sentence. It runs after a successful handshake, so it can only remove chains, never add them. It compares against the cleaned chain — the path the trust manager built and verified, not the bag of certificates the server sent — which is the same rule RFC 7469 §2.6 states as "The UA MUST ignore superfluous certificates in the chain that do not form part of the validating chain." And "does nothing" is a literal description of a code path, not a figure of speech.
What it hashes is one line at the bottom of the same file:
fun X509Certificate.sha256Hash(): ByteString = publicKey.encoded.toByteString().sha256()
publicKey.encoded is the DER-encoded SubjectPublicKeyInfo. So
the value being compared is a hash over the key, and the certificate's
serial number, validity dates, extensions and issuer signature are all
outside it. Put what happened on the server back to nothing yet
— the last section left it on a renewal — and set the pin to the leaf
certificate's fingerprint, which is what
openssl x509 -fingerprint -sha256 prints. The pin check reads
NO MATCH on the very first connection, with nothing renewed and the
real server answering, because a hash of the whole certificate is being
compared against a set of hashes of public keys. This is the single most
common way a first pinning deployment fails, and the error message says
"Certificate pinning failure!" which points at the certificate and hides the
actual cause. Set the pin back to the leaf's SPKI — the key in your
CSR before continuing, and leave the server event on nothing yet;
every figure below assumes both.
RFC 7469 Appendix A gives the non-normative program that produces the right value, and it is worth reading as a definition rather than as a recipe:
openssl x509 -noout -in certificate.pem -pubkey | \
openssl asn1parse -noout -inform pem -out public.key
openssl dgst -sha256 -binary public.key | openssl enc -base64
Extract the public key. Re-encode it as DER. Hash that. Base64 the hash. Nothing in there touches the certificate as a whole, which is the entire reason the same key pair, CSR reused renewal survives a pin and the fresh key pair renewal does not.
A pin cannot rescue a chain, and mostly it is not a chain's problem
Set who is answering to an interception proxy whose root is not installed, leaving the pin on the leaf SPKI. The hero readout is HANDSHAKE REFUSED and the pin check reads NOT REACHED. Nothing about pinning was involved. The proxy was stopped by ordinary path validation, which found no signature path from the certificate it presented to any root in the trust store — the check that has been running since long before anyone pinned anything.
Now the case a pin is actually for. Switch who is answering to a certificate for your name, mis-issued by another public CA. Every check in the previous lesson passes: real certificate authority, real signature chain, in date, correct hostname. A browser with no pin connects. The pin check reads NO MATCH over 3 hashes vs 1 pin and the verdict is INTERCEPTOR BLOCKED. This is the entire value proposition, stated narrowly: pinning converts "any of the hundreds of certificate authorities in the store may speak for my name" into "one key may". Nothing else in this panel does that.
An interception proxy is only interesting once its root is in the
trust store, because that is the state in which normal validation says yes.
Switch to an interception proxy whose root is installed on the
device. The chain the client built is now 2 certificates long,
neither key is one you pinned, and the pin check reads BYPASSED
rather than the NO MATCH the mis-issued row gave: Chromium's
CheckPins reaches this branch verbatim —
if (!is_issued_by_known_root && enable_pkp_bypass_for_local_trust_anchors_) {
return PKPStatus::BYPASSED;
}
— and transport_security_state.h declares that member as
bool enable_pkp_bypass_for_local_trust_anchors_ = true;. The
verdict is INTERCEPTOR READS IT, with pinning configured, enabled,
and correct. Untick the Chromium bypass and the same row becomes
INTERCEPTOR BLOCKED — the same verdict the mis-issued row reaches on
a default configuration. That is the difference the flag makes: against a
public CA the pin fires, and against the device's own administrator it
fires only if you turn off a default that Chromium ships on and advises
against changing. Tick the bypass again before moving on.
The rationale is not carelessness. A locally-installed root is, definitionally, something an administrator or the device's owner put there, and a browser that refused those connections would break every corporate proxy and every debugging tool by fiat. Chromium's own header comment on the flag reads "Disabling the bypass for local trust anchors is highly discouraged." The consequence, though, is exact: pinning in a browser is a defence against a public certificate authority issuing a certificate it should not have, and it is not a defence against anyone who can add a root to the machine.
There is one common tool that does not work this way, and it is worth
knowing because it inverts the rule. curl's --pinnedpubkey
documentation states: "This option is independent of option --insecure. If
you use both options together then the peer is still verified by public
key." curl also extracts the key from the server's own certificate rather
than searching a chain — "A public key is extracted from this certificate
and if it does not exactly match the public key provided to this option,
curl aborts the connection". So in curl a pin is a standalone check on the
leaf. In OkHttp, Android and Chromium it is an additional check on a chain.
Reading advice written for one and applying it to the other is how people
conclude that pinning replaces validation.
The four ways it quietly does not run
Each of these has the same signature in production: everything works, no error is logged, and the security property you believe you have is absent. Reset the panel to its opening state first — pin on the leaf's SPKI, nothing yet, your server, pattern api.example.com, host api.example.com, Chromium bypass ticked, expiration unticked — and then take them one at a time.
The pattern does not match the host. Leave the pattern on
api.example.com and change the host to
eu.api.example.com. The pin check reads NOT RUN and the
comparisons readout drops to 0 hashes vs 0 pins, because
findMatchingPins returned an empty list and
if (pins.isEmpty()) return ended the function. OkHttp's own
documentation is blunt about the wildcard forms: a single asterisk like
*.publicobject.com matches "exactly one prefix", and "Be
careful with this approach as no pinning will be enforced if additional
prefixes are present, or if no prefixes are present." Switch the pattern to
*.example.com with the host still on
eu.api.example.com and it is still NOT RUN — two labels
is not one. Only **.example.com covers it. Set the host back to
api.example.com and the pattern back to
api.example.com before the next paragraph.
The pins expired. Tick the Android expiration switch. The pin check reads NOT RUN again, and this one is by design: the platform documentation defines the attribute as "The date, in yyyy-MM-dd format, on which the pins expire, thus disabling pinning", and explains the trade in the next breath — "Expiration helps prevent connectivity issues in apps which do not get updates to their pin set, such as when the user disables app updates. However, setting an expiration time on pins may enable attackers to bypass your pinned certificates." An app on an old version in the store is not failing; it is unprotected, and there is no signal anywhere that says so. Untick it before continuing.
The chain ends at a local root. The previous section. Default on in Chromium.
Debug builds are exempt. Android's network security configuration
documents debug-overrides as applying whenever
android:debuggable is true, and states the consequence
directly: "Trust anchors specified in debug-overrides are added
to all other configurations, and certificate pinning is not performed when
the server's certificate chain uses one of these debug-only trust anchors."
This is the correct design — it is the reason a debug build can be proxied —
and it is also why "I tested pinning and my proxy still worked" is not
evidence of anything until you check which build you tested.
Underneath all four is the fact that a pin lives on a device you do not control. The most-discussed pinning thread on Hacker News is a write-up of removing pinning from a shipped messenger client, and the tooling for it is routine: patch the pin set out of the binary, or hook the check at runtime. Pinning raises the cost of intercepting your own app's traffic from "install a root certificate" to "attach a debugger". It does not make it impossible, and a threat model that treats it as a control against the device's owner is mis-specified.
Why the header version died, and what replaced it
HTTP Public Key Pinning (HPKP) was the browser-delivered form: a
Public-Key-Pins response header with a list of hashes and a
lifetime, noted by the user agent exactly the way
Strict-Transport-Security is noted.
Chrome's entry for its removal gives the reason plainly: "It has very low
adoption, and although it provides security against certificate
misissuance, it also creates risks of denial of service and hostile
pinning." Status: removed, Chrome 72. Expect-CT, the header
that asked browsers to require Certificate Transparency for a site, is
marked deprecated as of Chrome 107 — it became unnecessary once CT was
enforced by default for all publicly trusted certificates.
"Denial of service" here means the outage the panel produces on its second click. "Hostile pinning" means an attacker who obtains one valid certificate for a host can serve a long-lived pin set of keys only they control, and every browser that sees it is locked to the attacker for the life of the pin. Both risks are properties of a mechanism that lets an unauthenticated header make a site unreachable in the future, and neither is a claim that pin validation does not work.
Which is why the mechanism is alive everywhere the delivery is a build
artifact rather than a header. Chromium still enforces its own built-in pin
list — GetStaticPKPState, an updateable set of pinsets shipped
with the browser — which is pinning with the deployment risk moved to
people who can roll it back. Android ships
<pin-set> in the network security configuration. Apple
ships NSPinnedDomains in the app's
Info.plist. OkHttp ships CertificatePinner. In all
four the pins ship with the client, which is the actual precondition:
pinning is safe in proportion to how fast you can change the client.
A browser vendor can push a list in hours. A mobile app is at the mercy of a
store review and users who do not update, which is the same constraint that
governs rotating a signing key that clients have
cached — you need an overlap window, and you need it to be longer than
your slowest client.
The boundary: what to pin, and the pin that saves you
Walk the panel across the three pin targets with the CA rotated its issuing intermediate selected, and the trade becomes a table rather than an opinion. On the leaf's SPKI the check still reads MATCH: the CA re-signed the same key, and the leaf key is what you pinned. On the issuing CA's SPKI it reads NO MATCH and the app is offline — that intermediate is gone. On the root CA's SPKI it reads MATCH, because roots outlive intermediates by a decade. Now change the event to you moved to a different certificate authority: leaf SPKI survives it, and both CA pins do not.
So pinning the intermediate — the advice RFC 7469 §4 offers as the compromise, "Pinning to an intermediate issuer, or even to a trust anchor or root, still significantly reduces the number of issuers who can issue end-entity certificates for the Known Pinned Host" — buys operational flexibility at the cost of depending on a certificate whose rotation schedule belongs to someone else. Public CAs do rotate issuing intermediates, on their own timetable, and they are not obliged to tell you. A pin on a root is very nearly no restriction at all, since the root will sign anything its own policies allow, but it does exclude every other CA in the store, which is the entire misissuance threat.
Whichever you choose, the load-bearing part is the second pin. Set the event to key leaked — you deployed the offline backup key with the pin on the leaf SPKI and the backup pin unticked: APP OFFLINE. The one thing you must be able to do in an emergency — stop using a key that leaked — is the thing a single pin forbids. Tick plus a backup pin for an offline key pair: SERVER REACHED, on 3 hashes vs 2 pins. That is the whole of RFC 7469 §4.3: "Because having a backup key pair is so important to recovery, UAs MUST require that hosts set a Backup Pin."
Note what the specification did with that requirement. §2.5 makes it a condition for the pins being stored at all: a user agent notes the pins only if "The given set of Pins contains at least one Pin that does NOT refer to an SPKI in the certificate chain." A header that pins only what is currently being served is not a weak configuration, it is an ignored one. No app-level pinning implementation enforces that rule for you, so it becomes a review checklist item: at least one pin in the set must correspond to a key that is not in production, and must not be reachable from the machines that serve it.
The backup key is itself a liability, and the RFC says so in the same section — "if an attacker gains control of the private key, she will be able to perform a MITM attack without being discovered." MITM is machine-in-the-middle: whoever holds a pinned private key can answer for you and every pinned client will accept it, which is the one attack pinning is supposed to prevent. A pinned backup key sitting in the same secrets manager as the live key is not a backup, it is a second live key that nobody is watching. Offline means offline.
Checking it on a system you actually have
Four checks, in the order that resolves an incident fastest.
Compute the pin from what is being served right now and compare it to what shipped. This is the one that ends most arguments, because it produces the same string the client computes:
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
Run it once against production and once against the certificate in your pin
configuration. If they differ, the key changed. Add
-showcerts and repeat the pipeline for each certificate in the
chain to get the intermediate's and root's pins — those are the values that
tell you whether a CA rotated something underneath you.
Read the exception, all of it. OkHttp builds a failure message that contains both sides of the comparison — the peer chain with a computed pin per certificate, then the configured pins for that hostname:
javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
Peer certificate chain:
sha256/afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=: CN=publicobject.com, OU=PositiveSSL
sha256/klO23nT2ehFDXCfx3eHTDRESMz3asj1muO+4aIdjiuY=: CN=COMODO RSA Secure Server CA
Pinned certificates for publicobject.com:
sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Everything you need is in it. The number of rows under "Peer certificate chain" is the cleaned chain length. If your configured pin appears nowhere in that list, a key rotated. If the chain is shorter than you expect, the trust manager built a different path than you assumed.
Confirm pinning is running at all. The failure modes in this lesson are silent, so the check has to be positive rather than negative: deliberately break it. Change one character of a pin in a debug build, hit the endpoint, and confirm you get the exception. If you do not, pinning is not enforced for that host — the pattern does not match, the expiration passed, or the build is exempt. That is exactly OkHttp's own suggested workflow: "The easiest way to pin a host is turn on pinning with a broken configuration and read the expected configuration when the connection fails."
Before you ship, answer three questions in writing. Which key are you pinning and who controls its rotation schedule. Where is the backup key and is it genuinely offline. How many days does it take you to get a new pin set onto the slowest client you have — and is that number shorter than your certificate lifetime. If the last answer is longer than the third, you have scheduled an outage rather than configured a control. That question is why pinning is a deliberately unusual choice today rather than a default, and why "no pinning, plus the validation you already have, plus Certificate Transparency monitoring for certificates issued in your name" is the configuration most services should be running.
A mobile app pins the leaf SPKI for api.example.com with a
pattern of api.example.com, and the pins have no expiration.
A tester installs an interception proxy's root certificate on the device
and reports that they can read the app's traffic. Assuming the app uses
OkHttp's CertificatePinner in a release build, what is the
most likely explanation?
From here, the check pinning narrows is chain validation and hostname matching, and the other way to stop trusting the public CA set entirely — by making both ends present certificates from a private one — is mutual TLS.