De eigen Android-documentatie van Google zegt dat certificate pinning "niet wordt aanbevolen voor Android-apps".
Apple zegt "in de meeste gevallen is pinning niet nodig en kun je het beter vermijden".
En hier is een Chromium-engineer die een vraag beantwoordt over hoe je pinning fatsoenlijk opzet. Hij begint zijn antwoord zo: "Hoewel pinning in het algemeen nooit aangemoedigd zou moeten worden, omdat het de veiligheid en stabiliteit van het internet als geheel actief schaadt, is de Network Security Config van Android de aangewezen aanpak als je op een private CA pint." Hij vertelt je letterlijk hoe het moet, en hij kan het nog steeds niet laten.
Ondertussen zit pinning in ongeveer de helft van de mobiele apps die we bekijken, inclusief de apps die voor een audit op ons bureau belanden. Goed nieuws: zit je op Flutter, dan werkt het waarschijnlijk niet. Slecht nieuws: dezelfde zin.
Kernpunten
- Pinning is een smalle maatregel tegen een dreiging die is gekrompen, met een schadestraal ter grootte van je hele gebruikersbestand. Certificate Transparency, CAA-records en de wijziging in de vertrouwensopslag van Android 7 hebben het grootste deel van zijn oorspronkelijke werk weggenomen.
- Het is werkelijk noodzakelijk voor financiën, zorg en overheid — apps die tegen MAS-L2 van OWASP moeten worden getoetst, het profiel met verdediging in de diepte voor software die gevoelige gegevens verwerkt. Voor alle anderen zeggen Google, Apple, Cloudflare en de helft van OWASP: niet doen.
- De levensduur van certificaten stort in: 200 dagen sinds maart 2026, 100 in 2027, 47 in 2029. Pin je een blad en kun je niet wisselen zonder een appversie uit te brengen, dan zijn dat acht gedwongen releases per jaar.
- Pin de sleutel (SPKI), nooit het certificaat, en lever altijd een reservepin mee voor een offline sleutel die je zelf beheert.
- Lever nooit direct naar harde afwijzing — en bouw je eigen faaltelemetrie, want geen van beide platforms heeft er een.
- Op Flutter doet
<pin-set>innetwork_security_config.xmlniets voor Dart-verkeer. Dart draait zijn eigen TLS-stack. De bug staat sinds januari 2022 open. - De test van 30 minuten aan het eind van dit artikel vertelt je welke van deze gevallen op jouw app slaat. De naïeve versie van die test liegt tegen je.
Waar pinning je werkelijk tegen beschermt
Snel de uitgangspositie, zodat we dezelfde woorden gebruiken. Normaal vertrouwt je app elk certificaat dat door elke certificaatautoriteit in de vertrouwensopslag van het systeem is ondertekend, en die opslag bevat ongeveer honderdvijftig hoofdcertificaten. Pinning versmalt dat tot iets specifieks: deze sleutel, en niets anders.
Eerst iets over de naam. Iedereen zegt "SSL-pinning", wij ook, in de titel van dit artikel. SSL is al meer dan tien jaar dood — het is allemaal TLS nu. Maar daar zoeken mensen op, dus blijft het staan. Weet alleen: zegt iemand "SSL" en meent hij dat in 2026, dan zegt dat iets over hoe vers zijn bronnen zijn.
Nu het eerlijke dreigingsmodel. Twee dingen waar pinning werkelijk tegen verdedigt:
Een gecompromitteerde of malafide certificaatautoriteit. Dat is de eigenlijke taak van pinning. Het schoolvoorbeeld is DigiNotar in 2011: aanvallers braken in bij een Nederlandse CA, gaven frauduleuze certificaten voor Google-domeinen uit, en onderschepten Iraanse gebruikers. Pinning is wat het ving — Chrome leverde een vooraf geladen pin voor google.com mee.
Onderschepping binnen bedrijven. DLP-proxy's, antivirussoftware die TLS inspecteert, een MDM die op systeemniveau een root installeert. Pinning breekt ze allemaal. Dat is het punt — maar het betekent ook dat je de legitieme proxy van je zakelijke klant samen met die van de aanvaller breekt.
En nu het deel dat de blogposts uit 2015 niet dekken, want niets ervan bestond toen.
De dreiging van een malafide CA is fors gekrompen
Certificate Transparency publiceert elk publiek vertrouwd certificaat naar open logboeken waar alleen aan toegevoegd kan worden. Het mechanisme startte in 2013, maar werd in de praktijk in 2018 verplicht: Chrome begon het in versie 68 af te dwingen, en Apple dwingt het op OS-niveau af, wat betekent: binnen apps, niet alleen in Safari.
Dat is geen theorie. September 2015 was de eerste keer dat CT een verkeerd uitgegeven certificaat in het wild ving — een niet-geautoriseerd certificaat voor google.com dat Symantec bij interne tests produceerde. Geen kwade opzet, één dag geldig. Het was ook de eerste barst in wat in 2018 eindigde met browsers die Symantec volledig wantrouwden.
Een recent geval dook op in september 2025: de Kroatische CA Fina had twaalf niet-geautoriseerde certificaten voor 1.1.1.1 van Cloudflare uitgegeven, verspreid over februari 2024 en augustus 2025. Achttien maanden lang merkte niemand het; CT-logboeken brachten ze uiteindelijk boven water. En let op — Fina staat helemaal niet in de mobiele vertrouwensopslag, dus die certificaten zouden op een telefoon toch nooit hebben gevalideerd. Het logboek ving ze ongeacht wie de CA vertrouwt. Precies daar zit de waarde.
Daarbovenop: CAA-records, DNS-records die verklaren welke CA's voor jouw domein mogen uitgeven, plus verplichte domeinvalidatie vanuit meerdere perspectieven.
Het platform deed een deel al voor je
Vanaf Android 7 — preciezer: targetSdk 24 — vertrouwen apps standaard geen certificaten die de gebruiker heeft geïnstalleerd. Dat is sinds 2016 de standaard. Het scenario "het slachtoffer installeerde een kwaadaardige root" is dus niet iets waar pinning je nog van redt. Het besturingssysteem was je voor.
targetSdk | Systeem-CA's | Door gebruiker geïnstalleerde CA's | HTTP zonder versleuteling |
|---|---|---|---|
| ≤ 23 | vertrouwd | vertrouwd | toegestaan |
| 24–27 | vertrouwd | niet vertrouwd | toegestaan |
| ≥ 28 | vertrouwd | niet vertrouwd | standaard geblokkeerd |
Die tabel hangt aan targetSdk, niet aan de Android-versie van het toestel — en daarom vertrouwt een oude app op een nieuwe telefoon nog steeds wat de gebruiker heeft geïnstalleerd.
Waar pinning helemaal niet tegen beschermt
De formulering van OWASP is botweg: heeft een aanvaller het toestel in handen, dan zet hij je pinninglogica simpelweg uit. Op een geroote Android of een gejailbreakte iPhone is dat één commando in objection — en voor Flutter bestaat er speciaal gereedschap, waar we op terugkomen, want dat is interessant.
Dus: pinning beschermt je echte gebruikers tegen onderschepping op een vijandig netwerk. Het beschermt je API niet tegen de eigenaar van het toestel. Gebruik je pinning om sleutels te verbergen of reverse-engineering tegen te houden, dan heb je al verloren. Daar is attestatie aan de serverkant voor — Play Integrity, App Attest — geen trucs aan de clientkant.
OWASP spreekt zichzelf hier tegen, en dat telt wanneer iemand je een rapport overhandigt
MASVS vereist pinning wel — maar alleen voor een app die tegen MAS-L2 moet worden getoetst, het profiel met verdediging in de diepte van OWASP voor software die gevoelige gegevens verwerkt. Het is geen basismaatregel. De testgids zegt ronduit dat het niet als kwetsbaarheid gerapporteerd hoort te worden wanneer een app geen pinning implementeert. En de Pinning Cheat Sheet van OWASP zegt: "De eerste vraag zou moeten zijn: moet ik pinnen? Het antwoord daarop is waarschijnlijk nooit." Die formulering is in maart 2023 toegevoegd; daarvoor stond ze er niet. Markeert een pentestrapport dus "ontbrekende SSL-pinning" als bevinding, dan is dat meestal een geautomatiseerde checklist en geen besluit over jouw dreigingsmodel.
Heb je certificate pinning werkelijk nodig?
Korte checklist. Slaat ook maar één van deze op jou — niet pinnen.
- Je beheert niet beide kanten: je server en je app
- Je kunt de pinset niet veilig en snel bijwerken
- Pins bijwerken vraagt een appversie uitbrengen
- Je kunt het sleutelpaar niet kennen voordat het in productie gaat
- Het is geen native mobiele app
Wie het werkelijk nodig heeft: financiën, zorg, overheidsdiensten — overal waar de gegevens gevoelig zijn en het dreigingsmodel uitgaat van een vijandig netwerk. Dat is het MAS-L2-geval. Alle anderen: waarschijnlijk niet.
Waarom dit in 2026 urgent werd
In april 2025 stemde het CA/Browser Forum om de levensduur van TLS-certificaten af te bouwen. Apple diende het voorstel in. Het schema:
| Vanaf | Maximale levensduur | Wisselingen per jaar |
|---|---|---|
| vóór maart 2026 | 398 dagen | ~1 |
| 15 maart 2026 | 200 dagen | ~2 |
| 15 maart 2027 | 100 dagen | ~4 |
| 15 maart 2029 | 47 dagen | ~8 |
De trap van 200 dagen geldt nu. Zevenenveertig dagen is ruwweg acht certificaatwisselingen per jaar. Pin je het blad en kun je pins niet bijwerken zonder uit te brengen, dan zijn dat acht gedwongen app-updates per jaar — met beoordeling in de winkel, en met gebruikers die niet bijwerken.
En het ecosysteem bevecht pinning met opzet. In 2024 begon Let's Encrypt de uitgevende tussenlaag willekeurig te kiezen, specifiek om de gewoonte tussenlagen te pinnen te breken. In november 2025 nam het dezelfde regel mee naar een nieuwe hiërarchie van zes tussenlagen, en de aankondiging is er duidelijk over: "zoals eerder kiest elke uitgifte willekeurig welke tussenlaag wordt gebruikt, om het pinnen van tussenlaagsleutels te ontmoedigen." De meestgebruikte CA op het internet is nu twee jaar bezig geweest dat soort pinning bewust te breken.
Dat is geen kwaadwilligheid. Het is duurbetaalde ervaring, en de browserwereld ging er als eerste doorheen.
HPKP — pinning via een HTTP-header, in 2015 door Google-engineers gespecificeerd — mislukte volledig. Chrome faseerde het uit in versie 67 en haalde het weg in versie 72, in januari 2019. De opgegeven redenen van Google: zeer laag gebruik, kans op dienstweigering, en vijandige pinning.
Het beroemde slachtoffer is Smashing Magazine, oktober 2016: vier dagen offline voor de meeste lezers. Ze wisselden naar een certificaat met een nieuwe sleutel, stuurden een header met alleen de nieuwe pin, en iedereen met een gecachte pinset was buitengesloten. Terugdraaien kon niet, want het oude certificaat was al verlopen.
Aan de mobiele kant: Barclays, november 2016. De app zou een verouderde tussenlaag hebben gepind, de keten veranderde, en betalingen stopten — aan de vooravond van Black Friday. Om het op tijd te herstellen gaf Symantec een certificaat onder de oude tussenlaag uit, en moest daarvoor de eis van het CA/Browser Forum over serienummers schenden.
In beide gevallen dezelfde moraal: pinning breekt niet wanneer je wordt aangevallen. Het breekt op een gewone dinsdag, wanneer iemand een certificaat vernieuwt.
Vijf regels als je toch pint
Regel 1. Pin de sleutel, niet het certificaat
Technisch is dat de SPKI — SubjectPublicKeyInfo, de structuur in het certificaat die de publieke sleutel bevat. Je pint de met base64 gecodeerde SHA-256-hash ervan.
Waarom dat het hele spel is: het certificaat verandert bij elke vernieuwing, de sleutel niet. Pin de sleutel en een vernieuwing met dezelfde sleutel raakt je pin nooit. Pin de vingerafdruk van het hele certificaat en het breekt elke keer — twee keer per jaar bij 200 dagen, acht keer bij 47.
Dit is het commando voor een 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
En dezelfde hash uit een private sleutel die je hebt gegenereerd maar nog niet hebt laten certificeren — zo bereken je een reservepin:
openssl pkey -in backup.key -pubout -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
Het goede nieuws: beide platforms ondersteunen van nature alleen SPKI, zowel op Android als op iOS. Pin je dus een heel certificaat, dan heb je het zelf gebouwd of een bibliotheek gekozen die het voor je doet — en vrijwel zeker zonder goede reden.
Regel 2. Reservepins zijn verplicht
Minstens één sleutel volledig onder jouw beheer. Dat is de formulering van Google, en "volledig onder jouw beheer" is het dragende deel. Niet "een tweede certificaat van dezelfde CA". Een sleutelpaar dat je vooraf hebt gegenereerd en offline bewaart, en waarvoor je op afroep een certificaat kunt krijgen. De pin ervan gaat naast de live pin mee in de app en wacht daar simpelweg af.
Die ontbrekende reservepin is precies wat van de slechte dag van Smashing Magazine vier dagen uitval maakte. Ze hadden de oude sleutel nog — hun hostingpartij had hem bewaard. Wat ze in de header hadden meegestuurd was alleen de vingerafdruk van de nieuwe.
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-03-01">
<!-- actieve sleutel -->
<pin digest="SHA-256">YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</pin>
<!-- offline reservesleutel, nog nooit gebruikt voor verkeer -->
<pin digest="SHA-256">sRHdihwgkaib1P1gxX8HFszlD+7/gTfNvuAybgLPNis=</pin>
</pin-set>
</domain-config>
</network-security-config>
Het iOS-equivalent, dat declaratief is en door het besturingssysteem wordt afgedwongen:
<!-- 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>
Dat voorbeeld pint bladsleutels. De eigen documentatie van Apple gebruikt in plaats daarvan NSPinnedCAIdentities — dezelfde structuur één niveau hoger in de keten, en daar gaat regel 3 over.
Regel 3. Kies je niveau in de keten — en weet wat het kost
Hier is geen consensus, dus volgen beide standpunten. Apple raadt aan de CA te pinnen in plaats van de server, zodat je servercertificaten kunt wisselen zonder een app-update uit te brengen. OWASP raadt het omgekeerde aan: pin het blad, maar altijd met een reserve.
| Niveau | Kosten van wisselen | Wat je vertrouwt | Oordeel in 2026 |
|---|---|---|---|
| Blad | Gratis bij vernieuwing met dezelfde sleutel; anders een release | Precies één sleutel | Werkbaar alleen met reservepins |
| Tussenlaag | Onvoorspelbaar | Alles wat die tussenlaag uitgeeft | Van tafel bij publieke CA's |
| Publieke root | Zelden | Alles wat die CA ooit zal uitgeven | Zo breed dat het bijna zinloos is |
| Je eigen private root | Jij bepaalt | Je eigen PKI | Het ene duidelijk goede geval |
Een tussenlaag pinnen voor publiek vertrouwde certificaten is sinds 2024 in wezen van tafel: Let's Encrypt heeft nu zes tussenlagen en je kunt niet voorspellen welke jouw certificaat krijgt. Zit je op een publieke CA, dan blijft het blad met reservepins over, of de root.
De opstelling waarin pinning nog steeds werkelijk een goed idee is, is je eigen private CA, een die je van begin tot eind beheert. Op je eigen root pinnen is verstandig, voorspelbaar, en breekt niet doordat iemand anders van gedachten verandert. En dat is trouwens precies wat die Chromium-engineer in het citaat bovenaan zei.
Regel 4. Lever nooit direct naar harde afwijzing
De volgorde is: eerst alleen-rapporteren — de pin wordt gecontroleerd, de verbinding wordt niet geblokkeerd, en missers gaan naar telemetrie. Een week kijken. Dan harde afwijzing voor één procent van de gebruikers. Daarna een gefaseerde uitrol naar iedereen.
En nu het vervelende deel: op geen van beide platforms bestaat ingebouwde foutrapportage. Niet in de Network Security Config van Android, niet in App Transport Security van iOS. Het ene mechanisme dat dit ooit deed was report-uri van HPKP, en dat is dood. Dus bouw je de telemetrie zelf: vang de exceptie, bouw een gebeurtenis — domein, sleutel die je kreeg, sleutels die je verwachtte, appversie — en stuur die over een kanaal dat niet gepind is, anders komt het bericht over je storing er niet uit.
Nog iets: leer een storing van een aanval te onderscheiden. Faalt iedereen tegelijk, dan heb je je eigen wisseling gebroken. Verspreide fouten wijzen op een bedrijfsproxy, een antivirus, of een echte onderscheppingspoging.
Regel 5. Bouw de noodrem voordat je hem nodig hebt
Op Android zit er een ingebouwd — het attribuut expiration op de pinset. Na die datum stopt pinning simpelweg: pins worden niet gecontroleerd, verkeer loopt alsof er niets aan de hand was. Een noodrem op een tijdklok.
Maar er hangt een prijskaartje aan, en Google zegt dat zelf: "een vervaltijd op pins instellen kan aanvallers in staat stellen je gepinde certificaten te omzeilen." Het is dus tegelijk je verzekering tegen een onbruikbare app en een manier om je pinning te omzeilen door te wachten. Op een andere pagina van diezelfde documentatie beveelt Google "een voldoende korte vervalperiode" aan. Beide uitspraken zijn officieel en beide zijn actueel.
En nu ons favoriete detail in dit hele verhaal. Het canonieke voorbeeld in de documentatie van Google draagt nog steeds expiration="2018-01-01". Kopieerde je het — en iedereen kopieert het — dan staat je pinning al acht jaar volledig uit. Ze werkten die pagina in juni 2026 bij. De datum bleef staan.
Op iOS bestaat helemaal geen vergelijkbaar mechanisme. Pinning uitzetten betekent een release uitbrengen.
Bouw je een noodrem via configuratie op afstand of functieschakelaars, let dan op het kip-en-eiprobleem: het bezorgkanaal mag niet door dezelfde pins beschermd worden, anders kun je nooit herstellen. Het juiste antwoord is de pinset ondertekenen met een sleutel waarvan de publieke helft in de app is gebakken. Dan maakt het niet uit over welk kanaal ze binnenkwam. Voeg een oplopende versieteller toe zodat niemand een oudere, geldig ondertekende set kan herhalen.
Flutter: waar certificate pinning uit elkaar valt
Alles hierboven geldt voor iedereen. Dit is het deel waarvoor we het artikel schreven — wij bouwen Flutter-apps, en hier hebben we onze handen in gehad.
Dit is de kop, en hij druist in tegen wat de meeste mensen aannemen: Dart gebruikt de netwerkstack van het platform niet.
HttpClient uit dart:io gaat niet via OkHttp of HttpURLConnection op Android, of NSURLSession op iOS. Het opent een socket en draait de TLS-handdruk zelf, via een kopie van BoringSSL die in de Flutter-engine is gecompileerd.
Het praktische gevolg: je <pin-set> in network_security_config.xml doet niets voor Dart-verkeer. Stilzwijgend. Geen fout, geen waarschuwing — het verzoek gaat gewoon door.
Dit is niet onze ontdekking. Het is Flutter-issue #96722, ingediend in januari 2022 en nog steeds open. Een lid van het Flutter-team reproduceerde het en schreef de bevinding op: op iOS wordt het verzoek netjes geannuleerd, op Android gaat het door, en op een native Android-controleapp met dezelfde configuratie faalt het zoals het hoort.
Op iOS is het gedrag anders: er wordt gemeld dat het werkt, omdat Dart op Apple-platforms het vertrouwensbesluit aan SecTrust van het systeem overlaat. Dat staat nergens gedocumenteerd, en eerlijk gezegd is het de slechtste uitkomst. Pinning die op het ene platform werkt en op het andere stilzwijgend niets doet is gevaarlijker dan pinning die nergens werkt — want je hebt het getest. Op een iPhone.
Dan drie valkuilen. We zijn ze alle drie in productie tegengekomen.
Dart geeft je geen toegang tot de SPKI. De klasse X509Certificate toont der, pem, sha1, onderwerp, uitgever en geldigheidsdata. De publieke sleutel zit daar niet bij. Vrijwel elke handleiding pint dus een SHA-256 van het hele certificaat — wat volgens regel 1 bij elke vernieuwing breekt. Het goed doen betekent ASN.1 met de hand ontleden.
De eigen README van dio toont twee manieren om dit fout te doen. Dit is de eerste, samengevat:
// Voorbeeld uit de README van dio. Drie problemen in negen regels.
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 een allesomvattende badCertificateCallback zet het opbouwen van de keten, de verloopcontrole en de hostnaamverificatie uit. De vingerafdruk die het vergelijkt is een hash van cert.der — het hele certificaat, wat volgens regel 1 bij elke vernieuwing breekt. En validateCertificate draait later dan je denkt; zie hieronder.
De tweede variant in de README is erger: die pint binnen badCertificateCallback zelf en vergelijkt cert.pem met een opgeslagen PEM. Die callback vuurt per definitie alleen wanneer de validatie al is mislukt. Toont een aanvaller een certificaat dat geldig door een vertrouwde CA is ondertekend, dan draait hij helemaal niet en wordt er nooit iets vergeleken. Als pinningmechanisme is het per ontwerp stuk.
De juiste haak draait te laat. dio heeft er wel een — validateCertificate, die bij geslaagde validatie vuurt. Maar hij draait nadat het verzoek de deur uit is en het antwoord al binnen is. Het zijn niet alleen je verzoeklichaam en je Authorization-header die over de lijn gingen: je hebt de antwoordstatus en de headers al geaccepteerd. Het enige wat die controle nog redt is het antwoordlichaam. Het verzoek is al gelekt.
En dan de pakketten
Het populairste pinningpakket op pub.dev — ongeveer honderdzestig likes en bijna vijftigduizend downloads per maand toen we in augustus 2026 keken — gebruikt een brug via een platform channel om een apart native verzoek naar je host te doen, controleert de vingerafdruk daarop, en stuurt bij een match het echte verzoek over een andere verbinding waarop helemaal geen pinning zit. De ene verbinding wordt gevalideerd; een andere draagt je gegevens. Bovendien is elk verzoek nu twee verzoeken. We noemen het niet bij naam, want het gaat niet om één beheerder: lees de broncode van welk pinningpakket je ook gebruikt, en zoek uit welke verbinding het werkelijk controleert.
Wat je dan wel moet doen. Op Apple-platforms is er een net antwoord: verhuis je HTTP-laag naar cupertino_http. Dat is een echte NSURLSession, wat betekent dat pinning declaratief is — NSPinnedDomains in Info.plist, precies als in regel 2. Door het besturingssysteem afgedwongen, op basis van SPKI, zonder code van derden.
Op Android bestaat geen equivalent. Cronet heeft een eigen pinning-API, maar het pakket cronet_http exporteert die niet. De JNI-koppeling staat er gewoon in. Wat het pakket wel exporteert is enablePublicKeyPinningBypassForLocalTrustAnchors — de schakelaar die pinning omzeilt, niet de pinning zelf.
Er is ook één vraag die niemand op internet helder beantwoordt, en die markeren we liever dan te doen alsof: of de Network Security Config van Android geldt voor verkeer dat via Cronet loopt. Bronnen spreken elkaar tegen en er is geen officiële documentatie in welke richting dan ook. Test het zelf voordat je op een van beide antwoorden leunt.
Tot slot twee waarschuwingen specifiek voor Flutter. SDK's van derden — analyse, crashrapportage, betalingen — doen netwerkaanroepen via hun eigen native clients, en jouw pinning raakt ze niet. Dezelfde splitsing maakt uitgestelde deep linking op Flutter een probleem per platform in plaats van één implementatie. En migreer je naar native_dio_adapter om de native netwerkstack te krijgen, dan schakelt de op dio gebaseerde pinning die je uit een blogpost kopieerde stilzwijgend uit, want SecurityContext en beide callbacks worden niet meer aangeroepen.
Hoe je je pinning in een half uur controleert
Doe dit fatsoenlijk, want de naïeve versie liegt tegen je.
De naïeve versie is: zet een proxy op, installeer het certificaat ervan op de telefoon, kijk of de app breekt. Hij breekt. En dat zegt niets — want zoals hierboven besproken vertrouwen apps sinds Android 7 toch al geen door de gebruiker geïnstalleerde certificaten. Je concludeert dat pinning je beschermt terwijl het besturingssysteem je de hele tijd beschermde.
De pinningverificatie van 30 minuten
Draai hem één keer per platform. Alles wat niet is afgevinkt is een bevinding.
Opzet
- Releasebuild, geen debug — een debug-overrides-blok maakt de hele test ongeldig
- Proxycertificaat geïnstalleerd in de SYSTEEMOPSLAG, niet in de gebruikersopslag
- Flutter: bevestigd dat verkeer de proxy bereikt via VPN-opname of iptables, want Dart negeert proxy-instellingen van het systeem
- Een echte stroom achter een login doorlopen, niet alleen het eerste scherm
Waar je op let
- Je API-verkeer is onleesbaar — kun je het lezen, dan heb je geen werkende pinning
- Verkeer van externe SDK-pakketten en je eigen verkeer gedragen zich hetzelfde, of je weet waarom niet
- De fout die je ziet is een pinningfout, geen fout in de vertrouwensopslag
- Hetzelfde resultaat op Android en op iOS
Controleer daarna de configuratie zelf
- Pins zijn SPKI-hashes, geen certificaatvingerafdrukken
- Minstens één reservepin, voor een offline sleutel die je beheert
- Android: de vervaldatum van de pinset ligt in de toekomst en is bewust gekozen
- Er is een noodrem die geen release in de winkel vraagt
- Faaltelemetrie gaat over een kanaal dat zelf niet gepind is
De korte versie
Pinning is een smalle maatregel tegen een dreiging die veel onwaarschijnlijker is geworden, met een schadestraal over je hele gebruikersbestand. Het is werkelijk noodzakelijk voor financiën, zorg en overheid. Voor alle anderen zeggen Google, Apple, Cloudflare en de helft van OWASP: niet doen, en ze hebben redenen — dezelfde redenen die HPKP de das omdeden en die de levensduur van certificaten nu naar 47 dagen terugbrengen.
Doe je het toch: de sleutel, niet het certificaat. Reservepins met een offline sleutel. Eerst waarnemen, dan pas hard afwijzen. Je eigen noodrem. En een helder beeld van wat je betaalt voor je positie in de keten.
En zit je op Flutter, zoek dan eerst uit of het überhaupt werkt. De drie waarschijnlijkste antwoorden: pinning uitgeschakeld door een datum uit 2018 die uit de documentatie van Google is gekopieerd; pinning aangegeven in een configuratiebestand dat Dart nooit leest; of pinning die een verbinding valideert waarover je gegevens niet lopen.
De blogpostversie van dit advies zegt "zet pinning aan, bescherm je gebruikers". De technische werkelijkheid is uitzoeken waaraan je werkelijk gepind bent, verifiëren dat het op beide platforms afgaat, en een reservesleutel in een kluis leggen. Daar maakt niemand virale video's over.
Bronnen
Elke bewering hierboven is te controleren, dus hier staat waar ze vandaan komt.
- Google, "Security with HTTPS and SSL" — pinning "wordt niet aanbevolen voor Android-apps"; reservepins en "minstens één sleutel die volledig in jouw beheer is": developer.android.com/privacy-and-security/security-ssl
- Google, "Network security configuration" — het attribuut
expiration, de omzeilwaarschuwing, "alleenSHA-256wordt ondersteund", en het voorbeeld met2018-01-01: developer.android.com/privacy-and-security/security-config - Apple, "Identity Pinning: How to configure server certificates for your app" — "in de meeste gevallen is pinning niet nodig en kun je het beter vermijden", en de aanbeveling voor CA boven blad: developer.apple.com/news
- Ryan Sleevi op
net-dev@chromium.org, maart 2021 — het citaat over private CA's in zijn geheel: groups.google.com - OWASP Pinning Cheat Sheet — "Het antwoord daarop is waarschijnlijk nooit", en de lijst van gevallen om niet te pinnen: cheatsheetseries.owasp.org
- OWASP MASTG, netwerkcommunicatie — "Implementeert een app geen pinning, dan hoort dat niet als kwetsbaarheid gerapporteerd te worden. Moet de app echter tegen MAS-L2 worden getoetst, dan moet het geïmplementeerd zijn": github.com/OWASP/mastg
- Voorstel SC-081v3 van het CA/Browser Forum — het schema 398 → 200 → 100 → 47, ingediend door Apple, aangenomen op 11 april 2025: cabforum.org
- Let's Encrypt, "Generation Y" — zes tussenlagen, willekeurig gekozen "om het pinnen van tussenlaagsleutels te ontmoedigen": letsencrypt.org
- Cloudflare — de twaalf niet-geautoriseerde certificaten voor 1.1.1.1, en het ontbreken van Fina in de mobiele hoofdopslag: blog.cloudflare.com
- Flutter-issue #96722 — open sinds januari 2022: github.com/flutter/flutter
- dio,
IOHttpClientAdapter— waarvalidateCertificatewerkelijk draait, in de broncode: github.com/cfug/dio - Dart
X509Certificate— de volledige lijst van wat het toont, zonder publieke sleutel: api.dart.dev cronet_http,CronetEngine.build— de parameterlijst, mét omzeiling en zónder pinning: pub.dev
Wil je de achtergrond bij waarvoor een certificaatautoriteit werkelijk instaat, dan schreven we dat op in HTTPS eenvoudig gemaakt, en de cryptografie eronder in hoe versleuteling werkt. Dit artikel hoort bij onze video-aflevering over hetzelfde onderwerp; de vorige was iedereen had de tijdlijn mis.
En vond je iets verrassends toen je de test draaide — zeker de fout op één platform — dan horen we dat werkelijk graag. Onze inzet is dat de helft van alle Flutter-apps pinning heeft die op minstens één platform faalt.
Ilya Nixan is oprichter en lead developer bij Nerdy Production, een Flutter-eerst bureau dat apps bouwt en onderhoudt in fintech, zorg en retail. We doen ook Flutter-appontwikkeling voor teams die dit liever niet in productie ontdekken.
