Certificate pinning in Flutter
By Ann Tech · 6 November 2025
Certificate pinning prevents man-in-the-middle (MITM) attacks by verifying the server's certificate against a known-good value, rather than trusting the system certificate store. Without pinning, an attacker with a rogue CA certificate (or a compromised device) could intercept traffic.
When pinning is worth the cost
Pinning adds operational complexity — if your certificate rotates and you forgot to update the app, users get locked out. Use it when:
- The app handles financial transactions or medical data
- Your threat model includes targeted attacks against specific users
- Regulatory requirements (PCI DSS, HIPAA) mandate it
For most consumer apps, HTTPS with proper certificate validation is sufficient.
SPKI pinning vs certificate pinning
- Certificate pinning: pin the exact certificate. Breaks when cert rotates (every 1-2 years).
- SPKI pinning (Subject Public Key Info): pin the public key. Survives certificate renewal as long as you keep the same key pair.
SPKI pinning is strongly preferred in production.
Get your SPKI hash
# From the live server
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
Or from a certificate file:
openssl x509 -in cert.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
Implementation with Dio
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
Dio buildSecureDio() {
final dio = Dio();
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.badCertificateCallback = (X509Certificate cert, String host, int port) {
// Only allow specific hosts to bypass standard validation
return false;
};
return client;
};
// Add pinning interceptor
dio.interceptors.add(CertificatePinningInterceptor(
allowedSHAFingerprints: [
'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // Primary cert
'sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', // Backup cert
],
));
return dio;
}
class CertificatePinningInterceptor extends Interceptor {
CertificatePinningInterceptor({required this.allowedSHAFingerprints});
final List<String> allowedSHAFingerprints;
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// Note: Dio doesn't expose the certificate on response
// For full SPKI pinning, use a custom HttpClient as shown below
handler.next(response);
}
}
HttpClient-level pinning
For full control, intercept at the HttpClient level:
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
HttpClient buildPinnedHttpClient(List<String> spkiHashes) {
final client = HttpClient();
client.badCertificateCallback = (X509Certificate cert, String host, int port) {
// Extract the public key from the certificate's DER encoding
// and verify its SHA-256 hash matches our pinned value
final spki = _extractSpki(cert.der);
final hash = base64.encode(sha256.convert(spki).bytes);
final pinnedHash = 'sha256/$hash';
if (!spkiHashes.contains(pinnedHash)) {
// Log the mismatch for investigation
debugPrint('Certificate pin mismatch for $host: got $pinnedHash');
return false; // Reject the connection
}
return true;
};
return client;
}
List<int> _extractSpki(Uint8List der) {
// Parse the certificate DER to extract Subject Public Key Info
// This is ASN.1 parsing — use a library for production code
// ...
}
For production, use http_certificate_pinning package which handles the ASN.1 parsing:
dependencies:
http_certificate_pinning: ^4.0.0
Backup pins
Always pin at least two certs — the primary and a backup:
const pinnedHashes = [
'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // Current cert
'sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', // Next cert (pre-generated)
];
Generate your next cert's SPKI hash before the current one expires. Deploy the new hash in an app update before rotating the certificate.
Certificate rotation procedure
- Generate new certificate/key pair
- Compute SPKI hash of new cert
- Add new hash to
pinnedHashes(keep old hash too) - Release app update with both hashes
- Wait for adoption (≥ 95% of users on new version)
- Rotate the server certificate
- Remove old hash in next app update
Common pitfalls
Pinning the leaf certificate. Leaf certificates expire annually. Pin the intermediate CA or root CA public key instead — they last 10-25 years.
No backup pin. If you pin only one cert and it needs emergency rotation, all users on the old app version lose access. Always have a backup pin.
Enabling pinning in debug builds. Certificate pinning breaks local proxy debugging (Charles, Proxyman). Disable pinning in debug mode:
if (!kDebugMode) {
dio.interceptors.add(CertificatePinningInterceptor(hashes: pinnedHashes));
}
Sign in to like, dislike, or report.