Securing Android SDKs: A Defense-in-Depth
Planted September 2, 2026

Every Android SDK eventually asks the same question. How do you trust a request that arrived from a device you do not control, running code you shipped but no longer own, sitting inside a host app you did not write? You cannot patch a client the moment it leaves your build pipeline, and by the time your SDK is running on a few million devices, at least a handful of those devices belong to someone actively trying to take it apart.
The instinct most teams reach for first are a static API key baked into the SDK. It works, right up until someone decompiles the app, greps the strings, and now has your key forever. What follows is usually a scramble: obfuscate harder, rotate the key, add a checksum, repeat. None of it holds, because a single control can always be defeated by a single, matching attack. What actually holds is layering several weak controls so that defeating all of them at once cost more than the attacker is willing to spend. That is the whole idea behind defense in depth, and it is worth working through properly, layer by layer.
What we are actually defending against
Before picking controls it helps to be concrete about the attacker. Four capabilities show up again and again against SDKs specifically.
Static analysis is the cheapest. Tools like apktool and jadx turn an APK back into readable Smali or approximate Java in seconds, and anyone can run them. If your entire security model is a string sitting in a class file, it is already gone.
Dynamic instrumentation is the next step up. Frida let an attacker attach to a live process and hook whatever function they want, reading arguments and return values as the app actually runs. This defeats obfuscation almost by definition, because whatever your code decrypts at runtime to actually use, Frida can watch it get decrypted.
Network replay is a different kind of attack entirely. It does not care how your client works internally, it just capture a legitimate request off the wire and fires it again, or a thousand more times, hoping your backend cannot tell the difference.
Bot and emulator farms are the most expensive to run and the hardest to fully stop. Real hardware, real device farms, or software emulators running your SDK at scale, generating requests that look individually legitimate but are collectively fraudulent.
None of the four layers below stops all four attackers on its own. That is fine. They are not meant to.
Layer 1: obfuscation buys time, not trust
ProGuard and R8 rename your classes and members to short, meaningless identifier, strip unused code, and generally make static analysis slower and more annoying. A commercial tool like DexGuard goes further: string encryption, class encryption, resource obfuscation, and some protection for native libraries too.
None of this touches string literals the way most people assume. R8 does not encrypt constant strings, it only restructures code around them, so a plain string constant are still sitting in the dex file in UTF-8 for anyone who greps for it. That is why the actual fix for a sensitive constant is a runtime decode, not naming convention.
private val encodedEndpoint = byteArrayOf(0x55, 0x1a, 0x57, 0x52, 0x03, 0x4e, 0x5a, 0x54, 0x25)
private val runtimeKey = byteArrayOf(0x7a, 0x6c, 0x66, 0x7d, 0x66, 0x38, 0x3f, 0x3a, 0x51)
private fun xor(data: ByteArray, key: ByteArray): ByteArray =
ByteArray(data.size) { i -> (data[i].toInt() xor key[i % key.size].toInt()).toByte() }
fun endpoint(): String = String(xor(encodedEndpoint, runtimeKey))
The class file never spells the plaintext out directly, so a static grep finds nothing useful. But the value has to exist in memory eventually, the moment endpoint() runs, and that is exactly where Frida attaches. Hook the function, log the return value, done. This is the honest limit of the whole layer: obfuscation delays casual static analysis, it does not authenticate anything and it does not survive a patient attacker willing to instrument a live processes.
The accompanying proguard-rules.pro still matters, it strips logging calls entirely rather then just lowering their level, repackages classes down to single-letter names, and keeps only the public API surface an SDK consumer is meant to call. It is the cheapest layer to ship, requires no server changes, and it should be step one regardless of what else you add.
Layer 2: sign the request, not just the app
Once obfuscation is exhausted as a strategy, the next question is whether a request arriving at your backend were actually produced by your client and has not been altered on the way there. HMAC signing answers exactly that, and only that.
fun sign(method: String, path: String, body: ByteArray, secret: ByteArray): SignedRequest {
val timestamp = Instant.now().epochSecond.toString()
val nonce = UUID.randomUUID().toString()
val bodyHash = sha256Hex(body)
val canonical = canonicalString(method, path, timestamp, nonce, bodyHash)
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret, "HmacSHA256"))
val signature = mac.doFinal(canonical.toByteArray()).toHex()
return SignedRequest(method, path, body, timestamp, nonce, signature)
}
The canonical string is just the method, path, timestamp, nonce and body hash joined together, and the server recompute the exact same HMAC on its side to verify nothing changed in transit. The nonce and timestamp exist purely to stop replay, a server that has already seen a given nonce rejects it outright, and a timestamp outside a freshness window get rejected even if the signature is technically valid.
if (!constantTimeEquals(expected, request.signature)) {
return VerifyResult.Rejected("signature mismatch")
}
if (Math.abs(now - requestTime) > freshnessWindowSeconds) {
return VerifyResult.Rejected("stale timestamp")
}
if (!seenNonces.add(request.nonce)) {
return VerifyResult.Rejected("nonce already used (replay)")
}
That freshness window is a genuine tradeoff and worth calling out explicitly. Set it too tight, five seconds say, and ordinary clock skew between the device and your server start rejecting legitimate traffic. Set it too loose, an hour, and you have handed an attacker a generous window to replay a captured request before it expires. Something in the region of a few minutes is a reasonable default, and it should be a config value, not a hardcoded constant, because you will want to revisit it once you have real telemetry.
The comparison itself uses a constant time equals rather than a plain string comparison, which matters more than it looks. A naive == on two strings can leak timing information about how many leading bytes matched, and over enough requests an attacker can use that timing signal to reconstruct a valid signature byte by byte. It is a small detail, and it is exactly the kind of small detail that turns a solid design into a broken one.
What this layer explicitly does not do is proves the request came from a real instance of your app. It proves the request was produced by whoever holds the secret, and the secret, however you obfuscate it, is embedded in an APK that ships to every device running your SDK. A sufficiently motivated attacker extracts it once and signs whatever they want from then on. Signing stops tampering and stops exact replay. It does not stop a forged client that knows the secret.
Layer 3: prove the device is real
This is where device attestation earns its cost. On Android that means the Play Integrity API, and it worth being precise about what it actually returns, because I got this wrong myself on an early draft of the talk and had to go back and fix it.
The client requests an integrity token bound to a server issued nonce, and hands the raw JWT to your backend, which decodes it by calling Google directly. The decoded payload contain a few distinct sections, and they do not all mean what they sound like they mean.
requestDetails.nonce should be checked first, before trusting anything else in the payload, a mismatched or stale nonce means the token is being replayed and the rest of the fields are irrelevant.
appIntegrity.appRecognitionVerdict tells you whether the app itself is the genuine, Play recognized build, with values like PLAY_RECOGNIZED, UNRECOGNIZED_VERSION, or UNEVALUATED.
deviceIntegrity.deviceRecognitionVerdict are the one people get wrong most often, myself included the first time around. It is a list, not a single tier. A clean device can carry MEETS_BASIC_INTEGRITY, MEETS_DEVICE_INTEGRITY and MEETS_STRONG_INTEGRITY all at once, because they stack rather than replacing each other. Checking this field with equals against a single expected value silently breaks for every legitimate device that happens to carry more than one tier, which in practice is most of them. You need contains.
accountDetails.appLicensingVerdict tell you whether the account obtained the app through Play, LICENSED, UNLICENSED, or UNEVALUATED. This is an entitlement check, not a trust signal about the account, and it is easy to conflate the two.
If the device itself is not considered trustworthy, later fields tend to cascade to UNEVALUATED rather than failing individually, so a baseline check needs to require the positive values explicitly rather than merely the absence of a negative one.
fun meetsProductionBaseline(): Boolean =
appRecognitionVerdict == "PLAY_RECOGNIZED" &&
appLicensingVerdict == "LICENSED" &&
deviceRecognitionVerdict.contains("MEETS_DEVICE_INTEGRITY")
Play Integrity is also, obviously, a Google Play Services API, which mean it does not exist on every device your SDK might run on. Huawei devices without Google Mobile Services need Huawei’s own Safety Detect, part of HMS Core, which covers Huawei hardware only. Samsung offer Knox Attestation, hardware backed on Knox enabled Samsung devices, again a narrower scope than Play Integrity. For everyone else, and there is always an everyone else, you fall back to weaker signals: device fingerprinting, root and emulator heuristics, and a wider server side margin instead of a hard gate. There is no single API that covers every device on the planet, and if your user base spans these markets, building that fallback path deliberately is not optional, it is the actual job.
Layer 4: spend the expensive proof once
Attestation is not something you want to run on every single request. It cost latency, it counts against rate limits, and it is simply overkill to re-prove device integrity for every analytics event your SDK sends. The fix is to spend that expensive proof once, at session start, and convert it into something cheap you can reuse.
The flow is a short handshake. The client attests, send the token to the server, and gets back a short lived session token in exchange.
val verdict = integrityProvider.requestIntegrityToken(nonce)
if (!verdict.meetsProductionBaseline()) {
respond(exchange, 401, "attestation failed baseline check")
return
}
val token = UUID.randomUUID().toString().replace("-", "")
val expiresAt = Instant.now().plusSeconds(tokenTtlSeconds)
From that point on, the session token itself becomes the secret for layer two, the client signs every request with it exactly the way it would with a static key, except this one expires.
fun sign(session: Session, payload: String): SignedRequest =
RequestSigner.sign("POST", "/v1/event", payload.toByteArray(), session.token.toByteArray())
When the token expires, the server start rejecting requests with a clear reason, session_token expired, re-attest, and the client goes back through the handshake to mint a fresh one. In production this TTL is closer to fifteen minutes than fifteen seconds, long enough to amortize the cost of attestation across a real session, short enough that a leaked token has a short shelf life. This is the layer that make the whole system usable, without it you are stuck choosing between attesting on every call, which is too expensive, or attesting once and trusting a static secret forever, which is exactly the problem we started with.
The layers multiply, they do not just stack
The reason this whole design works is that each layer force a genuinely different attacker skillset. Obfuscation defeats casual static analysis but falls to dynamic instrumentation. Signing defeats tampering and simple replay but falls to anyone who extracts the secret. Attestation defeats forged clients but is expensive to run continuously. Session tokens make that expense affordable but rely on the layers underneath to have any teeth in the first place.
An attacker who wants to fully defeat this stack needs static analysis skill, dynamic instrumentation skill, real or convincingly faked hardware, and enough patience to do it all before a fifteen minute token expires. That are a different, much smaller population of attackers than the one who can copy a plaintext API key out of a decompiled APK, and that gap is the entire point.
None of this needs a rewrite to adopt in an existing SDK either. Ship obfuscation first since it needs no server changes at all. Add request signing on the client, then matching verification on the server. Integrate attestation, gated at SDK initialization rather than per event. Cut over to session token, running both the old static key and the new token path in parallel until you can retire the static key safely. Re-tune the freshness window and the token TTL once you have real traffic telling you something, they are dials, not defaults set once and forgotten.
Code you can actually run
Diagrams are convincing right up until someone asks whether a replay really gets rejected. I built a small companion project, security-demo, that run the whole thing end to end on localhost: a plain Kotlin JVM app, no Android SDK or emulator needed, ./gradlew run and you get a signed request accepted, an exact replay rejected, a session token expiring, and a re-attest that gets a fresh one, all over a real HttpServer. Play Integrity itself cannot run outside a real device, so that layer is a mock behind an interface, with the real Android client sitting uncompiled in a reference file for anyone who wants it directly. It is Apache 2.0 and public on my GitHub.
Conclusions
- Obfuscation buys time. It does not buy trust, and a patient attacker with Frida get past it regardless of how the strings are encoded.
- Signing proves a request was not altered in transit and stops exact replay. It does not prove the request came from a genuine client, only that whoever holds the secret produced it.
- Attestation is the layer that actually proves the client is real, and it is expensive enough that you should spend it once per session, not once per request.
- Session tokens is what make that economics work, converting one expensive proof into a currency cheap enough to spend on every call for the life of a session.
- None of the four layers is sufficient alone. Together, they force an attacker to hold four unrelated skillsets at once, and that cost are the actual defense.
I write my thoughts about Software Engineering and life in general on my Mastodon account. If you have liked this article or if it did help you, feel free to share, 👏 it and/or leave a comment. This is the currency that fuels amateur writers.













































