← Back to Blog

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 Labs25 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 .cer files to eliminate 90-day Let's Encrypt app breakage risks.
  • Ktor 3.5.0 Migration: Navigating Ktor 3.x breaking changes, replacing deprecated applicationEngineEnvironment with applicationEnvironment, and moving SSL connectors into engine configure blocks.
  • 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 verified errors by mapping local IPs via Ktor ipAddresses = 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 OkHttp engine, relying on system Network Security Config and CertificatePinner.
  • iOS Target: Managed by Ktor's Darwin engine, relying on Apple's App Transport Security (ATS) and NSURLSession challenge 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.
FeatureFull Certificate Pinning (.cer / .pem)Public Key Pinning (SPKI Hash)
What is PinnedThe byte-for-byte X.509 certificate fileThe SHA-256 hash of the Subject Public Key Info
MaintenanceHigh: Must push app updates before cert expirationLow: Key pairs can be reused across cert renewals
Rotation RiskHigh: Expiration (e.g., Let's Encrypt 90 days) bricks un-updated appsLow: Public key remains identical during renewals
Industry StandardLegacy / NicheMobile 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(): HttpClient

Step 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:
  • applicationEngineEnvironment and ApplicationEngineEnvironmentBuilder were completely removed.
  • Connector configurations (connector and sslConnector) moved directly into embeddedServer engine configure blocks.
  • applicationEnvironment now 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 localhost or api.ag-mobile-labs.com.
  • iPAddress (ASN.1 Tag 7): Used for raw numeric IP addresses like 127.0.0.1 or 10.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.
  1. Configure client engines with a dummy pin string: sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.
  2. Trigger a request to https://10.0.2.2:8443.
  3. Inspect Logcat / Console for SSLPeerUnverifiedException:
javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
  Peer certificate chain:
    sha256/gXcXYaML3OMOZ9J5iTU9cYdMh0c81DUsduuqS3/vTb8=: CN=127.0.0.1, O=Ktor
Copy 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 -base64

Part 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.cer

Step 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>
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] Why 10.0.2.2? The Android emulator uses 10.0.2.2 as 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 the sha256/... 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:
  1. What the Code Does: We intercept the NSURLAuthenticationMethodServerTrust challenge (the OS asking "Do you trust this server?"). We check if the host is exactly 127.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 using NSURLCredential.credentialForTrust(serverTrust).
  2. Why we skipped the SPKI Hash: Unlike Android, which provides a clean network_security_config.xml to globally trust local .cer files 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 the handleChallenge layer and manually approve the serverTrust object for 127.0.0.1.
  3. 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 handleChallenge block, you would extract the public key data from the serverTrust, convert it to a SHA-256 string, and match it against your hardcoded SPKI strings—mimicking OkHttp's CertificatePinner behavior perfectly. (Alternatively, Ktor's darwin.certificates.CertificatePinner helper 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.