App Integrity & Injection Explained
SECURITY › Security
This topic has one governing rule: **you cannot trust the client**. A device you don't control could be rooted, running under an emulator or hooking framework, or running a tampered copy of your APK. Anything your app checks about itself, on the device, is something an attacker on that same device could patch or fake.
The **Play Integrity API** exists to give you a signal you actually can trust, but only if you use it correctly: it assesses app integrity (is this a genuine, unmodified binary distributed by Play), device integrity (is this a genuine, certified Android device), and account details (does this user hold a legitimate license).
The critical part: the token it returns is encrypted, and must be decrypted and verified on **your backend**, via Google Play, never inside the app. An in-app check of any kind can be bypassed by the very attacker it's meant to catch.
There are two request shapes. **Standard requests** are the default and recommended for most calls: they're low latency, use a request hash tied to the specific action being protected, and Play caches results to keep frequent checks cheap.
**Classic requests** cost more latency and quota, and use a single-use nonce you generate server-side instead of a request hash, they suit infrequent, high-value actions where you want a fresh, unambiguous proof each time.
Either way, the request hash or nonce matters for the same reason: it ties the returned verdict to *this specific request*, so a captured, valid token can't be replayed against a different action later.
val request = IntegrityTokenRequest.builder()
.setNonce(serverNonce) // classic: server-generated, single-use
.build()
integrityManager.requestIntegrityToken(request)
The decrypted payload reports device integrity as a ladder of verdicts, from weakest to strongest:
- MEETS_BASIC_INTEGRITY: passes only basic checks, may be rooted, an emulator, or a custom ROM - MEETS_DEVICE_INTEGRITY: a genuine, Play-Protect-certified Android device - MEETS_STRONG_INTEGRITY: device integrity plus hardware-backed proof and up-to-date security patches - MEETS_VIRTUAL_INTEGRITY: a legitimate, Google-backed emulator (useful to allow in dev or CI, not usually in production)
A device can report multiple verdicts at once (they're cumulative up the ladder), and what you require server-side depends on the sensitivity of the action, a free content feed might accept BASIC, while a payment flow might require STRONG.
So 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 a debugger attached is common, but it has a hard ceiling: the attacker controls the device. Any check running inside the app can be hooked, patched, or have its result overridden by the same tooling that rooted the device in the first place.
fun looksRooted(): Boolean {
val indicators = listOf("/sbin/su", "/system/bin/su")
return indicators.any { File(it).exists() }
}
// trivially defeated by an attacker with root, or a hooking framework
That makes on-device detection a **deterrent, not a guarantee**: it raises the cost and skill required to bypass, and stops casual tampering, but it can never replace a server-side integrity signal for anything that actually matters.
Code obfuscation deserves the same honest framing. Enabling R8 (isMinifyEnabled = true) shrinks unused code and renames classes, methods, and fields to meaningless identifiers. That makes reverse engineering slower and more expensive, real friction for an attacker.
What it does **not** do is encrypt your logic or hide anything at runtime. A determined attacker can still decompile, rename symbols back to something readable, and step through the obfuscated code with enough patience. And critically, it does nothing for a secret baked into a string: an obfuscated method name doesn't obfuscate the literal "sk_live_..." sitting a few lines away.
Treat R8 the same way as root detection: **defence-in-depth that raises 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, letting an attacker's input change the query's meaning rather than just supplying a value.
// vulnerable: user input becomes part of the SQL itself
db.rawQuery("SELECT * FROM users WHERE email = '$email'", null)
The fix is **parameterisation**: pass a ? placeholder in the SQL and supply the value separately, as a bound argument, so the database engine always treats it as data, 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.
**Room** builds this same protection in for you. A @Query annotation with a named parameter like :email gets bound as a real parameter under the hood, not spliced into a string, and Room verifies the SQL against your schema at **compile time**, catching typos and type mismatches before the app ever runs:
@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 supply the SQL as 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, but it hands the responsibility back to you.
Last surface: **WebView**. Any web content you load should be treated as untrusted input, because it's essentially arbitrary code running with whatever privileges you grant 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 on it, including one injected by a compromised ad network or a MITM, can call those methods.
class NativeBridge {
@JavascriptInterface
fun deleteAccount() { /* callable as Android.deleteAccount() from JS */ }
}
webView.addJavascriptInterface(NativeBridge(), "Android")
Harden a WebView by disabling JavaScript unless you genuinely need it, restricting file access, allowlisting the URLs you'll 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.