You open your app, tap a button, and data appears on screen. Between that tap and that data, your app made an HTTPS request to your API server. HTTPS — the "S" stands for "secure" — means the data was encrypted in transit. Nobody listening on the Wi-Fi network, nobody at the ISP, nobody sitting between your phone and the server could read what was sent or received.
That's the promise. And for most apps, most of the time, it holds. But the mechanism that makes it work — TLS, certificates, certificate authorities — is something most Flutter developers have never looked at closely. It's invisible until it breaks. And there are specific, real-world scenarios where it can be undermined even when everything looks correct from the outside.
This post explains the entire system from zero. If you don't know what a certificate is, what a Certificate Authority does, or why anyone talks about "pinning" — this is where you start. By the end, you'll understand the machinery, the weaknesses, and the specific cases where you might need to go beyond what HTTPS gives you by default.
What happens before any data flows
Your app sends a request to https://api.yourapp.com/users. Before a single byte of your actual request leaves the device, something called the TLS handshake runs. TLS stands for Transport Layer Security. It's the protocol that turns plain HTTP into HTTPS.
The handshake is a conversation between your app and the server. In simplified, human terms:
- Your app says: "I want to talk to api.yourapp.com securely."
- The server replies: "Here is my identity document." This document is the server's certificate. It contains the server's name, a cryptographic public key, and a signature from a trusted third party.
- Your app checks the document. Is the name on the certificate the same as the domain I'm connecting to? Is the signature from an authority I trust? Has the certificate expired? If any check fails, the connection is refused.
- If everything checks out, the two sides use the certificate's public key to agree on a shared encryption key. From this point forward, every byte flowing in both directions is encrypted with that key. Nobody who intercepts the traffic can read it.
That's the conceptual flow. The actual protocol involves more steps — cipher suite negotiation, session resumption, potentially client certificates — but the core idea is: the server proves its identity, the app verifies that proof, and then they establish an encrypted channel.
The entire security of this system rests on step 3: the app's ability to verify that the server's identity document is legitimate. Which means we need to understand what that document actually is.
What a certificate actually is
A TLS certificate is a file. It contains structured data, and the important fields are:
- Subject: the domain name the certificate is issued for (e.g.,
api.yourapp.com) - Public key: a cryptographic key that the server uses during the handshake. The corresponding private key never leaves the server.
- Issuer: the name of the Certificate Authority (CA) that issued this certificate
- Validity period: a "not before" and "not after" date. Certificates expire.
- Digital signature: a cryptographic signature created by the issuing CA using the CA's own private key
The analogy that works best: a certificate is like a passport. The passport contains your name and photo (the domain and public key). It was issued by a government (the Certificate Authority). The government stamped it with an official seal that's extremely hard to forge (the digital signature). When you present the passport at a border, the officer doesn't just read the name — they verify the seal. If the seal is valid and the issuing government is one they recognise, they let you through.
Without the CA's signature, a certificate is just a file claiming to be someone. Anyone can create a certificate that says "I am api.google.com." The signature is what makes it trustworthy — because only the CA that signed it could have produced that signature, and the CA (in theory) only signs certificates for people who actually control the domain.
The chain of trust
You might be wondering: how does your phone know which Certificate Authorities to trust? The answer is built into the device itself.
Every Android phone, every iPhone, every laptop ships with a pre-installed list of trusted root certificates. These are the certificates of the root CAs — companies like DigiCert, Let's Encrypt, GlobalSign, Sectigo, and around 150 others. These root certificates are baked into the operating system. When Apple ships iOS or Google ships Android, they include a curated list of root CAs they've vetted and deemed trustworthy.
But your server's certificate usually isn't signed directly by a root CA. The structure is a chain:
- Root CA — the ultimate trust anchor. Its certificate is self-signed (it signs itself) and is pre-installed on devices. Root CAs keep their private keys in hardware security modules in physically secured facilities. They rarely sign individual server certificates directly.
- Intermediate CA — signed by the root CA. The root CA delegates signing authority to intermediate CAs. This is a security measure: if an intermediate CA's key is compromised, the root CA can revoke it without replacing every root certificate on every device in the world.
- Your server's certificate — signed by the intermediate CA.
When your app receives a certificate during the TLS handshake, it doesn't just check the certificate itself. It walks the chain:
- It reads your server's certificate. Signed by Intermediate CA X.
- It finds Intermediate CA X's certificate (usually sent along with the server certificate). Signed by Root CA Y.
- It checks: is Root CA Y in my device's trusted root store? If yes, the chain is valid. The connection proceeds.
If any link in the chain is broken — the intermediate CA's certificate has expired, the root CA isn't in the trust store, the signatures don't verify — the connection fails. Your app gets a TLS error. No data flows.
This system has worked remarkably well for decades. Billions of HTTPS connections are secured by it every day. But it has a structural weakness, and understanding that weakness is the reason certificate pinning exists.
What can go wrong — why HTTPS alone is not always enough
The chain of trust model has a single, fundamental assumption: every Certificate Authority in your device's trust store is trustworthy. All 150-odd of them. If even one is compromised, the entire system breaks for any domain.
This is not a theoretical concern. It has happened.
A Certificate Authority gets compromised
In 2011, a Dutch Certificate Authority called DigiNotar was breached. The attackers used DigiNotar's systems to issue fraudulent certificates for over 500 domains — including google.com, microsoft.com, and several intelligence agencies. These certificates were perfectly valid from a technical standpoint. Any device that trusted DigiNotar (which was all of them, because DigiNotar was in every major trust store) would accept a connection to a fake Google server as legitimate.
The attack was used to intercept the Gmail traffic of Iranian citizens. It was not a drill. People's communications were read by attackers using a technically valid certificate signed by a technically trusted CA.
DigiNotar was eventually removed from all trust stores and went bankrupt. But the damage was done, and the structural problem remains: your device trusts a large number of CAs, and you have no control over which ones or how well they protect their keys.
Corporate MITM proxies
Many companies run a man-in-the-middle (MITM) proxy on their corporate network. The way it works:
- The company installs its own custom CA certificate on every employee device (as part of the device management profile).
- When an employee's device makes an HTTPS connection to, say, api.slack.com, the proxy intercepts it.
- The proxy connects to the real Slack server on behalf of the device.
- The proxy generates a new certificate for api.slack.com, signed by the company's custom CA.
- The proxy presents this certificate to the employee's device.
- The device checks: is this certificate signed by a trusted CA? Yes — the company's CA is in the trust store. Connection accepted.
The employee's device sees a valid HTTPS connection to Slack. The lock icon is green. But the proxy is sitting in the middle, decrypting all traffic, inspecting it (for data loss prevention, malware scanning, or compliance monitoring), and re-encrypting it before forwarding.
This is not a hack — it's a legitimate corporate security practice. But it means that from your app's perspective, the "secure" connection is being decrypted and read by a third party. If your app handles sensitive data (financial records, health information, personal communications), you may not want to allow this, even on a corporate network.
Government-mandated CA insertion
Some governments require or coerce device manufacturers to include government-controlled CAs in the trust store. A government-controlled CA can issue certificates for any domain and intercept any HTTPS traffic from devices that trust it. This is the same mechanism as the corporate proxy, but at a national scale.
Domain validation vulnerabilities
Most certificates are issued through domain validation (DV) — the CA verifies that the person requesting the certificate controls the domain, usually by checking a DNS record or a file served from the web server. If an attacker can temporarily hijack DNS for your domain (via BGP hijacking, DNS cache poisoning, or compromising your DNS provider), they can obtain a legitimate, CA-signed certificate for your domain. The certificate is real. The CA followed its process. But the attacker now has a valid certificate for your server.
What certificate pinning does
All of the attacks above exploit the same weakness: the app trusts whatever the device's CA store says is valid. Certificate pinning removes that trust and replaces it with something specific.
When you implement certificate pinning, your app says:
"For connections to api.yourapp.com, I will only accept THIS specific certificate — or THIS specific public key. I don't care if the device has 150 trusted CAs. I don't care if some other CA has issued a technically valid certificate for my domain. If the certificate presented during the TLS handshake doesn't match what I've pinned, I refuse the connection."
With pinning in place:
- A compromised CA issues a fake cert for your domain? Refused. The fake cert doesn't match the pin.
- A corporate proxy intercepts the connection and presents its own cert? Refused. The proxy cert doesn't match the pin.
- A government-controlled CA signs a certificate for your domain? Refused. Same reason.
The app only talks to servers that present the exact certificate (or key) that you, the developer, have embedded in the app. This is a much stronger guarantee than "someone the device trusts says this is legitimate."
Certificate pinning vs public key pinning
There are two approaches to pinning, and the distinction matters for maintenance.
Certificate pinning
You pin the entire certificate. The app stores a copy of (or a hash of) the exact certificate your server uses. During the TLS handshake, the app compares the received certificate against the stored one, byte for byte.
Advantage: tightest possible match. There is zero ambiguity — either the certificate is the exact one you pinned, or the connection fails.
Problem: certificates expire. Let's Encrypt certificates expire every 90 days. Even certificates from traditional CAs expire after one or two years. When you renew or rotate the certificate on your server, the new certificate is a different file with a different signature. The pinned certificate in your app no longer matches. Every user running the old version of the app gets connection failures until they update.
If you forget to ship an app update with the new certificate before the old one expires, every user is locked out. This has caused real production outages at real companies.
Public key pinning
Instead of pinning the whole certificate, you pin just the public key inside the certificate. When your server's certificate is renewed, the new certificate usually contains the same public key (unless you deliberately generate a new key pair). So the pin survives certificate rotation.
Advantage: more resilient to routine certificate renewals. As long as you reuse the same key pair when renewing, the pin holds.
Problem: if you ever need to change the key pair (because the private key was compromised, or you're migrating to a stronger key type), the pin breaks, and you're back to the same update-before-expiry problem.
Backup pins
Regardless of which approach you choose, the standard practice is to pin at least two values: your current certificate or key, and a backup. The backup can be:
- The public key of your next planned certificate (pre-generated but not yet deployed)
- The public key of an intermediate CA in your chain (broader pin, but still much tighter than trusting the entire CA store)
With a backup pin, certificate rotation becomes: deploy the new certificate on the server (which matches the backup pin), then ship an app update that moves the old backup to primary and adds a new backup. Users on the old app version still connect (the backup pin matches). Users on the new version have the updated pin set. No outage window.
Implementation in Flutter
There are three levels at which you can implement pinning in a Flutter application: Dart-level (in your HTTP client), Android platform-level (network security config), and iOS platform-level (TrustKit or equivalent). Each has trade-offs.
Dart-level: Dio with a custom SecurityContext
This approach loads your server's certificate from the app's assets and configures Dart's HttpClient to trust only that certificate.
First, export your server's certificate as a PEM file. You can get it from your hosting provider, or extract it using openssl:
# Download the certificate chain from your server
openssl s_client -connect api.yourapp.com:443 -showcerts </dev/null 2>/dev/null \
| openssl x509 -outform PEM > api_cert.pemPlace the file in your Flutter project at assets/certs/api_cert.pem and declare it in pubspec.yaml:
flutter:
assets:
- assets/certs/api_cert.pemThen configure your Dio client:
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/services.dart';
Future<Dio> createPinnedHttpClient() async {
final dio = Dio(BaseOptions(
baseUrl: 'https://api.yourapp.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
));
// Load the pinned certificate from assets
final certData = await rootBundle.load('assets/certs/api_cert.pem');
final certBytes = certData.buffer.asUint8List();
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final context = SecurityContext(withTrustedRoots: false);
// withTrustedRoots: false means we DON'T trust the device's CA store.
// Only the certificate we explicitly add will be trusted.
context.setTrustedCertificatesBytes(certBytes);
final client = HttpClient(context: context);
// If the certificate doesn't match, reject the connection.
client.badCertificateCallback =
(X509Certificate cert, String host, int port) => false;
return client;
};
return dio;
}The key line is SecurityContext(withTrustedRoots: false). This tells Dart to ignore the device's entire CA trust store. The only trusted certificate is the one you load from assets. If the server presents any other certificate — even a perfectly valid one signed by a reputable CA — the connection is refused.
badCertificateCallback returning false means: if certificate validation fails for any reason, do not proceed. Never return true from this callback in production. Returning true disables certificate validation entirely, which is worse than having no pinning at all.
For public key pinning at the Dart level, you would compare the public key hash of the received certificate against your stored hash inside the badCertificateCallback, rather than loading the full certificate into the SecurityContext. The implementation is more involved — you need to extract the public key from the X509Certificate object and compute its SHA-256 hash — but the principle is the same.
Android: network_security_config.xml
Android provides a platform-level mechanism for certificate pinning that operates below the Dart layer. This is often preferable because it's declarative, supports backup pins natively, and includes an expiration date (after which pinning is disabled, preventing permanent lockouts from misconfiguration).
First, get the SHA-256 hash of your certificate's public key:
# Extract the public key and compute its SHA-256 hash (base64-encoded)
openssl s_client -connect api.yourapp.com:443 </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| openssl enc -base64This outputs a base64-encoded string like BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=. That's your pin.
Create the network security config file:
<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
<!-- Default: trust system CAs, block cleartext traffic -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Pin certificates for your API domain -->
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.yourapp.com</domain>
<pin-set expiration="2027-06-01">
<!-- Primary: current certificate's public key hash -->
<pin digest="SHA-256">your_current_key_hash_base64==</pin>
<!-- Backup: next planned certificate's public key hash -->
<pin digest="SHA-256">your_backup_key_hash_base64==</pin>
</pin-set>
</domain-config>
</network-security-config>Reference it in your AndroidManifest:
<!-- android/app/src/main/AndroidManifest.xml -->
<application
android:networkSecurityConfig="@xml/network_security_config"
...>The expiration attribute on <pin-set> is a safety mechanism. After the expiration date, the pins are ignored and the app falls back to normal CA validation. This prevents a scenario where you ship an app with pins, forget about them, rotate your certificates, and permanently lock out users who never updated. It's a safety net, not a substitute for proper rotation planning.
The base-config section with cleartextTrafficPermitted="false" is worth including regardless of whether you pin. It blocks all HTTP (non-HTTPS) traffic from your app at the platform level.
iOS: TrustKit
iOS does not have a declarative equivalent to Android's network security config. The standard approach for iOS certificate pinning in Flutter apps is the TrustKit library, configured in the native iOS layer.
Add TrustKit to your iOS project via CocoaPods (in ios/Podfile):
pod 'TrustKit'Then configure it in your AppDelegate.swift:
import TrustKit
@UIApplicationMain
class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let trustKitConfig: [String: Any] = [
kTSKSwizzleNetworkDelegates: true,
kTSKPinnedDomains: [
"api.yourapp.com": [
kTSKEnforcePinning: true,
kTSKIncludeSubdomains: true,
kTSKPublicKeyHashes: [
// Primary pin
"your_current_key_hash_base64==",
// Backup pin
"your_backup_key_hash_base64==",
],
]
]
]
TrustKit.initSharedInstance(withConfiguration: trustKitConfig)
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}kTSKSwizzleNetworkDelegates: true tells TrustKit to automatically intercept all URLSession connections — including those made by Dart's HTTP client via the iOS networking layer. This means pinning works without modifying your Dart code. The Dart-level Dio client makes a request, the request passes through iOS's networking stack, TrustKit intercepts the TLS handshake, and rejects the connection if the pin doesn't match.
The public key hash format is the same as the Android config — SHA-256 of the Subject Public Key Info, base64-encoded.
The maintenance burden — the honest part
Certificate pinning is not a "set it and forget it" feature. It's a commitment that requires ongoing operational discipline. Here is what that looks like in practice.
Certificate rotation planning
Your server's TLS certificate will expire. Let's Encrypt certificates expire every 90 days. Traditional CA certificates expire every 1-2 years. When the certificate changes, the pin must change. That means:
- Months before expiry, generate the new certificate (or at least the new key pair) so you know the hash.
- Ship an app update containing the new hash as a backup pin. Users who update now have both pins — the current and the next.
- Rotate the certificate on the server. Users on the updated app connect fine (the backup pin matches the new cert). Users on the old app connect fine until the old cert is removed.
- Ship another app update removing the old pin and adding a new backup for the next rotation.
This cycle never stops. Every rotation requires at least one app update, and the update must reach users before the old pin becomes the only one. If you use Let's Encrypt with 90-day certificates and public key pinning (where the key survives renewal), the cycle is less frequent — but the moment you change key pairs, the clock starts ticking.
The app store delay problem
On the Play Store, app reviews typically take hours to a few days. On the App Store, reviews can take days. You cannot count on an update being available to users instantly. Factor in the time for users to actually install the update (many have auto-updates disabled or delayed), and you need weeks of overlap between old and new pins, not days.
Force-update as a safety net
If something goes wrong — you rotate a certificate without updating the pins, or a pin is wrong — users on the broken version cannot connect to your API at all. Their app is dead. You need a mechanism to force them to update.
This is a separate feature: a force-update endpoint (on a different domain, with no pinning) that the app checks on launch. If the response says "your version is too old, update now," the app directs the user to the app store. This is infrastructure you need to build and maintain if you implement pinning.
Monitoring
Set up alerts for certificate expiry — 90 days out, 60 days out, 30 days out, 14 days out. Use a monitoring service (UptimeRobot, Better Stack, AWS Certificate Manager, or similar) that tracks your certificate's expiry date and notifies you well in advance. A single missed renewal with active pinning locks out your entire user base.
When to pin and when not to
Certificate pinning is a trade-off: stronger security guarantees in exchange for ongoing maintenance and operational risk. Whether it's worth it depends entirely on what your app handles and who might attack it.
Pin if:
- Your app handles financial transactions. Banking apps, payment apps, fintech. The value of the data flowing through the connection justifies the maintenance cost.
- Your app handles health or medical data. HIPAA compliance and patient safety make the stronger guarantee worthwhile.
- Your app operates in regions where government-level interception is a real concern. If your users are in countries known to perform HTTPS interception at the network level, pinning is a meaningful protection.
- Your app handles data that specific, motivated attackers would target. Corporate espionage, journalism, legal, anything where the threat model includes well-resourced adversaries.
- Your compliance requirements mandate it. Some regulatory frameworks (PCI DSS, for example) explicitly recommend or require certificate pinning for mobile applications.
Do not pin if:
- Your app is a consumer utility, social app, or content app. The data is not valuable enough to justify the operational overhead. Standard HTTPS with proper CA validation is sufficient.
- You do not have the operational maturity to manage certificate rotation. Pinning without a rotation plan is worse than no pinning. It will eventually lock out your users.
- You are a solo developer or small team without automated deployment pipelines. The manual overhead of managing pins, shipping updates on schedule, and monitoring expiry is significant. If it will fall through the cracks, don't do it.
- Your app uses many third-party API domains. Pinning your own API is manageable. Pinning Google Maps, Stripe, Firebase, and five other third-party domains means tracking their certificate rotations — which you don't control. This is fragile and rarely worth it.
The honest middle ground
For most apps, the correct approach is:
- Use HTTPS everywhere (enforce it with ATS on iOS and network security config on Android).
- Never disable certificate validation (
badCertificateCallbackreturningtrue,NSAllowsArbitraryLoads, etc.). - Use
flutter_secure_storagefor tokens. - Don't embed secret keys in the app binary.
- Don't implement certificate pinning unless your threat model specifically requires it.
This combination protects against the vast majority of real-world attacks. The scenarios where pinning matters — compromised CAs, state-level interception, targeted MITM on high-value data — affect a specific subset of applications. If you're building one of those applications, you know it. If you're not sure, you probably don't need it.
Wrapping up
The certificate system that secures HTTPS connections is a layered trust mechanism: your device trusts a set of root CAs, those CAs delegate to intermediaries, and the intermediaries vouch for your server's certificate. It works well. It has protected billions of connections for decades.
Its weakness is the breadth of trust. Your device trusts around 150 Certificate Authorities, any one of which can issue a certificate for any domain. If one is compromised, coerced, or negligent, the guarantee breaks. Certificate pinning narrows that trust to exactly the certificate (or key) you specify, eliminating the dependency on the broader CA system.
The cost is real: you take on the responsibility of managing that pin, rotating it on schedule, shipping app updates in advance, and having a recovery plan for when something goes wrong. For apps handling high-value data in hostile environments, that cost is justified. For most apps, standard HTTPS done properly is enough.
Understanding the system — even if you decide not to pin — changes the way you think about your app's network security. You stop thinking of HTTPS as a magic green lock and start understanding it as a chain of specific, verifiable trust relationships, each with known strengths and known limits. That understanding is worth having regardless of what you build.
This is Post 4 in the Flutter Security Beyond the Basics series. The previous post covered obfuscation and reverse engineering. The next post covers root detection, device attestation, and runtime integrity checks.