App Integrity & Injection Explained

SECURITY › Security

Every question in this topic comes back to one governing rule: you cannot trust the client. A device you do not control might be rooted, running inside an emulator or a hooking framework, or carrying a tampered copy of your APK. Any check your app performs about itself, on that same device, is something an attacker on that device can patch, hook, or fake before you ever see the result.

The Play Integrity API gives you a signal you can actually trust, but only if you use it correctly. It assesses three things. App integrity: is this a genuine, unmodified binary that Play itself distributed. Device integrity: is this a genuine, certified Android device rather than an emulator or a rooted phone. Account details: does this user hold a legitimate Play license for the app.

The part interviewers actually probe is what happens to the token it returns. That token is encrypted, and it can only be decrypted and verified on your backend, through Google's own servers, never inside the app. That is the whole point: no matter how carefully you write an in-app check, it runs on hardware the attacker controls, so it can be bypassed by the very attacker it exists to catch.

val integrityManager = IntegrityManagerFactory.create(context)
val request = IntegrityTokenRequest.builder()
    .setNonce(serverNonce)
    .build()
integrityManager.requestIntegrityToken(request)
    .addOnSuccessListener { response ->
        myBackend.verifyToken(response.token()) // decryption happens on the server
    }

Play Integrity supports two request shapes, and picking the right one is itself something interviewers ask about. Standard requests are the default for most calls: they're low latency, they use a request hash tied to the specific action being protected, and Play caches results so frequent checks stay cheap.

Classic requests cost more latency and quota. Instead of a request hash you generate a single-use nonce on your server, and you send a fresh one for every call. That trade-off suits infrequent, high-value actions, a large withdrawal or an account takeover flow, where you want an unambiguous, uncached proof each time rather than a cheap, frequent one.

Either shape, the hash or the nonce exists for the same reason: it ties the returned verdict to this specific request. Without it, an attacker who captured one valid token could replay it against a completely different action, a token proving 'user requested their profile' could otherwise be reused to authorise a payment.

val request = IntegrityTokenRequest.builder()
    .setRequestHash(actionHash) // standard: ties the verdict to this exact action
    .build()

The decrypted payload reports device integrity as a ladder of verdicts, from weakest to strongest, and a device can report more than one at once since they stack cumulatively on the way up.

MEETS_BASIC_INTEGRITY is the floor: it passes only basic checks and may still be rooted, an emulator, or running a custom ROM. MEETS_DEVICE_INTEGRITY means the device is genuine and Play Protect certified, an actual retail Android phone rather than a fake or virtualised one. MEETS_STRONG_INTEGRITY sits above that: it adds hardware-backed proof and confirms the device is running up to date security patches, the strongest assurance the API gives you.

MEETS_VIRTUAL_INTEGRITY sits off to the side rather than on top of the ladder: it identifies a legitimate, Google-backed emulator, useful to allow during development or CI, but not something you would normally accept in production. What you require server-side should scale with the sensitivity of the action, a free content feed might accept basic integrity, while a payment or account recovery flow should require strong.

Two more pieces of the decrypted payload come up often in interviews. The first is appRecognitionVerdict, which reports on the app itself rather than the device: PLAY_RECOGNIZED means the running binary and its signing certificate match exactly what Google Play distributed. UNRECOGNIZED_VERSION means it's signed by the developer but isn't the specific build Play shipped, and UNEVALUATED means no verdict could be produced. Licensing and account status are reported separately from this field, so app recognition alone doesn't tell you whether the user has paid for anything.

The second is history: Play Integrity is the successor to the SafetyNet Attestation API, which Google deprecated and eventually shut down. The migration matters for interviews because the concepts carried over, app and device attestation checked server-side, but the newer API added the request hash and classic-request nonce model, split device integrity into a ladder of verdicts instead of one pass or fail signal, and is the one you should reach for in any code written today.

Given all that, why not just detect root or tampering directly in the app and refuse to run? You can. Checking for su binaries, known root-management packages, or an attached debugger is common practice, and it isn't worthless.

fun looksRooted(): Boolean {
    val indicators = listOf("/sbin/su", "/system/bin/su")
    return indicators.any { File(it).exists() }
}

But it has a hard ceiling. The attacker controls the device, so any check running inside the app can be hooked, patched, or have its return value overridden by the same tooling that rooted the device in the first place. A check like the one above is trivially defeated by a hooking framework that simply forces looksRooted() to return false.

