Certificate pinning has no shared implementation, and should not
Each engine pins through its own trust machinery, and one cannot pin at all.
TL;DR
- A
certificatePinnerblock the security review counted as "pinning enabled" that was enforced on exactly one of four engines. The other three shipped with an empty engine config, and a proxy holding a trusted-CA certificate for our own host was answered with200 OKon all three. - A proxied-traffic test is the only check that distinguishes them, and it had never been run on anything but Android.
- The fix declares pinning per engine and returns the set of platforms where it could not be applied, so an unpinnable target is a logged, asserted fact rather than a silent gap.
- What reading on buys: the four engines' actual behaviour under a MITM proxy, why the browser cannot pin, and the one assertion that makes the gap impossible to ship unnoticed.
The case this post is about
A shared HTTP client with pinning configured once, in commonMain:
// commonMain/kotlin/network/Client.kt
fun httpClient(engine: HttpClientEngine) = HttpClient(engine) {
install(ContentNegotiation) { json() }
engine {
// what we believed was a cross-platform pin
pipelining = true
}
}
And the pinning itself, added in the Android source set because that is where the example code was, with a comment that turned out to be the whole problem:
// androidMain/kotlin/network/Engine.android.kt
actual fun platformEngine(): HttpClientEngine = OkHttp.create {
config {
certificatePinner(
CertificatePinner.Builder()
.add("api.example.com", "sha256/K8f3…=", "sha256/Lr29…=") // leaf + backup
.build()
)
}
}
// iosMain, wasmJsMain, desktopMain — the actuals that shipped
actual fun platformEngine(): HttpClientEngine = Darwin.create { }
actual fun platformEngine(): HttpClientEngine = Js.create { }
actual fun platformEngine(): HttpClientEngine = CIO.create { }
Three engines with an empty configuration block. The security review had asked "is pinning enabled?" and the honest answer given was yes — it was, in the file somebody opened.
The symptom
A penetration test, scoped to all four platforms, and a one-line finding:
F-03 TLS interception succeeds on iOS, web and desktop clients.
Android client correctly refuses the proxied connection.
Reproducing it took a proxy and four builds:
$ # mitmproxy with its CA installed in the device/host trust store
request reached the proxy? client behaviour
android (OkHttp) no SSLPeerUnverifiedException
ios (Darwin) YES 200 OK, body read
web (Js) YES 200 OK, body read
desktop (CIO) YES 200 OK, body read
Three of four clients happily spoke to a proxy holding a certificate for api.example.com signed
by a CA that was in the trust store and is not ours. Which is exactly what TLS is specified to do
and exactly what pinning exists to prevent.

Why the obvious fix failed
The first attempt was to move the pin into commonMain so all four engines would get it:
// commonMain — does not compile
engine {
certificatePinner(...) // e: Unresolved reference: certificatePinner
}
certificatePinner is an OkHttp API, reachable only from the OkHttp engine's config. Ktor's
HttpClientEngineConfig has no pinning surface, because pinning is not something Ktor implements —
it is delegated entirely to whatever does the TLS.
The second attempt was Ktor's own TLS configuration, which exists for CIO:
// desktopMain
CIO.create {
https {
trustManager = pinningTrustManager(pins) // compiles, and read the next paragraph
}
}
That compiles and it did change behaviour — the proxied request now failed on desktop. It also failed against the real server, intermittently, because the trust manager I had written validated the leaf certificate's public key and the server was behind a load balancer presenting one of two valid certificates. A pin set that omits a rotation target is an outage waiting for a certificate renewal, and mine omitted one of the two.
The third attempt was the browser, and this is where the design had to change. There is no API:
// wasmJsMain — there is nothing to write here
Js.create {
// fetch() does not expose certificate validation. The browser owns the TLS stack,
// the trust store is the user's, and a page cannot inspect or constrain the chain.
}
A web page cannot pin. Not "it is awkward" — the capability is deliberately absent, because a page is untrusted code and the browser's trust decisions are the user's. So any design whose correctness depends on all four targets pinning is a design that cannot be satisfied, and pretending otherwise is what produced the finding.
The actual mechanism
Pinning is an assertion about the certificate chain, and the chain is validated by whatever owns the TLS session — which is a different component on each target.
OkHttp owns its own TLS and exposes the hook. CertificatePinner is checked after the platform
trust manager has accepted the chain, so it is an additional constraint. Pins are SPKI hashes and
the API takes several, which is what makes rotation survivable.
Darwin's TLS belongs to NSURLSession. There is no pinning parameter; the mechanism is an
authentication-challenge delegate — you are handed the server's trust object and decide. Ktor's
Darwin engine exposes it, and the shape is a callback rather than a configuration value, so a
"configure pinning" API that returns Unit has nowhere to put it.
CIO implements TLS in Kotlin and takes a trust manager. Pinning is expressible and is your code, including the parts that are easy to get wrong — which chain element to pin, whether to accept a backup, what to do when the chain is longer than expected.
And the browser's TLS is the browser's. fetch() has no certificate surface at all. The trust
store is the user's, and the deliberate absence of this capability is a security property of the
web platform rather than a gap in it.
So a single shared pinning API has three possible shapes and all of them lie about one target:
Unit return implies success everywhere, a boolean implies a per-call answer that cannot be
produced before a request is made, and a throwing version makes the browser target unusable.
The shape that does not lie returns what it could not do.

