Google's own Android documentation says certificate pinning "is not recommended for Android apps."
Apple says "in most cases, pinning is not necessary and should be avoided."
And here is a Chromium engineer answering a question about how to set pinning up properly. He opens his reply like this: "While pinning in general should never be encouraged, as it actively harms the security and stability of the Internet at large, if you're pinning to a private CA, Android's Network Security Config is the preferred approach." He is literally telling you how to do it, and he still cannot help himself.
Meanwhile, pinning ships in about half the mobile apps we look at, including the ones that land on our desk for an audit. Good news: if you are on Flutter, chances are it is not working. Bad news: same sentence.
Key takeaways
- Pinning is a narrow control against a threat that has shrunk, with a blast radius the size of your entire user base. Certificate Transparency, CAA records and the Android 7 trust-store change took most of its original job away.
- It is genuinely necessary for finance, health and government — apps that have to verify against OWASP's MAS-L2, the defence-in-depth profile for software handling sensitive data. For everyone else, Google, Apple, Cloudflare and half of OWASP say don't.
- Certificate lifetimes are collapsing: 200 days as of March 2026, 100 in 2027, 47 in 2029. If you pin a leaf and cannot rotate without an app release, that is eight forced releases a year.
- Pin the key (SPKI), never the certificate, and always ship a backup pin for an offline key you control.
- Never ship straight to hard-fail — and build your own failure telemetry, because neither platform has any.
- On Flutter,
<pin-set>innetwork_security_config.xmldoes nothing for Dart traffic. Dart runs its own TLS stack. The bug has been open since January 2022. - The 30-minute test at the end of this article tells you which of these describes your app. The naive version of that test lies to you.
What pinning actually protects you from
Quick baseline, so we are using the same words. Normally your app trusts any certificate signed by any certificate authority in the system trust store, and that store holds around a hundred and fifty root certificates. Pinning narrows that to something specific: this key, and nothing else.
A note on the name first. Everyone says "SSL pinning," us included, in the title of this article. SSL has been dead for over a decade — it is all TLS now. But that is what people search for, so it stays. Just know that if somebody says "SSL" and means it in 2026, that tells you something about how fresh their sources are.
Now the honest threat model. Two things pinning genuinely defends against:
A compromised or rogue certificate authority. This is pinning's actual job. The textbook case is DigiNotar in 2011: attackers breached a Dutch CA, issued fraudulent certificates for Google domains, and intercepted Iranian users. Pinning is what caught it — Chrome shipped a preloaded pin for google.com.
Corporate interception. DLP proxies, antivirus software doing TLS inspection, an MDM installing a root at the system level. Pinning breaks all of them. That is the point — but it also means you break your enterprise customer's legitimate proxy along with the attacker's.
And now the part the blog posts from 2015 do not cover, because none of it existed yet.
The rogue-CA threat has shrunk a lot
Certificate Transparency publishes every publicly trusted certificate to open, append-only logs. The mechanism launched in 2013, but it became mandatory in practice in 2018: Chrome started enforcing it in version 68, and Apple enforces it at the OS level, which means inside apps, not just in Safari.
That is not theory. September 2015 was the first time CT caught a misissued certificate in the wild — an unauthorized certificate for google.com that Symantec produced during internal testing. No malicious intent, valid for a single day. It was also the first crack in what ended, in 2018, with browsers distrusting Symantec entirely.
A recent one surfaced in September 2025: the Croatian CA Fina had issued twelve unauthorized certificates for Cloudflare's 1.1.1.1, spread across February 2024 and August 2025. Nobody noticed for eighteen months; CT logs are what finally turned them up. And note — Fina is not in the mobile trust stores at all, so those certificates would never have validated on a phone anyway. The log caught them regardless of who trusts the CA. That is exactly the value.
On top of that: CAA records, which are DNS records declaring which CAs may issue for your domain, plus mandatory multi-perspective domain validation.
The platform already did some of this for you
Starting with Android 7 — more precisely, targetSdk 24 — apps do not trust user-installed certificates by default. That has been the default since 2016. So the "victim installed a malicious root" scenario is not something pinning saves you from any more. The operating system beat you to it.
targetSdk | System CAs | User-installed CAs | Cleartext HTTP |
|---|---|---|---|
| ≤ 23 | trusted | trusted | allowed |
| 24–27 | trusted | not trusted | allowed |
| ≥ 28 | trusted | not trusted | blocked by default |
That table is keyed on targetSdk, not on the device's Android version — which is why an old app on a new phone still trusts whatever the user installed.
What pinning does not protect against at all
OWASP's framing is blunt: if an attacker controls the device, they simply disable your pinning logic. On a rooted Android or a jailbroken iPhone that is one command in objection — and for Flutter there is dedicated tooling, which we will get to, because it is interesting.
So: pinning protects your real users from interception on a hostile network. It does not protect your API from the owner of the device. If you are using pinning to hide keys or to stop reverse engineering, you have already lost. That is what server-side attestation is for — Play Integrity, App Attest — not client-side tricks.
OWASP contradicts itself here, and it matters when someone hands you a report
MASVS does require pinning — but only for an app that must verify against MAS-L2, OWASP's defence-in-depth profile for software handling sensitive data. It is not a baseline control. The testing guide says plainly that if an app does not implement pinning, that should not be reported as a vulnerability. And the OWASP Pinning Cheat Sheet says: "The first question should be, should I pin? The answer to this is probably never." That language was added in March 2023; it was not there before. So when a pentest report flags "missing SSL pinning" as a finding, that is usually an automated checklist, not a decision about your threat model.
Do you actually need certificate pinning?
Short checklist. If any one of these is you — do not pin.
- You do not control both ends: your server and your app
- You cannot update the pin set safely and quickly
- Updating pins requires an app release
- You cannot know the key pair before it goes into production
- It is not a native mobile app
Who genuinely needs it: finance, health, government services — anywhere the data is sensitive and the threat model assumes a hostile network. That is the MAS-L2 case. Everyone else: probably not.
Why this got urgent in 2026
In April 2025 the CA/Browser Forum voted to phase down TLS certificate lifetimes. Apple filed the ballot. The schedule:
| From | Maximum lifetime | Rotations per year |
|---|---|---|
| before March 2026 | 398 days | ~1 |
| 15 March 2026 | 200 days | ~2 |
| 15 March 2027 | 100 days | ~4 |
| 15 March 2029 | 47 days | ~8 |
The 200-day tier is in force right now. Forty-seven days is roughly eight certificate rotations a year. If you pin the leaf and you cannot update pins without shipping, that is eight forced app updates a year — with store review, and with users who do not update.
And the ecosystem is fighting pinning on purpose. Back in 2024, Let's Encrypt started picking the issuing intermediate at random, specifically to break the habit of pinning intermediates. In November 2025 it carried the same policy into a new hierarchy of six intermediates, and the announcement spells it out: "as before, each issuance will choose which intermediate to use at random, to discourage intermediate key pinning." The most widely used CA on the internet has spent two years deliberately breaking that kind of pinning.
This is not malice. It is hard-won experience, and the browser world went through it first.
HPKP — pinning via an HTTP header, specified in 2015 by Google engineers — failed completely. Chrome deprecated it in version 67 and killed it in version 72, in January 2019. Google's stated reasons: very low adoption, risk of denial of service, and hostile pinning.
The famous casualty is Smashing Magazine, October 2016: offline for four days for most of its readers. They rotated to a certificate with a new key, shipped a header containing only the new pin, and everyone holding a cached pin set was locked out. There was no rollback, because the old certificate had already expired.
On the mobile side, Barclays, November 2016. Reportedly the app pinned an outdated intermediate, the chain changed, and payments stopped — on the eve of Black Friday. To fix it in time, Symantec issued a certificate under the old intermediate, and had to violate the CA/Browser Forum's serial-number requirement to do it.
Same moral in both cases: pinning does not break when you are attacked. It breaks on an ordinary Tuesday, when somebody renews a certificate.
Five rules if you are pinning anyway
Rule 1. Pin the key, not the certificate
Technically that is the SPKI — SubjectPublicKeyInfo, the structure inside the certificate that holds the public key. You pin the base64-encoded SHA-256 hash of it.
Why this is the whole ballgame: the certificate changes on every renewal, the key does not. Pin the key and a same-key renewal never touches your pin. Pin the whole certificate fingerprint and it breaks every single time — twice a year at 200 days, eight times at 47.
Here is the one-liner for a live host:
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
And the same hash from a private key you have generated but not yet certified — which is how you compute a backup pin:
openssl pkey -in backup.key -pubout -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
The good news: both platforms natively support only SPKI, on Android and iOS both. So if you are pinning a whole certificate, you either hand-rolled it or you picked a library that does it for you — and almost certainly for no good reason.
Rule 2. Backup pins are mandatory
At least one key entirely under your control. That is Google's phrasing, and "entirely under your control" is the load-bearing part. Not "a second certificate from the same CA." A key pair you generated ahead of time and keep offline, one you can get a certificate for on demand. Its pin ships in the app alongside the live one and just sits there waiting.
That missing backup pin is exactly what turned Smashing Magazine's bad day into four days of downtime. They still had the old key — their host had kept it. What they had shipped in the header was the fingerprint of the new one only.
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-03-01">
<!-- live key -->
<pin digest="SHA-256">YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</pin>
<!-- offline backup key, never yet used to serve traffic -->
<pin digest="SHA-256">sRHdihwgkaib1P1gxX8HFszlD+7/gTfNvuAybgLPNis=</pin>
</pin-set>
</domain-config>
</network-security-config>
The iOS equivalent, which is declarative and enforced by the OS:
<!-- Info.plist -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>api.example.com</key>
<dict>
<key>NSIncludesSubdomains</key><true/>
<key>NSPinnedLeafIdentities</key>
<array>
<dict><key>SPKI-SHA256-BASE64</key><string>YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</string></dict>
<dict><key>SPKI-SHA256-BASE64</key><string>sRHdihwgkaib1P1gxX8HFszlD+7/gTfNvuAybgLPNis=</string></dict>
</array>
</dict>
</dict>
</dict>
That sample pins leaf keys. Apple's own documentation uses NSPinnedCAIdentities instead — the same structure one level up the chain, which is what rule 3 is about.
Rule 3. Pick your level in the chain — and know what it costs
There is no consensus here, so both positions follow. Apple recommends pinning the CA rather than the server, so that you can rotate server certificates without shipping an app update. OWASP recommends the opposite: pin the leaf, but always with a backup.
| Level | Rotation cost | What you are trusting | Verdict in 2026 |
|---|---|---|---|
| Leaf | Free with a same-key renewal; a release otherwise | Exactly one key | Workable only with backup pins |
| Intermediate | Unpredictable | Everything that intermediate issues | Off the table on public CAs |
| Public root | Rare | Everything that CA will ever issue | So broad it is nearly pointless |
| Your own private root | You decide | Your own PKI | The one clearly good case |
Pinning an intermediate for publicly trusted certificates has been basically off the table since 2024: Let's Encrypt has six intermediates now and you cannot predict which one your certificate gets. If you are on a public CA, that leaves the leaf with backup pins, or the root.
The configuration where pinning is still genuinely a good idea is your own private CA, one you control end to end. Pinning to your own root is sensible, predictable, and it does not break because somebody else changed their mind. Which, incidentally, is exactly what that Chromium engineer said in the quote at the top.
Rule 4. Never ship straight to hard-fail
The order is: report-only mode first — the pin gets checked, the connection does not get blocked, and misses go to telemetry. Watch for a week. Then hard-fail for one percent of users. Then a staged rollout to everybody.
And here is the annoying part: there is no built-in failure reporting on either platform. Not in Android's Network Security Config, not in iOS App Transport Security. The one mechanism that ever did this was HPKP's report-uri, and it is dead. So you build the telemetry yourself: catch the exception, build an event — domain, key you got, keys you expected, app version — and send it over a channel that is not pinned, or the report about your outage will not make it out.
One more thing: learn to tell an outage from an attack. Everyone failing at once means you broke your own rotation. Scattered failures mean a corporate proxy, an antivirus, or an actual interception attempt.
Rule 5. Build the kill switch before you need it
On Android there is one built in — the expiration attribute on the pin set. After that date, pinning simply stops: pins are not checked, traffic flows as if nothing had happened. A kill switch on a timer.
But it has a price, and Google says so itself: "setting an expiration time on pins may enable attackers to bypass your pinned certificates." So it is both your insurance against bricking the app and a way to bypass your pinning by waiting. On a different page of the same documentation, Google recommends "a sufficiently short expiration period." Both statements are official and both are current.
And here is our favourite detail in this whole story. The canonical example in Google's documentation still carries expiration="2018-01-01". If you copied it — and everybody copies it — your pinning has been fully disabled for eight years. They updated that page in June 2026. The date stayed.
On iOS there is no equivalent mechanism at all. Turning pinning off means shipping a release.
If you are doing a kill switch over remote config or feature flags, watch out for the chicken-and-egg problem: the delivery channel cannot be protected by the same pins, or you can never recover. The right answer is to sign the pin set with a key whose public half is baked into the app. Then it does not matter what channel it arrived on. Add a monotonic version counter so nobody can replay an older, validly signed set.
Flutter: where certificate pinning falls apart
Everything above applies to everyone. This is the part we wrote the article for — we build Flutter apps, and this is the part we have had our hands in.
Here is the headline, and it goes against most people's intuition: Dart does not use the platform networking stack.
HttpClient from dart:io does not go through OkHttp or HttpURLConnection on Android, or NSURLSession on iOS. It opens a socket and runs the TLS handshake itself, through a copy of BoringSSL compiled into the Flutter engine.
The practical consequence: your <pin-set> in network_security_config.xml does nothing for Dart traffic. Silently. No error, no warning — the request just goes through.
This is not our discovery. It is Flutter issue #96722, filed in January 2022 and still open. A member of the Flutter team reproduced it and wrote the finding down: on iOS the request is cancelled properly, on Android it goes through, and on a native Android control app carrying the same config it fails as it should.
On iOS the behaviour is different: it is reported to work, because Dart on Apple platforms hands the trust decision to the system's SecTrust. That is not documented anywhere, and honestly it is the worst outcome. Pinning that works on one platform and silently no-ops on the other is more dangerous than pinning that works nowhere — because you tested it. On an iPhone.
Then three traps. We have hit all three in production.
Dart gives you no access to the SPKI. The X509Certificate class exposes der, pem, sha1, subject, issuer and validity dates. The public key is not among them. So virtually every tutorial pins a SHA-256 of the entire certificate — which, per rule 1, breaks on every renewal. Doing it properly means parsing ASN.1 by hand.
dio's own README shows two ways to get this wrong. Here is the first, condensed from it:
// dio's README example. Three problems in nine lines.
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient(context: SecurityContext(withTrustedRoots: false));
client.badCertificateCallback = (cert, host, port) => true;
return client;
},
validateCertificate: (cert, host, port) =>
cert != null && fingerprint == sha256.convert(cert.der).toString(),
);
withTrustedRoots: false plus a blanket badCertificateCallback disables chain building, expiry and hostname verification. The fingerprint it compares is a hash of cert.der — the whole certificate, which rule 1 says breaks on every renewal. And validateCertificate runs later than you think; see below.
The README's second variant is worse: it pins inside badCertificateCallback itself, comparing cert.pem to a stored PEM. By definition that callback fires only when validation has already failed. If an attacker presents a certificate validly signed by any trusted CA, it never runs at all and nothing is ever compared. As a pinning mechanism it is broken by design.
The right hook runs too late. dio does have one — validateCertificate, which fires on successful validation. But it runs after the request has gone out and the response has already come back. It is not just your request body and Authorization header on the wire: you have already accepted the response status and headers. The only thing that check saves is the response body. The request has already leaked.
And then the packages
The most popular pinning package on pub.dev — around a hundred and sixty likes and close to fifty thousand downloads a month when we checked in August 2026 — uses a platform channel bridge to make a separate native request to your host, checks the fingerprint on that, and if it matches, sends the real request over a different connection with no pinning on it at all. One connection gets validated; another carries your data. Also, every request is now two requests. We are not naming it, because the point is not one maintainer: read the source of whichever pinning package you are using, and find out which connection it actually checks.
What to actually do. On Apple platforms there is a clean answer: move your HTTP layer onto cupertino_http. That is a real NSURLSession, which means pinning is declarative — NSPinnedDomains in Info.plist, exactly as in rule 2. OS-enforced, SPKI-based, zero third-party code.
On Android there is no equivalent. Cronet has its own pinning API, but the cronet_http package does not export it. The JNI binding is right there in the package. What the package does export is enablePublicKeyPinningBypassForLocalTrustAnchors — the switch that bypasses pinning, not the pinning.
There is also one question nobody on the internet answers clearly, and we would rather flag it than pretend otherwise: whether Android's Network Security Config applies to traffic going through Cronet. Sources contradict each other and there is no official documentation either way. Test it yourself before you rely on either answer.
Two closing warnings for Flutter specifically. Third-party SDKs — analytics, crash reporting, payments — make network calls through their own native clients, and your pinning does not touch them. The same split is what makes deferred deep linking on Flutter a per-platform problem rather than one implementation. And if you migrate to native_dio_adapter to get the native networking stack, the dio-based pinning you copied from a blog post silently switches off, because SecurityContext and both callbacks stop being invoked.
How to check your pinning in half an hour
Do this properly, because the naive version lies to you.
The naive version is: stand up a proxy, install its certificate on the phone, see if the app breaks. It will break. And that means nothing — as covered above, since Android 7 apps do not trust user-installed certificates anyway. You will conclude that you are protected by pinning when the operating system was protecting you all along.
The 30-minute pinning verification
Run it once per platform. Anything unchecked is a finding.
Setup
- Release build, not debug — a debug-overrides block invalidates the whole test
- Proxy certificate installed in the SYSTEM trust store, not the user store
- Flutter: traffic confirmed to reach the proxy via VPN capture or iptables, because Dart ignores system proxy settings
- A real authenticated flow exercised, not just the first screen
What to look for
- Your API traffic is unreadable — if you can read it, you have no working pinning
- Third-party SDK traffic and your own traffic behave the same way, or you know why they do not
- The failure you see is a pinning failure, not a trust-store failure
- The same result on Android and on iOS
Then check the configuration itself
- Pins are SPKI hashes, not certificate fingerprints
- At least one backup pin, for an offline key you control
- Android: the pin-set expiration date is in the future and deliberately chosen
- A kill switch exists that does not require a store release
- Failure telemetry is sent over a channel that is not itself pinned
The short version
Pinning is a narrow control against a threat that has become far less likely, with a blast radius covering your whole user base. It is genuinely necessary for finance, health and government. For everyone else, Google, Apple, Cloudflare and half of OWASP say don't, and they have reasons — the same reasons that killed HPKP and that are now cutting certificate lifetimes to 47 days.
If you do it: the key, not the certificate. Backup pins with an offline key. Observation before hard-fail. Your own kill switch. And a clear view of what you are paying for your position in the chain.
And if you are on Flutter, first find out whether it works at all. The three most likely answers: pinning disabled by a 2018 date copied out of Google's documentation; pinning declared in a configuration file Dart never reads; or pinning that validates a connection your data does not travel on.
The blog-post version of this advice says "enable pinning, protect your users." The engineering reality is finding out what you are actually pinned to, verifying it fires on both platforms, and putting a spare key in a safe. Nobody makes viral videos about that.
Sources
Every claim above is checkable, so here is where each one comes from.
- Google, "Security with HTTPS and SSL" — pinning "is not recommended for Android apps"; backup pins and "at least one key that's fully in your control": developer.android.com/privacy-and-security/security-ssl
- Google, "Network security configuration" — the
expirationattribute, the bypass warning, "onlySHA-256is supported", and the2018-01-01sample: developer.android.com/privacy-and-security/security-config - Apple, "Identity Pinning: How to configure server certificates for your app" — "in most cases, pinning is not necessary and should be avoided", and the CA-over-leaf recommendation: developer.apple.com/news
- Ryan Sleevi on
net-dev@chromium.org, March 2021 — the private-CA quote in full: groups.google.com - OWASP Pinning Cheat Sheet — "The answer to this is probably never", and the do-not-pin list: cheatsheetseries.owasp.org
- OWASP MASTG, network communication — "If an app does not implement pinning, this shouldn't be reported as a vulnerability. However, if the app must verify against MAS-L2 it must be implemented": github.com/OWASP/mastg
- CA/Browser Forum ballot SC-081v3 — the 398 → 200 → 100 → 47 schedule, filed by Apple, passed 11 April 2025: cabforum.org
- Let's Encrypt, "Generation Y" — six intermediates, chosen at random "to discourage intermediate key pinning": letsencrypt.org
- Cloudflare — the twelve unauthorized 1.1.1.1 certificates, and Fina's absence from the mobile root stores: blog.cloudflare.com
- Flutter issue #96722 — open since January 2022: github.com/flutter/flutter
- dio,
IOHttpClientAdapter— wherevalidateCertificateactually runs, in source: github.com/cfug/dio - Dart
X509Certificate— the full list of what it exposes, with no public key on it: api.dart.dev cronet_http,CronetEngine.build— the parameter list, bypass included and pinning absent: pub.dev
If you want the background on what a certificate authority actually vouches for, we wrote that up in HTTPS made simple, and the cryptography underneath it in how encryption works. This article is the companion to our video episode on the same subject; the previous one was everyone got the timeline wrong.
And if you found something surprising when you ran the test — particularly the one-platform failure — we would genuinely like to hear it. Our bet is that half of all Flutter apps have pinning that fails on at least one platform.
Ilya Nixan is Founder & Lead Developer at Nerdy Production, a Flutter-first agency that builds and maintains apps across fintech, healthcare, and retail. We also do Flutter app development for teams who would rather not find this out in production.