That makes on-device detection a deterrent, not a guarantee. It raises the cost and skill required to bypass casual tampering, and it stops the least sophisticated attackers, but it can never replace a server-side integrity signal for anything that actually matters.

Code obfuscation deserves the same honest framing as root detection. Enabling R8, isMinifyEnabled = true in the release build type, shrinks out unused code and renames classes, methods, and fields to short, meaningless identifiers. R8 is the default build-time optimizer for Android and effectively replaces the older, separate ProGuard toolchain; you don't add a dependency for it, you just turn it on.

android {
    buildTypes {
        release {
            isMinifyEnabled = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

What R8 does not do matters just as much. It doesn't encrypt your logic or hide anything at runtime, a determined attacker can still decompile the APK, rename the meaningless symbols back to something readable, and step through the flow with enough patience. It also does nothing for a secret baked into a string literal: an obfuscated method name doesn't obfuscate the literal API key sitting a few lines below it. Treat R8 the same way as root detection, defence-in-depth that raises attacker cost, not a security boundary on its own.

Switching from client trust to data safety: SQL injection. It happens when untrusted input gets concatenated directly into a SQL string, so the input can change what the query means instead of just supplying a value for it to compare against.

// vulnerable: user input becomes part of the SQL itself
db.rawQuery("SELECT * FROM users WHERE email = '$email'", null)

The fix is parameterisation. You put a question mark placeholder in the SQL and supply the value separately, as a bound argument, so the database engine always treats it as data and never as executable SQL, no matter what characters it contains.

db.rawQuery("SELECT * FROM users WHERE email = ?", arrayOf(email))

The same rule applies to SQLiteDatabase.query()'s selectionArgs parameter: never build the WHERE clause by string-concatenating a variable into it, even if it feels like it would just add one more condition.

Room builds this same protection in for you, which is why interviewers like asking how it differs from raw SQLite. A @Query annotation with a named parameter like :email gets bound as a real parameter under the hood instead of being spliced into a string, and Room verifies that SQL against your schema at compile time, catching typos and type mismatches before the app ever ships.

@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE email = :email")
    fun findByEmail(email: String): User?
}

The one place this protection doesn't automatically apply is @RawQuery, where you hand Room a raw SupportSQLiteQuery yourself. If you build that string by concatenating input, you've reintroduced the exact vulnerability Room otherwise protects you from. @RawQuery isn't unsafe by itself, it just hands the responsibility for safe SQL back to you.

None of this works if you assume the client is well-behaved. Client-side validation, checking that an email field looks like an email, or that a price field isn't negative, is a UX convenience, not a security boundary. A modified client, someone's own build of your app, a proxy replaying edited requests, or a raw curl command, can skip every on-device check and call your API directly with whatever payload it wants.

That's why server-side input validation is still required even when the Android client already validates the same input before sending it. The server is the only place you can actually trust, because it's the only code you fully control; everything the client sends should be treated as untrusted until the server has checked it, no matter how carefully the app validated it first.

Last surface: WebView. Any web content you load should be treated as untrusted input, because it's effectively arbitrary code running with whatever privileges you've granted it.

The highest-risk single API is addJavascriptInterface: it exposes native Kotlin methods directly to JavaScript running on the loaded page. If that page isn't fully trusted, any script running on it, including one injected by a compromised ad network or a man-in-the-middle, can call those exposed methods.

class NativeBridge {
    @JavascriptInterface
    fun deleteAccount() { /* callable as Android.deleteAccount() from JS */ }
}
webView.addJavascriptInterface(NativeBridge(), "Android")

Since API 17, only public methods explicitly annotated with @JavascriptInterface are reachable from the page at all; that annotation closed an older reflection-based remote-code-execution hole where any public method, including ones never meant to be exposed, was callable from JavaScript by name. Everything else on the bridge class stays invisible to the page no matter how the object is exposed.

Harden a WebView further by disabling JavaScript unless you genuinely need it, restricting file access so pages can't reach into app-private storage, allowlisting the URLs you'll ever load, and only ever bridging methods to fully trusted, first-party content. Combined with server-side Play Integrity checks and parameterised queries, that closes out the practical hygiene this whole topic is built around.

Back to App Integrity & Injection