The fix
Configure pinning per engine, and have the configuration report the platforms where it could not be applied — so the gap is a value the application asserts on rather than an absence nobody sees.
// commonMain/kotlin/network/Pinning.kt
//
// The pins, shared — hashes are platform-independent even though their enforcement is not.
// Several per host ON PURPOSE: a single pin is an outage at the next certificate renewal,
// which is a self-inflicted incident that pinning is famous for.
data class PinSet(val host: String, val spkiSha256: List<String>) {
init {
require(spkiSha256.size >= 2) {
"$host has ${spkiSha256.size} pin(s). At least one BACKUP pin is required, or " +
"the next certificate rotation is an outage with no client-side remedy."
}
}
}
/** What a platform could NOT enforce. Empty means fully pinned. */
data class PinningResult(val unpinnable: Set<String>) {
val isFullyPinned: Boolean get() = unpinnable.isEmpty()
}
/**
* Applies pinning to the engine being configured and REPORTS what it could not apply.
* A Unit return would make an unpinnable platform indistinguishable from a pinned one,
* which is the exact defect this replaces.
*/
expect fun HttpClientEngineConfig.applyPinning(pins: List<PinSet>): PinningResult
// androidMain — OkHttp: a real additional constraint on an already-validated chain
actual fun HttpClientEngineConfig.applyPinning(pins: List<PinSet>): PinningResult {
(this as? OkHttpConfig)?.config {
certificatePinner(
CertificatePinner.Builder().apply {
pins.forEach { p -> p.spkiSha256.forEach { add(p.host, it) } }
}.build()
)
} ?: return PinningResult(pins.map { it.host }.toSet()) // wrong engine: say so
return PinningResult(emptySet())
}
// iosMain — Darwin: a challenge handler, because there is no configuration value
actual fun HttpClientEngineConfig.applyPinning(pins: List<PinSet>): PinningResult {
(this as? DarwinClientEngineConfig)?.handleChallenge { _, _, challenge, completion ->
val host = challenge.protectionSpace.host
val expected = pins.firstOrNull { it.host == host }
// A host with no pins is NOT a failure — it is out of scope, and must fall through
// to the system's own validation rather than being rejected or blindly accepted.
if (expected == null) {
completion(NSURLSessionAuthChallengePerformDefaultHandling, null); return@handleChallenge
}
val trust = challenge.protectionSpace.serverTrust
// The system's own evaluation runs FIRST. Pinning is an additional constraint on a
// chain that already validates; replacing the system check with a hash comparison
// discards expiry, revocation and hostname verification.
if (trust == null || !SecTrustEvaluateWithError(trust, null)) {
completion(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null); return@handleChallenge
}
val leafHash = SecTrustGetCertificateAtIndex(trust, 0)
?.let { spkiSha256Base64(it) }
if (leafHash != null && leafHash in expected.spkiSha256) {
completion(NSURLSessionAuthChallengeUseCredential, NSURLCredential.credentialForTrust(trust))
} else {
completion(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null)
}
} ?: return PinningResult(pins.map { it.host }.toSet())
return PinningResult(emptySet())
}
// wasmJsMain — the honest actual. It applies nothing and says which hosts are unpinned.
actual fun HttpClientEngineConfig.applyPinning(pins: List<PinSet>): PinningResult =
PinningResult(pins.map { it.host }.toSet())
And the assertion at the call site, which is what turns the report into a control:
// commonMain/kotlin/network/Client.kt
fun httpClient(engine: HttpClientEngine, platform: Platform): HttpClient {
// Captured OUT of the engine block, because that block is where the typed engine config
// exists and the assertion belongs to the caller rather than to the configuration.
var result: PinningResult? = null
val client = HttpClient(engine) {
install(ContentNegotiation) { json() }
engine { result = applyPinning(API_PINS) }
}
with(checkNotNull(result) { "applyPinning never ran — engine block not invoked" }) {
when {
isFullyPinned -> Unit
// The browser genuinely cannot pin. That is ACCEPTED, once, in one place, with
// the compensating control named — rather than being an absence nobody noticed.
platform == Platform.Web ->
logger.warn("pinning unavailable on web for $unpinnable; " +
"mitigated by HSTS + CSP + a short-lived session token")
// Any other platform reporting an unpinnable host is a WIRING BUG — a wrong
// engine type, a missing actual — and it fails the build's own startup check
// rather than shipping as a silent downgrade.
else -> error(
"pinning was not applied for $unpinnable on $platform. " +
"This platform CAN pin, so this is a configuration error, not a limitation."
)
}
}
return client
}
And the output it produced, re-running the proxy test on all four:
$ ./tools/pinning-check.sh --proxy mitmproxy --hosts api.example.com
android (OkHttp) refused SSLPeerUnverifiedException: Certificate pinning failure!
ios (Darwin) refused NSURLErrorCancelled (challenge cancelled)
desktop (CIO) refused TLSException: pinned key not found in chain
web (Js) ALLOWED pinning unavailable — expected, logged, accepted
3 / 3 pinnable platforms enforce. 1 platform reported unpinnable, with a named mitigation.
How we proved it
That three of four engines were unpinned, measured with a proxy rather than inferred from the config:
reached the proxy before after
android no no
ios YES no
desktop YES no
web YES YES (expected)
The proxy is the only instrument that distinguishes "pinning configured" from "pinning enforced", and it had only ever been pointed at Android.
That the pin set needed a backup, which the first fix lacked. The test is to present the second valid certificate from the load-balancer pool and confirm the client accepts it:
$ ./tools/pinning-check.sh --present-cert lb-secondary
one pin (leaf only) REFUSED ← a false positive against the real server
two pins (leaf + backup) accepted
That failure mode is worse than no pinning, because it is an outage rather than a vulnerability,
and it arrives on a certificate renewal rather than on a deploy. The require in PinSet exists
because of this run.
That replacing the system check with a hash comparison is wrong, which the first Darwin implementation did:
expired cert wrong hostname proxied
hash comparison only accepted accepted refused
SecTrustEvaluateWithError + hash refused refused refused
Pinning is an additional constraint. A hash-only check passes a certificate that is expired, or issued for a different host, as long as its key matches — which is a smaller set of attacks and a real regression on everything else TLS validates.
That the non-web error branch catches a wiring bug. The fixture is a module whose iOS actual
is missing, so the common expect resolves to a stub returning everything unpinnable:
$ ./gradlew :app:iosSimulatorArm64Test --tests '*PinningWiring*'
FAILED: pinning was not applied for [api.example.com] on Ios.
This platform CAN pin, so this is a configuration error, not a limitation.
What I cannot claim. The web mitigations — HSTS, CSP, short-lived tokens — are not equivalent to pinning and I am not going to pretend they are. They reduce the window and the blast radius of an intercepted session; they do not prevent interception by a CA the user's browser trusts. The honest position is that the web client has a weaker threat model than the mobile ones, written down in one place, rather than a uniform claim that is false on one target.
And a limit on the approach. PinningResult reports what the configuration could not apply.
It cannot report a pin that was applied and is wrong — a typo'd hash pins against nothing and
reports success, failing only when a real request is made. The proxy check is what covers that,
and it has to run in CI against a real endpoint, which is the part of this that is still a
scheduled job rather than a per-PR gate.

What this generalizes to
A capability that exists on most platforms and is deliberately absent on one cannot be expressed as a shared API that returns nothing — the return type is where the asymmetry has to live. The general move is to make the unavailable case a value the caller must handle rather than a silence: report what could not be done, accept it explicitly where it is genuinely impossible with the compensating control named, and fail loudly where it is possible and merely unwired. The two look identical in a configuration file and they are a limitation and a bug respectively.
Related
- One HttpTimeout block, four engines that honour it differently — the same per-engine asymmetry for a setting that silently goes nowhere.
- There is no shared way to store a secret — another security capability where the platform's implementation is the point.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".