DeepConcepts

Security / transport security / certificate validation

Certificate Pinning Runs After Validation, or Not at All

The misconception

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.

15 min

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.

what happens
pin check
matched on
comparisons made
chain the client built
a connection here proves
The chain the client built, and the pin set it was compiled with

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.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.