End-to-End SSL Pinning & Local TLS Sandbox in Kotlin Multiplatform (KMP): The Complete Engineering Blueprint
A deep dive into building a local cryptographic sandbox for testing SSL Pinning (SPKI Hashes) across Android (OkHttp) and iOS (Darwin) with Ktor 3.5. Overcome Ktor 3.5.0 migration shifts, ASN.1 tag mismatches, keystore overwrite loops, and iOS interop quirks.
AG Mobile Labs•25 min read•
Kotlin MultiplatformKMPSSL PinningSPKI HashesTLS HandshakeKtor 3.5OkHttpDarwin EngineAndroid SecurityiOS SecurityMobile SecurityAG Mobile Labs
⚡TL;DR
- The Purpose: This guide provides a complete educational sandbox blueprint for developers wanting to practice, test, and master SSL Pinning and TLS handshakes locally in Kotlin Multiplatform (KMP) without needing a live server or production domain.
- Public Key Pinning (SPKI) vs. Full Certs: Why mobile engineering standards favor pinning SHA-256 Subject Public Key Info (SPKI) hashes over raw
.cerfiles to eliminate 90-day Let's Encrypt app breakage risks. - Ktor 3.5.0 Migration: Navigating Ktor 3.x breaking changes, replacing deprecated
applicationEngineEnvironmentwithapplicationEnvironment, and moving SSL connectors into engineconfigureblocks. - Keystore Overwrite Prevention: Eliminating the server restart trap by checking
KeyStore.getInstance("JKS")on disk to prevent pin mutation on every build. - The ASN.1 Tagging Trap: Fixing
Hostname 10.0.2.2 not verifiederrors by mapping local IPs via KtoripAddresses = listOf(InetAddress.getByName("10.0.2.2"))to emit ASN.1 Type 7 (iPAddress) SAN tags.
Introduction: The Local Cryptographic Sandbox
Implementing SSL/TLS Certificate Pinning in Kotlin Multiplatform (KMP) is one of the most effective security controls for protecting mobile apps against Man-in-the-Middle (MitM) attacks and unauthorized proxy sniffing (e.g., Charles Proxy, Bruno, Wireshark).
However, attempting to test certificate pinning directly against live production infrastructure during early development can lead to locked-out staging environments, broken builds, and endless debugging cycles across two distinct native networking layers:
- Android Target: Managed by Ktor's
OkHttpengine, relying on systemNetwork Security ConfigandCertificatePinner. - iOS Target: Managed by Ktor's
Darwinengine, relying on Apple'sApp Transport Security (ATS)andNSURLSessionchallenge handlers.
[ Local Sandbox Environment ] ──► Educational Testing (Ktor 3.5 Server & Self-Signed Certs)
│
▼
[ Production Deployment ] ──► Enterprise Hardening (Real CA Domains & SPKI Hashes)
ℹ️Info
Educational Sandbox Notice: This article serves as an educational blueprint for building a zero-cost local sandbox. It teaches developers how to simulate, debug, and verify TLS connections using self-signed certificates locally.
Technical Foundations: Public Key Pinning (SPKI) vs. Full Certificate Pinning
Before diving into engine configuration, it is essential to understand what we are pinning.
| Feature | Full Certificate Pinning (.cer / .pem) | Public Key Pinning (SPKI Hash) |
|---|---|---|
| What is Pinned | The byte-for-byte X.509 certificate file | The SHA-256 hash of the Subject Public Key Info |
| Maintenance | High: Must push app updates before cert expiration | Low: Key pairs can be reused across cert renewals |
| Rotation Risk | High: Expiration (e.g., Let's Encrypt 90 days) bricks un-updated apps | Low: Public key remains identical during renewals |
| Industry Standard | Legacy / Niche | Mobile Industry Standard |
By pinning the SHA-256 hash of the Subject Public Key Info (SPKI), the mobile app doesn't care about certificate expiration dates or Certificate Authority signatures—it only verifies that the server possesses the matching cryptographic public key.
Part 1: Setting Up the Monorepo & Dependencies
To create an end-to-end sandbox, we organize the project as a Gradle monorepo containing the shared KMP client logic (Kotlin
2.4.0) and a lightweight local Ktor 3.5.0 backend.Gradle Version Catalog (libs.versions.toml)
[versions]
kotlin = "2.4.0"
ktor = "3.5.0"
[libraries]
# Shared Client Dependencies
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
# Server Dependencies (Ktor 3.5.0)
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-network-tls-certificates = { module = "io.ktor:ktor-network-tls-certificates", version.ref = "ktor" }Shared Client build.gradle.kts
kotlin {
// ...
sourceSets {
commonMain.dependencies {
// ...
implementation(libs.ktor.client.core)
}
androidMain.dependencies {
// ...
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
// ...
}Shared expect / actual Client Factory Architecture (HttpClientFactory.kt)
Because Ktor relies on platform-native engines (OkHttp on Android, Darwin on iOS) to execute network requests and apply SSL Pinning rules, we use Kotlin Multiplatform's
expect / actual mechanism to decouple the HTTP client creation.Step 1: Declare expect function in commonMain
Create
shared/src/commonMain/kotlin/com/agmobilelabs/sslpinningkmp/HttpClientFactory.kt:// shared/src/commonMain/kotlin/com/agmobilelabs/sslpinningkmp/HttpClientFactory.kt
package com.agmobilelabs.sslpinningkmp
import io.ktor.client.HttpClient
/**
* Expected HTTP client factory to be implemented by target platforms
* with native engine configurations (OkHttp for Android, Darwin for iOS).
*/
expect fun createHttpClient(): HttpClientStep 2: Implement Baseline actual in androidMain
Create
shared/src/androidMain/kotlin/com/agmobilelabs/sslpinningkmp/HttpClientFactory.kt:// shared/src/androidMain/kotlin/com/agmobilelabs/sslpinningkmp/HttpClientFactory.kt
package com.agmobilelabs.sslpinningkmp
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
actual fun createHttpClient(): HttpClient {
return HttpClient(OkHttp)
}Step 3: Implement Baseline actual in iosMain
Create
shared/src/iosMain/kotlin/com/agmobilelabs/sslpinningkmp/HttpClientFactory.kt:// shared/src/iosMain/kotlin/com/agmobilelabs/sslpinningkmp/HttpClientFactory.kt
package com.agmobilelabs.sslpinningkmp
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
actual fun createHttpClient(): HttpClient {
return HttpClient(Darwin)
}With this foundational
expect / actual bridge established across all source sets, we can now customize the native engines with platform-specific SSL Pinning and TLS security rules.Part 2: Building a Persistent Local Ktor 3.5 Server
In Ktor 3.x (including 3.5.0), engine environment construction underwent major architectural changes:
applicationEngineEnvironmentandApplicationEngineEnvironmentBuilderwere completely removed.- Connector configurations (
connectorandsslConnector) moved directly intoembeddedServerengineconfigureblocks. applicationEnvironmentnow manages core application settings (e.g. loggers).
1. The ASN.1 Tagging Trap (Type 2 vs. Type 7 SAN Tags)
When configuring a local server certificate for IP addresses (such as loopbacks
127.0.0.1 or Android Emulator alias 10.0.2.2), a subtle X.509 cryptographic pitfall often breaks TLS handshakes:In RFC 5280, Subject Alternative Name (SAN) entries are strongly typed:
- dNSName (ASN.1 Tag 2): Used for text domain strings like
localhostorapi.ag-mobile-labs.com. - iPAddress (ASN.1 Tag 7): Used for raw numeric IP addresses like
127.0.0.1or10.0.2.2.
If raw IP strings are passed into Ktor's
domains = listOf("10.0.2.2"), Ktor compiles them as Tag 2 (dNSName) entries. When an HTTP client (like OkHttp) connects to an IP host, it strictly filters the SAN list for Tag 7 (iPAddress) entries, fails to find a match, and throws Hostname 10.0.2.2 not verified.To fix this on the server, we use Ktor's
ipAddresses property with strongly-typed java.net.InetAddress objects, forcing Ktor to emit valid Type 7 SAN tags.2. Preventing the Keystore Overwrite Loop
A common developer trap when generating local certificates via
buildKeyStore is generating a new certificate on every server boot. This changes the server's public key hash continuously, breaking client pinning definitions.We resolve this by checking
keyStoreFile.exists() and loading the existing Java KeyStore (JKS) if present:// server/src/main/kotlin/com/agmobilelabs/sslpinningkmp/Application.kt
package com.agmobilelabs.sslpinningkmp
import io.ktor.network.tls.certificates.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.io.File
import java.net.InetAddress
import java.security.KeyStore
import org.slf4j.LoggerFactory
fun main() {
// 1. Set up path to store the keystore file inside the build directory
val keyStoreFile = File("server/build/keystore.jks")
keyStoreFile.parentFile?.mkdirs()
val keyStorePassword = "keystorePassword"
val privateKeyPassword = "privateKeyPassword"
val keyAlias = "sandboxAlias"
// 1. Persistent Keystore Resolution (Prevents Pin Mutation)
val keyStore: KeyStore = if (keyStoreFile.exists()) {
LoggerFactory.getLogger("ktor.application").info("Loading existing KeyStore from disk...")
KeyStore.getInstance("JKS").apply {
keyStoreFile.inputStream().use { stream ->
load(stream, keyStorePassword.toCharArray())
}
}
} else {
LoggerFactory.getLogger("ktor.application").info("Generating new 2048-bit RSA KeyStore...")
val newKeyStore = buildKeyStore {
certificate(keyAlias) {
password = privateKeyPassword
// Text domains (ASN.1 Type 2 dNSName)
domains = listOf("localhost")
// IP Addresses (ASN.1 Type 7 iPAddress)
ipAddresses = listOf(
InetAddress.getByName("127.0.0.1"),
InetAddress.getByName("0.0.0.0"),
InetAddress.getByName("10.0.2.2")
)
keySizeInBits = 2048
hash = HashAlgorithm.SHA256
sign = SignatureAlgorithm.RSA
}
}
newKeyStore.saveToFile(keyStoreFile, keyStorePassword)
newKeyStore
}
// 2. Ktor 3.5 Embedded Server Setup
embeddedServer(
factory = Netty,
environment = applicationEnvironment {
log = LoggerFactory.getLogger("ktor.application")
},
configure = {
connector { port = 8080 }
sslConnector(
keyStore = keyStore,
keyAlias = keyAlias,
keyStorePassword = { keyStorePassword.toCharArray() },
privateKeyPassword = { privateKeyPassword.toCharArray() }
) {
port = 8443
}
}
) {
routing {
get("/") {
call.respondText("Hello from a secure Ktor 3.5 Sandbox Server!")
}
}
}.start(wait = true)
}Part 3: SPKI Hash Extraction Techniques
To populate our client-side pin configurations, we need to extract the SHA-256 hash of the server's public key.
Strategy 1: The "Crash & Extract" Shortcut (No OpenSSL Required)
Both OkHttp and Darwin emit diagnostic pinning logs when a pin verification fails.
- Configure client engines with a dummy pin string:
sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=. - Trigger a request to
https://10.0.2.2:8443. - Inspect Logcat / Console for
SSLPeerUnverifiedException:
javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
Peer certificate chain:
sha256/gXcXYaML3OMOZ9J5iTU9cYdMh0c81DUsduuqS3/vTb8=: CN=127.0.0.1, O=KtorCopy
gXcXYaML3OMOZ9J5iTU9cYdMh0c81DUsduuqS3/vTb8= directly into your code.Strategy 2: Terminal Pipelined Command
If you prefer extracting the SPKI hash ahead of time via terminal:
keytool -exportcert -alias sandboxAlias -keystore server/build/keystore.jks -storepass keystorePassword \
| openssl x509 -inform der -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64Part 4: Android Client Setup & CA Security Rules
1. The CA:TRUE Constraint
On Android 11+ (API 30+), manually installing a leaf certificate via Settings -> Security -> CA Certificate produces:
"Private key required to install a certificate"
Because leaf certificates carry Basic Constraints: CA:FALSE, Android classifies them as user identity credentials requiring private keys.
2. Android Network Security Configuration
We bypass manual installer prompts by exporting the public certificate directly from the server's KeyStore and bundling it into the Android app's resources.
Step A: Export .cer File from KeyStore
Run the following
keytool command in your project root terminal to extract the public certificate file:keytool -exportcert \
-alias sandboxAlias \
-keystore server/build/keystore.jks \
-storepass keystorePassword \
-file local_server_cert.cerStep B: Place Certificate in res/raw/
Move the generated
local_server_cert.cer file into your Android module's resources folder:
composeApp/src/androidMain/res/raw/local_server_cert.cer(Note: Create the
raw folder under res/ if it does not exist).Step C: Create network_security_config.xml
Create
composeApp/src/androidMain/res/xml/network_security_config.xml and reference @raw/local_server_cert:<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="false">10.0.2.2</domain>
<trust-anchors>
<certificates src="@raw/local_server_cert"/>
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config>Step D: Link in AndroidManifest.xml
Link the configuration file inside
composeApp/src/androidMain/AndroidManifest.xml:<application
android:networkSecurityConfig="@xml/network_security_config"
... >
</application>3. Android Client Engine (androidMain)
Now we update
shared/src/androidMain/kotlin/com/agmobilelabs/sslpinningkmp/HttpClientFactory.kt to configure OkHttp's CertificatePinner:[!NOTE] Why10.0.2.2? The Android emulator uses10.0.2.2as a special alias to map to your host machine's loopback interface (127.0.0.1). If you test on a physical Android device on the same WiFi network, you would use your machine's local LAN IP (e.g.192.168.1.5) here and in the server's SAN list.Where does thesha256/...string come from? This is the exact Subject Public Key Info (SPKI) hash we extracted in Part 3 (using either the Crash & Extract method or the OpenSSL terminal command). It must mathematically match the public key of the local Ktor server.
// shared/src/androidMain/kotlin/com/agmobilelabs/sslpinningkmp/HttpClientFactory.kt
package com.agmobilelabs.sslpinningkmp
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import okhttp3.CertificatePinner
actual fun createHttpClient(): HttpClient {
return HttpClient(OkHttp) {
engine {
config {
val pinner = CertificatePinner.Builder()
.add("10.0.2.2", "sha256/gXcXYaML3OMOZ9J5iTU9cYdMh0c81DUsduuqS3/vTb8=")
.build()
certificatePinner(pinner)
}
}
}
}Part 5: iOS Darwin Engine & Objective-C Interop
1. Info.plist Setup
Configure
NSAppTransportSecurity in the root <dict> container:<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true />
</dict>2. Darwin handleChallenge Interceptor (iosMain)
Update
shared/src/iosMain/kotlin/com/agmobilelabs/net/HttpClientFactory.kt to handle Apple's trust challenge:// shared/src/iosMain/kotlin/com/agmobilelabs/sslpinningkmp/HttpClientFactory.kt
package com.agmobilelabs.net
import io.ktor.client.*
import io.ktor.client.engine.darwin.*
import platform.Foundation.*
import platform.Security.*
actual fun createHttpClient(): HttpClient {
return HttpClient(Darwin) {
engine {
handleChallenge { session, task, challenge, completionHandler ->
val method = challenge.protectionSpace.authenticationMethod
if (method == NSURLAuthenticationMethodServerTrust) {
val serverTrust = challenge.protectionSpace.serverTrust
val host = challenge.protectionSpace.host
if (host == "127.0.0.1" && serverTrust != null) {
// Added .toInt() to resolve Kotlin/Native NSInteger Long -> Int interop mismatch
completionHandler(
NSURLSessionAuthChallengeUseCredential.toInt(),
NSURLCredential.credentialForTrust(serverTrust)
)
} else {
completionHandler(
NSURLSessionAuthChallengePerformDefaultHandling.toInt(),
null
)
}
} else {
completionHandler(
NSURLSessionAuthChallengePerformDefaultHandling.toInt(),
null
)
}
}
}
}
}ℹ️Info
Kotlin/Native Interop Note: Apple defines
NSURLSessionAuthChallengeDisposition as NSInteger (mapped to Kotlin Long on 64-bit iOS). Calling .toInt() is required to satisfy Ktor's 32-bit Int parameter requirement.🔒 Why no SPKI Hash for iOS in this Sandbox?
You might be wondering: Why did we use the SPKI hash for Android in Part 4, but completely omitted it here for iOS?
Here is exactly what the
handleChallenge block is doing, and why it differs from Android for a local self-signed environment:- What the Code Does: We intercept the
NSURLAuthenticationMethodServerTrustchallenge (the OS asking "Do you trust this server?"). We check if the host is exactly127.0.0.1(the iOS Simulator maps this directly to the Mac host machine). If it is, we programmatically force iOS to accept the self-signed certificate usingNSURLCredential.credentialForTrust(serverTrust). - Why we skipped the SPKI Hash: Unlike Android, which provides a clean
network_security_config.xmlto globally trust local.cerfiles for debug builds, iOS rigidly blocks self-signed trust evaluations. To bypass this for a self-signed localhost certificate in testing, we have to drop down into thehandleChallengelayer and manually approve theserverTrustobject for127.0.0.1. - How it works in Production: In a real production app communicating with a valid, CA-signed domain (where the root CA is already trusted by Apple), you would use SPKI hash pinning! In production, inside this exact same
handleChallengeblock, you would extract the public key data from theserverTrust, convert it to a SHA-256 string, and match it against your hardcoded SPKI strings—mimicking OkHttp'sCertificatePinnerbehavior perfectly. (Alternatively, Ktor'sdarwin.certificates.CertificatePinnerhelper works out-of-the-box in production).
📩 Stay Connected with AG Mobile Labs
Subscribe to the AG Mobile Labs Engineering Newsletter to receive technical deep dives on Kotlin Multiplatform architecture, security hardening, and mobile systems engineering straight to your inbox.
💬Comments
Loading comments...
Enjoyed this article?
Subscribe to my newsletter to get the latest articles on Android architecture, KMP, and mobile engineering straight to your inbox.