June 13, 2026
Your KMP library is only as protected as its weakest target. And the attacker knows it.
There is a line I like to repeat whenever the subject is Kotlin Multiplatform security: you write one thing, but you defend N.
Jackson F. de A. M.
9 min read
It sounds obvious written out like that, but I see good engineers fall for it all the time.
You put the logic incommonMain, you feel like you shipped one artifact, you obfuscate Android beautifully, you sleep soundly. And the compiler, quietly, went ahead and generated an iOS framework with readable symbols and a JS bundle that is basically your source code handed back to you.
This article is about that asymmetry. We will go from the anatomy of the .klib (the part almost no material covers) to how I would attack your library if I wanted to, and what to do about it. I will use pinning as the case study, because it is the most instructive example of a defense you think you applied everywhere and in practice applied on a third of your clients.
The usual disclaimer: everything here is defensive analysis and local research. Reversing your own library, or a public one to understand the ecosystem, is bread and butter for anyone who takes security seriously. Use it on what is yours.
The centerpiece: what is inside a .klib
Let us start with the most underestimated thing in KMP. The .klib is a zip. Not a metaphor, not "kind of a zip." It is a zip. You run unzip and it opens.
unzip mylib-iosarm64-1.0.0.klib -d mylibunzip mylib-iosarm64-1.0.0.klib -d mylibInside, the layout is stable and documented (it is literally what the compiler's KotlinLibraryLayout describes):
default/
manifest # java properties: unique_name, abi_version, dependencies, native_targets...
linkdata/ # metadata serialized as ProtoBuf: the entire ABI
root_package/
0_.knm # e.g.: fun verifyLicense(): Boolean
ir/ # the serialized Kotlin IR, that is, the actual code
irDeclarations.knd # the declarations
bodies.knb # the function bodies
strings.knt # the string literals. yes, yours.
resources/default/
manifest # java properties: unique_name, abi_version, dependencies, native_targets...
linkdata/ # metadata serialized as ProtoBuf: the entire ABI
root_package/
0_.knm # e.g.: fun verifyLicense(): Boolean
ir/ # the serialized Kotlin IR, that is, the actual code
irDeclarations.knd # the declarations
bodies.knb # the function bodies
strings.knt # the string literals. yes, yours.
resources/Notice what this means. The manifest is a plain-text Java properties file: module name, ABI version, compiler version, dependencies, targets. No secret, but a gorgeous map. The linkdata is your entire public surface serialized as ProtoBuf: every class, every function with its flags (suspend, inline, visibility), every property, and the full set of your expect declarations. And ir/ is the punchline: unlike JVM and Android (which produce JAR and AAR), every other target carries the Kotlin IR, which is your code in an intermediate representation, before it becomes native code. bodies.knb holds the function bodies. strings.knt holds your strings.
You do not even have to crack the zip open by hand. The compiler ships a tool for this:
klib info mylib-iosarm64-1.0.0.klib
klib dump-metadata mylib-iosarm64-1.0.0.klib
klib dump-metadata-signatures mylib-iosarm64-1.0.0.klib
klib dump-ir mylib-iosarm64-1.0.0.klibklib info mylib-iosarm64-1.0.0.klib
klib dump-metadata mylib-iosarm64-1.0.0.klib
klib dump-metadata-signatures mylib-iosarm64-1.0.0.klib
klib dump-ir mylib-iosarm64-1.0.0.klibdump-metadata-signatures hands you the signature of everything. dump-ir hands you the assembled IR. For a library that was not written to resist this, it is like opening the source.
And now the part that reorganizes the rest of the article: the .klib you publish to Maven Central does not go through R8. It does not go through any obfuscation. Obfuscation happens when the consuming application does its final build. The artifact you hand to the world is the cleanest and most readable version of your library that will ever exist. You obfuscate for your users and publish in the clear for your attacker.
From anatomy to attack
Now, think like someone who wants to break your library. Where do I start?
The shortcut that makes traditional reversing almost unnecessary
I do not touch the obfuscated APK. Why would I suffer inside an R8-treated binary if your library is published? I pull the .klib from Maven Central, open it, read the linkdata and the IR, and study the logic in isolation, with none of the noise of the whole app around it and not a single line of obfuscation.
I find the weakness there (a client-side license check, a signature computation, a permissive parser, an open deserialization path) and only then do I go looking for apps that depend on your library.
This inverts classic reversing. In the single-target world I take a beating inside the obfuscated binary. In KMP I learn for free from the clean artifact and apply it to the deployed one. The app becomes just the place where I use what I already understood.
When I do need to find the library inside the app, I pick the most generous target
Here is KMP's unfair advantage on the attacker's side: there are several copies of your logic, at different protection levels, and I pick the loosest one.
On Android, even after R8, the package structure often survives, because plenty of apps do not obfuscate dependency code. io.ktor.*, kotlinx.serialization.*, your library's package, all locatable by prefix in jadx. And there is the @Metadata annotation the Kotlin compiler emits on every class: it carries the original Kotlin metadata, readable via kotlinx-metadata-jvm. Even when R8 renames at the JVM level, if the metadata was not rewritten alongside it, it hands you the original Kotlin signatures back, with what was nullable, what was suspend, what was inline.
On iOS, the Kotlin/Native framework embeds the fully qualified name inside the symbol mangling scheme. So:
nm -gU MyLib.framework/MyLib | grep -i license
class-dump MyLib.framework/MyLib
strings MyLib.framework/MyLib | grep -i tokennm -gU MyLib.framework/MyLib | grep -i license
class-dump MyLib.framework/MyLib
strings MyLib.framework/MyLib | grep -i tokenAn nm filtered by your package name lists the entire public surface for me, with readable names. No R8 in the way, because R8 never even passed through here.
On JS and Wasm, a bundle without aggressive minification already delivers the module structure almost as source code. And if a source map leaked into production (it happens more than you would think), I read your Kotlin back, variable names and all.
The move only KMP hands me: cross-target correlation
This is the part that makes attacking a KMP library qualitatively different. The commonMain logic is the same across the N targets. Only the packaging changes. So I read the logic where it is most exposed (the iOS symbols, or the JS bundle), understand exactly how that validation works, and then attack the same logic on the most hardened target, Android, already knowing what to look for. R8 slows me down on reading. It does not slow me down once I already know the algorithm and only need to locate where it lives. Your Android hardening turned into theater, because the source of truth leaked through the side target.
And your expect declarations give me the index. I see expect fun verifyIntegrity(): Boolean in the common metadata and I know exactly which per-platform actual to go find. The weakest one, naturally.
I am not searching in the dark: your commonMain hands me the table of contents of what exists.
And when none of that is enough, there is the runtime
If the logic only makes sense at execution time, I go for dynamic instrumentation. Frida and objection on a rooted or jailbroken device hook functions at runtime. The classic target, and the one that leads us straight into the defense, is certificate pinning: I hook the function that does the trust validation and make it always return success. Done, your pinning evaporated and I see your traffic in Burp.
Keep that detail, because it matters for the next section: client-side pinning makes the attack more expensive, it does not prevent it. On a device I control, I always have the last word.
The defense, with pinning as the case study
Pinning is the perfect example of the KMP trap, so let us go through it carefully.
The TLS configuration, and therefore the pinning configuration, is per engine in Ktor. Each platform uses a different engine: OkHttp on Android, Darwin on iOS, Js in the browser. And the pinning API of each is different, when it exists at all. You declare a beautiful expect in common:
// commonMain
expect fun httpClient(): HttpClient// commonMain
expect fun httpClient(): HttpClientAnd then you need an actual per platform. This is where theory meets reality.
Android, with OkHttp
The easy one, because you inherit OkHttp's CertificatePinner, which pins the Subject Public Key Info:
// androidMain
import okhttp3.CertificatePinner
actual fun httpClient(): HttpClient = HttpClient(OkHttp) {
engine {
config {
certificatePinner(
CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
)
}
}
}// androidMain
import okhttp3.CertificatePinner
actual fun httpClient(): HttpClient = HttpClient(OkHttp) {
engine {
config {
certificatePinner(
CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
)
}
}
}You test it, it fails nicely when the pin does not match, and you walk away happy, thinking you covered KMP. That is exactly the dangerous moment.
iOS, with Darwin
Here Ktor gives you its own CertificatePinner, in io.ktor.client.engine.darwin.certificates, which implements ChallengeHandler (the NSURLSession delegate). It is configured through handleChallenge, not through a certificatePinner like Android's.
Different detail, same intent:
// iosMain
import io.ktor.client.engine.darwin.certificates.CertificatePinner
actual fun httpClient(): HttpClient = HttpClient(Darwin) {
engine {
handleChallenge(
CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
)
}
}// iosMain
import io.ktor.client.engine.darwin.certificates.CertificatePinner
actual fun httpClient(): HttpClient = HttpClient(Darwin) {
engine {
handleChallenge(
CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
)
}
}It works, and it pins the SPKI in base64 SHA-256, conceptually identical to Android. But it is another class, another API, configured somewhere else.
If you copied the Android block thinking it covered everything, iOS has no pinning at all and compiles beautifully anyway. The compiler will not warn you that it is missing, because to it these are two independent actuals.
A word on a common temptation here: the soft-fail. The idea is to wrap handleChallenge in a try/catch and, if the pin does not match, fall back to normal TLS instead of failing. The skeleton looks like this:
handleChallenge { session, task, challenge, completionHandler ->
try {
pinner.invoke(session, task, challenge, completionHandler)
} catch (e: Exception) {
// pin did not match: fall back to default handling
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, null)
}
}handleChallenge { session, task, challenge, completionHandler ->
try {
pinner.invoke(session, task, challenge, completionHandler)
} catch (e: Exception) {
// pin did not match: fall back to default handling
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, null)
}
}Think for a second about what this does. It is pinning that gives up at the exact instant it should act. The moment the pin does not match, which is the only moment pinning is good for anything, it caves. If you need soft-fail for operational reasons (certificate rotation, for example), fine, keep the concept, but be honest about what it buys you: basically nothing against an attacker. And please do not catch raw Exception, because you then treat any network error as a reason to switch the protection off.
Web, and the part nobody wants to hear
On the web target you simply do not pin. The Js engine uses the browser's fetch, and in the browser the one controlling the TLS stack is the browser, not your code. You have no access to the trust evaluation. There is no pinning API to drop in there.
// jsMain / wasmJsMain
actual fun httpClient(): HttpClient = HttpClient(Js) {
// nowhere to pin. and that is not something you forgot.
}// jsMain / wasmJsMain
actual fun httpClient(): HttpClient = HttpClient(Js) {
// nowhere to pin. and that is not something you forgot.
}HPKP, which was the HTTP-header-based pinning mechanism, was deprecated and removed from browsers long ago, precisely because it caused more self-inflicted wounds than protection. So the honest answer is: on your web target, certificate pinning is not available, period. You defend the web layer with other weapons (well-configured TLS on the server, HSTS, CSP, Subresource Integrity, short-lived tokens, and the premise that the client is hostile), but pinning is not one of them.
What all of this draws is the parity trap. You pin on Android, you feel like you pinned, and in reality you pinned on one platform out of three. commonMain cannot force you to configure this on every target, because the place where it is configured is the actual, which is exactly what it does not see.
The defense against this is not code, it is process: a per-target security parity checklist, and ideally a per-platform test that fails when the expected protection is not there. If it does not fail, you do not have a guarantee, you have hope.
Beyond pinning: the defenses that survive reversing
Pinning solves a specific problem (man-in-the-middle and a compromised CA). It does not solve the bigger problem the .klib anatomy lays bare, which is: your logic is readable. So the principle that organizes everything else is uncomfortable but it is the truth.
Your library's security cannot depend on its logic being secret. The published .klib is fully readable and your worst target leaks the rest. Any scheme that depends on obscurity (a client-side license check, an embedded secret, a "hidden" algorithm) is dead the instant you publish. If it is a real secret, it lives behind a server boundary, end of conversation. The client checks, the server decides.
Practical things follow from this. Minimize the public surface: internal where you can, explicit visibility, do not expose more than the contract needs, because every public symbol is one more line in the attacker's class-dump. Do not treat obfuscation as a security layer, because it is uneven across targets and nonexistent in the published .klib; it raises cost, it does not guarantee. Treat the set of your actuals as a first-class trust boundary, and review each one, because review tends to focus on commonMain ("that is where the logic is") and leave the per-platform actuals with fewer eyes, which is precisely where I, the attacker, swap the behavior the common code trusts blindly. Remember that compiler plugins (serialization, Compose, atomicfu, your own) run inside the compilation process with full access to the IR and inject code that shows up in no source file, so they fall under the same pinning and verification discipline as your runtime dependencies, and the Gradle wrapper along with them, verified by checksum.
And close the shortcut from the start of the article: sign your artifacts and enable dependency verification in Gradle, with checksum and signature verification covering all targets, not just the one you build day to day. Because dependency confusion in KMP is per source set, and the dependency pulled in only by jsMain is the one nobody audits. That way, at least, your consumers can detect a .klib swapped underneath them.
If you use kotlinx.serialization polymorphic deserialization on data coming off the network, prefer closed polymorphism with sealed classes and explicit registration of the allowed set, instead of the open polymorphic { }.
Otherwise, the attacker controls the type discriminator and pushes you a subtype that was never supposed to arrive there.
Closing
The summary is a bit uncomfortable, but it is honest. From the point of view of whoever reverses it, your KMP library does not have one protection level. It has the level of your loosest target, replicated across all the others. You defend N copies of the same logic, and I only need to break the easiest one to understand the N.
The good news is that, knowing this, you can design a defense that does not fool itself.
Stop protecting the logic and start protecting what is actually secret, behind a boundary the client does not control.
Treat parity across targets as a testable requirement, not a good intention.
And when you pin, pin knowing you are making the attack more expensive for whoever is in the middle of the path, not building a vault, because on the attacker's device, with Frida in hand, the last word is never yours.
You write it once. Defend it like someone who knows it will be read N.