Smart Dev Pinning Plugin
Certificate pinning for Flutter HTTP requests, with identical behavior on Android and iOS.
It supports pinning at the leaf or intermediate CA level, against either the certificate (full DER) or the public key (SPKI). Multiple hashes can be supplied as backup pins.
Requirements
- Flutter 3.3 / Dart 3.3 or newer
- Android 5.0 (API 21) or newer
- iOS 11 or newer
Install
dependencies:
smart_dev_pinning_plugin: ^4.0.0
flutter pub get
Quick start
import 'package:smart_dev_pinning_plugin/smart_dev_pinning_plugin.dart';
final client = SecureClient();
final response = await client.httpRequest(
method: 'GET',
url: 'https://api.example.com/v1/profile',
certificateHashes: ['/UzJAZYxLBnEpBwXAcmd4WHi7f8aYgfMExGnoyp5B04='],
pinningMethod: PinningMethod.publicKey,
);
if (response.success) {
print(response.data);
} else {
print('Failed (${response.errorType}): ${response.error}');
}
SecureClient is a singleton; calling SecureClient() always returns the same
instance. Requests run off the UI thread, so they never freeze the interface.
Pinning methods
There are four strategies, picked with the pinningMethod argument. Which one
you want depends on what you control and how often the certificate rotates.
| Method | Pins against | When to use |
|---|---|---|
publicKey |
Leaf public key (SPKI) | Your own server; survives renewals that reuse the key |
certificate |
Leaf certificate (full DER) | Your own server; strictest, breaks on every renewal |
intermediatePublicKey |
Intermediate CA public key | Services behind a CDN/WAF (Cloudflare, Imperva, Akamai); most stable |
intermediateCertificate |
Intermediate CA certificate (full DER) | When you need a specific intermediate CA in the chain |
For services behind a CDN or WAF the leaf certificate often changes between edge nodes and rotates frequently, so pinning the intermediate's public key is usually the practical choice — those keys tend to last years.
For the intermediate methods the plugin also verifies that the leaf actually chains (with valid signatures) to the pinned intermediate, and that the leaf's SAN/CN matches the host. Pinning a public intermediate alone is not enough to be accepted.
Generating the hash
The hash is the base64 of a SHA-256 digest. There's one command per pinning
method — pick the one that matches the PinningMethod you'll use.
Use the bare host and port (
example.com:443) in-connect. Do not prefix it withhttps://— that resolves to a different host and produces a hash that won't match what the plugin computes at runtime.
1. Leaf public key — for PinningMethod.publicKey
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| openssl base64
2. Leaf certificate — for PinningMethod.certificate
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -outform DER \
| openssl dgst -sha256 -binary \
| openssl base64
The two intermediate methods use the second certificate in the chain
(-showcerts, index 1). The awk step selects it.
3. Intermediate public key — for PinningMethod.intermediatePublicKey
openssl s_client -showcerts -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/{ if(/BEGIN/){n++}; if(n==2) print }' \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| openssl base64
4. Intermediate certificate — for PinningMethod.intermediateCertificate
openssl s_client -showcerts -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/{ if(/BEGIN/){n++}; if(n==2) print }' \
| openssl x509 -outform DER \
| openssl dgst -sha256 -binary \
| openssl base64
Backup pins
Pass more than one hash and the connection is accepted if any of them matches. This is how you roll certificates without an app update: ship the next certificate's hash alongside the current one before the rotation happens.
await client.httpRequest(
method: 'GET',
url: 'https://api.example.com/v1/ping',
certificateHashes: [currentHash, nextHash],
pinningMethod: PinningMethod.publicKey,
);
Request body and methods
GET, POST, PUT, DELETE, PATCH, HEAD and OPTIONS are supported. An
unrecognized method comes back as InvalidMethodError rather than silently
falling back.
body accepts a String or a Map (encoded to JSON). encoding defaults to
'json', which sets Content-Type: application/json and forwards the body as
is; any other value sends the raw body without forcing that header.
await client.httpRequest(
method: 'POST',
url: 'https://api.example.com/v1/users',
headers: {'Authorization': 'Bearer $token'},
body: {'name': 'Ada', 'email': 'ada@example.com'},
certificateHashes: [hash],
pinningMethod: PinningMethod.certificate,
);
Timeout
There's a 30 second total timeout and a 10 second connection timeout by default. The total can be overridden per request; the connection timeout is fixed.
await client.httpRequest(
method: 'GET',
url: 'https://api.example.com/v1/slow',
certificateHashes: [hash],
pinningMethod: PinningMethod.publicKey,
timeout: const Duration(seconds: 15),
);
Binary responses
Text responses come back in response.data as before. When the body isn't valid
UTF-8 (an image, protobuf, etc.) it's returned base64-encoded and flagged, so
nothing is corrupted by a lossy text decode. Use dataBytes to get the raw bytes
regardless of type:
final res = await client.httpRequest(/* ... */);
if (res.success) {
if (res.isBinary) {
final Uint8List? bytes = res.dataBytes; // base64 decoded
} else {
final String? text = res.data;
}
}
Error handling
Network and pinning failures are not thrown — they come back in the
SmartResponse with success: false and an errorType. The only exceptions
are argument validation (ArgumentError) and running on an unsupported platform
(UnsupportedError).
final res = await client.httpRequest(/* ... */);
if (!res.success) {
switch (res.errorType) {
case 'ConnectionError': // pinning mismatch, TLS failure, timeout, DNS
case 'HttpError': // non-2xx response; res.statusCode is set
case 'SSLPinningError': // the pinned client could not be built
default:
print('${res.errorType}: ${res.error}');
}
}
errorType |
Cause |
|---|---|
ConnectionError |
Pinning mismatch, TLS handshake failure, timeout, or DNS error |
HttpError |
Server returned a non-2xx status (statusCode is set) |
SSLPinningError |
The pinned TLS client could not be created |
RequestError |
Other request-level failure (e.g. malformed URL) |
ResponseReadError |
The response body could not be read |
InvalidMethodError |
Unsupported HTTP method |
ValidationError |
Missing URL or certificate hash |
JSONParseError |
The response could not be parsed |
A pinning mismatch surfaces as ConnectionError rather than a dedicated type,
because it's reported as a connection-level failure. Release builds return a
generic message so the failure reason isn't leaked; the specific detail (e.g.
hash does not match) is only included in debug builds.
API reference
SecureClient.httpRequest
Future<SmartResponse> httpRequest({
required String method,
required String url,
Object? body,
Map<String, String>? headers,
String? encoding,
required List<String> certificateHashes,
required PinningMethod pinningMethod,
Duration? timeout,
})
| Parameter | Type | Notes |
|---|---|---|
method |
String |
GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS |
url |
String |
Must be https; required |
body |
Object? |
String or Map (JSON-encoded) |
headers |
Map<String, String>? |
Request headers |
encoding |
String? |
Defaults to 'json' |
certificateHashes |
List<String> |
One or more base64 SHA-256 hashes; required, non-empty |
pinningMethod |
PinningMethod |
Validation strategy; required |
timeout |
Duration? |
Total request timeout; defaults to 30s |
SmartResponse
| Field | Type | Notes |
|---|---|---|
success |
bool |
true for a 2xx response |
data |
String? |
Response body (base64 when isBinary) |
isBinary |
bool |
true when data is base64-encoded binary |
dataBytes |
Uint8List? |
Body as raw bytes |
statusCode |
int? |
HTTP status code |
error |
String? |
Error message when success is false |
errorType |
String? |
See the table above |
PinningMethod
enum PinningMethod {
certificate, // leaf certificate DER hash
publicKey, // leaf public key (SPKI) hash
intermediateCertificate, // intermediate CA certificate DER hash
intermediatePublicKey, // intermediate CA public key (SPKI) hash
}
What gets verified
During the TLS handshake, for every request, the plugin:
- checks the certificate or public key against your pin(s);
- validates the handshake signature, so presenting a public certificate alone is not enough to impersonate the server;
- checks the certificate's validity dates.
For intermediate pinning it additionally verifies that the leaf certificate chains to the pinned intermediate and that its host matches the request.
Platform support
| Platform | Supported |
|---|---|
| Android | Yes |
| iOS | Yes |
| Web / Desktop | No |
Notes on pinning in production
- Prefer public-key pinning over full-certificate pinning so renewals that keep the same key don't break the app.
- Always ship at least one backup pin and rotate it ahead of time.
- For CDN/WAF-backed services pin the intermediate's public key; leaf certificates there change often and vary by edge node.
- Test against staging before shipping a pin change to production.
License
MIT. See LICENSE.
A changelog is kept in CHANGELOG.md.