When we think about cyber attacks, we usually think about someone targeting a backend system or an API gateway, but in my experience with securing mobile apps, It occurred to me that user’s the device itself could also serve as an entry point.
A legitimate user might open your app on a normal device, while a potential attacker, might be using:
- A rooted device
- running an emulator at scale
- A custom or modified Android ROM
- An unlocked bootloader
- Frida, Xposed, or another hooking framework
- A modified application
- Scripts that automate account creation or transactions
None of these signals on their own mean that a user has malicious intent, someone might root their phone because they like tinkering with Android, or run an emulator simply for development purpose. But when several of these signals appear together, especially alongside suspicious behaviour, they can tell you something important about the environment your app is running in.
A compromised or heavily modified device can give an attacker more control over your application than you would normally expect. They may be able to inspect runtime behaviour, modify values in memory, bypass checks, automate actions, or manipulate parts of the app that were never designed to be touched.
That was the problem I wanted to explore when I built DeviceTrust, how can an Android app collect these signals and use them as part of a broader fraud-prevention system?
DeviceTrust is an open-source Android library written with Kotlin and C++. It collects low level integrity signals and converts them into a risk assessment.
implementation("com.github.Xheghun:DeviceTrust:0.1.2")
This article covers what I learned while building DeviceTrust, and where device integrity fits into a real world fraud prevention strategy.
Root detection is not a black and white problem
An easy mistake to make when implementing root detection is to treat it as a simple yes-or-no check:
if (isRooted()) {
blockUser()
}
Sure, it looks reasonable, but there are two problems with this approach.
First, a single boolean becomes an obvious target. If an attacker can find the method responsible for that decision,using tools like Frida they may be able to hook it or patch the return value. Suddenly, true becomes false, and your root detection is having a very bad day.
The second problem is that root detection is not always certain.
A file or package commonly associated with root access could exist on a development or heavily customised device. On the other hand, modern root-hiding tools can deliberately hide many of the artefacts applications normally look for, so not finding evidence of root does not automatically mean the device is trustworthy.
A better approach is to collect several independent signals, something like:
Root artifact found +40
Suspicious mount detected +35
Hooking framework mapped +50
Unlocked bootloader +35
SELinux running in permissive mode +45
The signals can then be evaluated using a risk policy:
when (assessment.level) {
TrustLevel.LOW_RISK -> allowNormalFlow()
TrustLevel.REVIEW -> requireAdditionalVerification()
TrustLevel.HIGH_RISK -> sendForServerReview()
}
The goal is not to answer if the device rooted with absolute certainty, it is to understand whether the environment looks risky enough that you should handle a sensitive operation differently.
Reason behind native C++?
A lot of basic root detection implementations rely entirely on Java or Kotlin APIs, simple checking if a folder exists
File("/system/xbin/su").exists()
There is nothing wrong with this, but it is fairly easy to observe and intercept with hooking or instrumentation frameworks.
This is one of the reasons DeviceTrust uses the Android NDK.
Part of the detection logic runs in native C++ and performs lower-level checks using Linux system calls. Native code is not magically impossible to hook or bypass, but it gives us another layer to work with and makes some forms of tampering a little less straightforward.
The native layer looks at things such as:
- Known root-related file paths
/proc/self/mountinfo/proc/self/maps/proc/self/status- Kernel command-line parameters
- Android system properties
- SELinux enforcement state
The native layer also searches memory mappings for indicators associated with Frida, Xposed, LSPosed, Zygisk, and similar frameworks.
Native code makes analysis and bypassing harder, but it is not an impenetrable security boundary. An attacker with enough control over the operating system could still modify native code, intercept JNI calls, or even manipulate the results your app reads from procfs.
Emulator detection requires multiple signals
Emulator detection has a similar problem.
Checking only something like Build.MODEL or Build.FINGERPRINT is not enough. Those values can be changed, and legitimate devices sometimes report values that may look unusual.
Instead, an emulator may reveal itself through a combination of signals such as:
- QEMU or goldfish device nodes
- Ranchu hardware properties
- Virtual kernel parameters
- Generic build fingerprints
- Emulator product names
- Hardware information that does not quite add up
Again, context matters.
One strange value should not be enough to label a device as an emulator, let alone block the user. But when several independent signals start pointing in the same direction, you can have much more confidence in that assessment.
Emulators are also not inherently malicious. Developers, automated testing systems, accessibility tools, and security researchers use them legitimately.
What you do with that information should depend on the application and the action being performed. A game might prevent an emulator from joining competitive matches, while a banking app might simply ask for additional verification before allowing a transfer.
Custom ROM and boot integrity signals
Custom ROM detection presents similar challenges. A custom ROM does not necessarily mean that the user is committing fraud.
However, several system properties remain relevant when evaluating the environment:
- Bootloader lock state
- Android Verified Boot state
- Release keys versus test keys
- Engineering or
userdebugbuilds - SELinux enforcing versus permissive mode
An unlocked bootloader may increase risk because the operating system can be replaced or modified. It should still be treated as one signal rather than conclusive evidence of malicious activity.
Avoid overcounting correlated evidence
Another thing I had to account for was correlated signals.
Say an emulator triggers five different checks. It might be tempting to give each one its full score and conclude that the device is extremely risky. The problem is that those five signals may all be telling you the same thing.
If they all come from the same underlying condition, counting each one at full weight can make the risk score look much worse than it really is.
DeviceTrust applies diminishing weight to signals within the same category while retaining the full value of independent evidence from different categories.
In other words, five clues pointing to the same thing should not necessarily count the same as five clues pointing to five different problems.
Conceptually:
First emulator signal: 40 points
Second emulator signal: 30 / 2 points
Third emulator signal: 20 / 3 points
This produces a more balanced result and reduces the effect of noisy signal families.
Thresholds should also be tuned using real application data rather than copied blindly from another product.
Client-side checks are not sufficient
This was probably the most important lesson I took away from building DeviceTrust:
The client should collect evidence, but the server should make sensitive authorization decisions.
No matter how many checks you add on the device, you still have to assume that a determined attacker may control the environment your app is running in. They may be able to modify the application process, tamper with local results, intercept network requests, or change data before it ever reaches your backend, so instead of asking the client to decide whether a sensitive action should be allowed, treat the signals it collects as evidence that the server can use alongside other information, such as:
- Server-verified Play Integrity verdicts
- A server-issued challenge
- Request and transaction context
- Account age and history
- Recent device activity
- Velocity limits
- Session and authentication strength
- Known fraud patterns
- Behavioral signals
Freshness matters too.
A device that passed an integrity check when the app launched should not automatically be trusted to perform a sensitive transaction several hours later. For high-risk operations, the evidence should be recent and tied to the action being performed.
Respond proportionately to risk
Detecting risk is only half the problem. The next question is what should you actually do with that information?
Immediately disabling an account because a device looks rooted is usually too aggressive. You could end up blocking legitimate users and creating an account recovery headache for both the user and your support team.
A better approach is to make the response match the level of risk.
Low risk
- Continue normally
- Record minimal aggregate telemetry
Medium risk
- Require biometric or password re-authentication
- Request an OTP
- implement transaction limits
- Delay a high-value operation
- Require additional server verification
High risk
- Block the sensitive operation
- End the current session
- Require account recovery
- Send the event for fraud review
Whenever possible, restrict the operation rather than permanently punishing the account. Device integrity describes the current environment, not necessarily the intent of the account owner.
Privacy matters
There is another side to collecting all of this device information: privacy.
Device security signals can quickly become sensitive telemetry, especially once they are stored alongside an account, session, or transaction.
Before collecting a signal, it is worth asking:
- Do we actually need this signal?
- Do we need the raw value, or would a simple category be enough?
- How long should we keep it?
- Who should be allowed to access it?
- Does it need to be tied to a user or transaction?
- Have we clearly documented why we collect it?
DeviceTrust does not rely on persistent device identifiers, and I think that distinction matters.
Fraud prevention should help you understand whether an environment looks risky.
Design the library around evidence, not policy
Different applications have different risk tolerances. For this reason, DeviceTrust separates evidence collection from policy evaluation.
val evidence = deviceTrust.collectEvidence()
Applications can use the default policy:
val deviceTrust = DeviceTrust.create()
Or configure different thresholds:
val deviceTrust = DeviceTrust.create(
policy = DefaultTrustPolicy(
reviewThreshold = 30,
highRiskThreshold = 70,
)
)
This separation allows the detection layer to remain reusable while each product defines its own response strategy.
Final lessons
Building DeviceTrust changed how I think about device integrity.
The biggest lesson is that there is rarely a single signal that tells you, with complete certainty, whether a device can be trusted. Most of the time, you are collecting clues and trying to understand the bigger picture.
A few things I took away from building it:
- Device integrity is probabilistic, not absolute.
- Several independent signals tell you far more than a single isRooted() boolean.
- Native checks can make bypassing harder, but they do not make the client trustworthy.
- A rooted device, emulator, or custom ROM does not automatically mean fraud.
- False positives matter just as much as missed detections when choosing weights and thresholds.
- The client should collect evidence, while sensitive authorization decisions should stay on the server.
- Device signals become much more useful when combined with account, behavioral, transaction, and platform-integrity signals.
- Your response should match the level of risk. Not every suspicious signal needs the security equivalent of flipping the table.
- Security telemetry needs clear rules around privacy, access, and retention.
- And perhaps most importantly: build every client-side defense knowing that, given enough time and motivation, someone may eventually find a way around it.
That does not make client-side security useless. It just means its job is to make attacks harder, provide useful evidence, and give the rest of your fraud system more information to work with.
DeviceTrust is available on GitHub.
