In a React Native app, why can't a jailbreak or root check running on the device be trusted, and how do attackers bypass it?
answer
- the check runs on the attacker's device
- heuristics: su binary, test-keys, known paths
- root-hiding and runtime hooking
- a JS check is one patch away
- a signal, not a verdict
basics
~20 sA root or jailbreak check is a heuristic running on a device the attacker controls, so root-hiding tools, runtime hooking or a patched bundle make it return false. Treat its result as a risk signal, never as a security decision.
solid answer
~40 sClient-side root and jailbreak checks look for traces: an `su` binary in known paths, a `test-keys` build tag, known root or jailbreak apps, writable system locations. Expo's `Device.isRootedExperimentalAsync()` is a typical example, and its own docs call it experimental and not completely reliable. Every check runs **on the attacker's device**, so it can be defeated three ways: tools that **hide** root from chosen apps, **runtime hooking** that makes the detection function return `false`, and **patching** the app — a check written in JavaScript is a string comparison in the bundle that can be edited before the package is re-signed. Checks also produce false positives on some unrooted devices. So a client check is a speed bump and a risk signal. Anything valuable must be decided on the server, ideally backed by platform attestation.
go deeper
Recall that root and jailbreak checks look for traces such as an su binary, and that they run on the user's own device, so they can be fooled.
Explain the three bypasses, hiding, hooking and patching, and why a JavaScript check is especially easy to remove from an extracted bundle.
Position client checks as risk signals feeding a server decision backed by attestation, and weigh their false positives against the little they add against a determined attacker.
Decide how much client hardening to fund versus server-side controls, and how to explain to product and compliance what a root check can and cannot promise.
## What a root or jailbreak check looks at **Rooting** an Android device or **jailbreaking** an iPhone gives the owner privileges the operating system normally withholds: reading other apps' data, modifying system files, injecting code into running processes. Apps that handle money or secrets often try to detect that state. Detection is heuristic: it looks for **traces** a modified device usually leaves. | Platform | Typical traces checked | |---|---| | Android | an `su` executable in known paths, a `test-keys` build tag, known root-management apps, writable system partitions | | iOS | files and apps a jailbreak installs, the ability to write outside the app sandbox, unexpected dynamic libraries loaded into the process | Expo ships one such check. `Device.isRootedExperimentalAsync()` from `expo-device` returns a boolean. On Android it flags a `test-keys` build tag, `/system/app/Superuser.apk` or `/system/xbin/su`; on iOS it runs a set of known jailbreak checks. Its documentation warns that it is not completely reliable, that detection can be bypassed on both platforms, that tools exist to hook every known jailbreak-detection function, and that some unrooted devices also have an `su` executable. ## Why the result cannot be trusted The check runs on hardware the attacker owns, inside a process the attacker can inspect and change. Whatever the function returns is whatever the device lets it return. 1. **Hiding.** Modern root managers can hide root from selected apps, removing the files and properties the check looks for. 2. **Hooking.** Dynamic instrumentation tools attach to the running app and replace a function's return value. One hook on the detection method, native or JavaScript, makes every check pass. 3. **Patching.** A check written in JavaScript lives in the app's bundle. The attacker extracts the bundle, edits the condition, repackages and re-signs the app. 4. **Emulating.** Some attacks run the app in an emulator with its own instrumentation, where "rooted" is not even a meaningful question. Moving the check into native code, adding several overlapping checks, or obfuscating them raises the effort, which is worth something against casual users. It does not change the fact that the final answer is produced on a device you do not control. ## False positives are a cost too Heuristics also misfire. Developer devices, some manufacturers' builds and custom ROMs can look rooted without being compromised in any way that matters for your app. A hard block based on a heuristic therefore locks out some honest users while barely slowing a determined attacker. ## What the result is good for - **A risk signal.** Send it with the request; the server combines it with other signals rather than obeying it. - **User messaging.** Warn users that a rooted device weakens the protections the app relies on. - **Local hygiene.** Avoid caching extra sensitive data on a device that reports itself as modified. ## What to send with the signal If the app reports a root signal, make it useful to the server without over-collecting: - which **category** of heuristic fired (a root binary, a build tag, a hooking library), not a raw list of files on the user's phone; - the **app version** and platform, so the server can tell a buggy check from a real trend; - whether a **platform attestation** accompanied the request and what it said. The server then correlates: a root signal together with a failed attestation on a money transfer is worth acting on; a root signal alone on a balance view probably is not. ## Where the decision belongs A request that moves money or reveals secrets must be judged by the **server**. The server cannot see the device either, so it needs evidence the device cannot forge: **platform attestation** from Play Integrity on Android or App Attest on iOS, verified on the server against the platform's keys, plus the usual server-side controls such as limits and step-up authentication. ## Common interview mistakes - Presenting a library's `isRooted` boolean as a security control. - Believing a native check cannot be bypassed because it is not in JavaScript. - Sending `isRooted: false` to the server and treating that as proof. - Ignoring that re-signing a patched app changes its signature, which is exactly what attestation can detect. The short version: **a client check answers the question the attacker lets it answer.** Use it to inform, and decide on the server.
- Does moving a React Native root check from JavaScript into a native module make it trustworthy?It makes it harder, not trustworthy. The attacker can no longer edit a bundle condition, but runtime hooking replaces a native method's return value just as easily, and root-hiding tools remove the traces before any check runs. Native checks are a cost increase; the decision still belongs on the server.
- If an attacker patches the JavaScript bundle to skip the check and re-signs the app, what changes that the server could notice?The signing identity. A re-signed package no longer carries your signature, so Play Integrity reports an app that Play does not recognise, and on iOS the re-signed app's attestations belong to a different App ID that your server rejects. That is why server-verified attestation catches repackaging that client checks cannot.
A root check on the phone is like asking a visitor to confirm they are not carrying a fake ID: an honest visitor answers truthfully, and the one you worry about says whatever gets them through the door.
saying these in an interview costs you the question
- isRootedExperimentalAsync returning false proves the device is not rooted.
- A check written in native code cannot be bypassed.
- Sending isRooted: false to the server is enough evidence.
- Only rooted devices ever trip a root heuristic.
- Stacking several client checks makes the result trustworthy.