DEV Community

YADNYESH RANA
YADNYESH RANA

Posted on

Your Root Detection Is Theater: Move Integrity Checks to the Server

Most "root detection" I see in real Android codebases is a isDeviceRooted() function that checks for su binaries, Superuser.apk, or suspicious build tags, then sets a boolean. It ships, it "works" in the demo, and it stops exactly one kind of attacker: the one who didn't bother opening a disassembler.

The uncomfortable truth: any check that runs on the device is a check the device's owner can lie to.

Why local root detection is theater

A root/tamper check written in Kotlin, running inside your own APK, is just more code for an attacker to read. Tools like Frida or Xposed can hook isDeviceRooted() directly and force it to always return false — the attacker doesn't need to actually hide root from your check, they just patch the check itself. The function's result is trusted by your app, and the function lives on hardware you don't control.

// The pattern that doesn't actually stop anyone determined
fun isDeviceRooted(): Boolean {
    val paths = listOf("/system/bin/su", "/system/xbin/su", "/sbin/su")
    return paths.any { File(it).exists() }
}

if (isDeviceRooted()) {
    // an attacker with Frida just hooks this function and returns false
    blockSensitiveAction()
}
Enter fullscreen mode Exit fullscreen mode

This isn't a reason to skip integrity checks — it's a reason to stop trusting the device to grade its own homework.

The fix: let Google attest, let your server decide

Google Play Integrity API produces a token that's generated and signed by Google Play Services, not by your app's own code, and verified on a server the attacker doesn't control. The client's only job is to request the token and forward it — it never gets to declare itself trustworthy.

class IntegrityGate(private val context: Context) {

    private val cloudProjectNumber = 897654321098L // your Play Console cloud project

    fun requestToken(
        requestNonce: String,
        onToken: (String) -> Unit,
        onError: (Exception) -> Unit,
    ) {
        val manager = IntegrityManagerFactory.create(context.applicationContext)
        val request = IntegrityTokenRequest.builder()
            .setCloudProjectNumber(cloudProjectNumber)
            .setNonce(requestNonce)
            .build()

        manager.requestIntegrityToken(request)
            .addOnSuccessListener { onToken(it.token()) }
            .addOnFailureListener(onError)
    }
}
Enter fullscreen mode Exit fullscreen mode

That token then goes to your backend, never gets decrypted or trusted on-device, and your server checks the verdicts inside it before doing anything sensitive (unlocking a paid feature, approving a transaction, issuing a session).

Three things that separate a real integration from a checkbox one

The nonce has to be bound to the specific request, not just "present." If you call setNonce() with a static string or a plain random UUID, an attacker who captures one valid token can replay it against a different request. Hash the actual transaction data — user id, amount, timestamp — into the nonce, so a token minted for one action is cryptographically useless for any other:

fun nonceFor(userId: String, amountCents: Long, ts: Long): String {
    val payload = "$userId:$amountCents:$ts"
    val digest = MessageDigest.getInstance("SHA-256").digest(payload.toByteArray())
    return Base64.encodeToString(digest, Base64.NO_WRAP or Base64.URL_SAFE)
}
Enter fullscreen mode Exit fullscreen mode

Your server then recomputes that same hash from the request it actually received and rejects the call if it doesn't match the nonce inside the verified token.

MEETS_DEVICE_INTEGRITY and MEETS_STRONG_INTEGRITY are different bars, and conflating them is a common mistake. The weaker one confirms the OS is Play-Protect-certified and passes CTS; the stronger one additionally confirms a hardware-backed keystore (TEE) is active. Gating "unlock premium content" behind the weak verdict and "approve a payment" behind the strong one is a reasonable split — using the same bar for both usually means either annoying legitimate users on older-but-fine hardware, or under-protecting the transaction that actually matters.

Tokens expire in minutes, and that's a feature you have to design around, not fight. A Play Integrity token is only valid for a few minutes after minting, deliberately, so you can't cache one and reuse it as a standing credential. That means requesting a fresh token per sensitive action (login, purchase, privileged setting change) — not once at app startup — which also keeps the ~1-2KB token off the wire for the 95% of API calls that don't need it at all.

Where this stops being enough

Play Integrity assumes Google Mobile Services exist on the device, which isn't universal — HarmonyOS devices, some custom ROMs, and GMS-less tablets can't request a token at all. A production integration needs a fallback path (typically hardware-backed Android Keystore key attestation) for that slice of users, or they get silently locked out of anything gated behind the check.

I ended up writing the full attestation architecture — server-side verdict parsing, the GMS-less fallback chain, replay-attack protection, and where this fits alongside root detection, static analysis resistance, and network hardening in a real threat model — as part of a longer security handbook, since none of it fits cleanly into one post. If you want the deeper version: Android Security, Hardening & Reverse Engineering Handbook.

Either way — if your integrity check runs entirely on-device and reports its own verdict, that's the signal to move it server-side. The attacker you're defending against already has a debugger; the only check that survives contact with one is a check they can't patch.

Top comments (0)