← All recipes

Recipe: certificate (public-key) pinning against the gateway

Cross-platform: Kotlin Multiplatform, Flutter, React Native.

Problem

TLS proves you are talking to a server with a valid certificate — not that it is your server. A compromised or coerced CA, or a corporate MITM proxy, can present a valid cert for your host and read everything. Pinning says: trust this connection only if the server's key (or cert) matches one I shipped. The catch is that pinning is a production posture; in development you talk to localhost, self-signed certs, and rotating tunnel certs, so pinning must be off there or nothing connects.

Concept

Pin the public key (SPKI SHA-256), not the certificate — the key usually survives cert renewal, so a routine renewal doesn't brick every installed app. Ship one or two pins (a primary and a backup key) for the real gateway host only. Gate it behind a flag that is off in debug and on in release, so make tunnel (whose cert is ngrok's or Cloudflare's, and rotates) and localhost keep working while development, and the production gateway is pinned in the shipped build.

Implementation ×3

KMP (Ktor) — pinning lives in the platform engine, not commonMain.

Android (OkHttp engine):

OkHttp {
    config {
        certificatePinner(
            CertificatePinner.Builder()
                .add("gateway.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
                .add("gateway.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup key
                .build(),
        )
    }
}

iOS (Darwin engine) uses handleChallenge { session, task, challenge, completionHandler -> … } to evaluate the server trust and compare the leaf's public-key hash before accepting.

Flutter (dio) — swap the IO adapter's client and verify the fingerprint:

(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
  final client = HttpClient();
  client.badCertificateCallback = (cert, host, port) => spkiSha256(cert) == kGatewayPin;
  return client;
};

(badCertificateCallback only fires for otherwise-rejected certs; for a valid chain, verify the pin in an interceptor instead. The http_certificate_pinning package wraps both.)

React Native (Expo)fetch/ky cannot pin in JS; pinning is native config applied by a config plugin. Android via a network-security-config XML (<pin-set>), iOS via NSPinnedDomains in Info.plist, both injected through expo-build-properties or a small config plugin. Or use react-native-ssl-pinning's fetch for the pinned calls.

The dev bypass

pinningEnabled = isRelease && baseUrl is the production gateway host

Never pin a tunnel or localhost: ngrok/Cloudflare present their own certificate, and it rotates. Pin the stable production hostname only, in release builds. This is the switch the spec asks for, and forgetting it is the classic "works on my machine, bricks in QA" bug.

How to see it work

Pin the wrong hash and the pinned build refuses to connect (a TLS failure the executor maps to NetworkUnavailable); pin the right one and it connects; point the same build at the tunnel with pinning off and it connects there too. There is nothing to unit-test — pinning is a real TLS handshake — so this is verified by pointing a release build at a gateway with a known key.

Trade-offs

Pinning is the highest-friction security control there is: rotate a key without shipping the new pin first and every installed app stops working until they update. That is why you pin the public key (not the cert), ship a backup pin, and keep a remote kill-switch in mind. It is off in this lab's dev flow on purpose — the tunnel and self-signed localhost make pinning counterproductive there — and documented here as the production posture.