Fraud in mobile apps often runs from devices that are not what they seem: an emulator driven by a script, a rooted or jailbroken phone with tooling attached, or a cloned copy of your app. The Fingerly iOS and Android SDKs, and the React Native, Flutter and Lynx packages over them, collect raw input about the environment. The server derives a signal for each finding and adds it to the suspect score.
These are signals for your own policy, not verdicts. A rooted phone is not a fraudster, and a clean device is not proven honest. Fingerly never blocks anyone on its own.
What the signals tell you
| Finding | Platform | Signal group key | Default weight |
|---|---|---|---|
| Emulator | Android | android_emulator | 9 |
| Simulator | iOS | ios_simulator | 16 |
| Rooted device | Android | rooted_device | 12 |
| Jailbroken device | iOS | jailbroken_device | 10 |
| Cloned or repackaged app | Android | cloned_app | 9 |
| Instrumentation attached | Android and iOS | frida_detected | 14 and 14 |
| Intercepted app traffic | Android and iOS | mitm_attack | 14 and 14 |
| Debugger or developer tooling | Android and iOS | developer_tools | 8 and 16 |
| Mass-provisioned or freshly reset device | Android and iOS | device_farm | 8 and 8 |
| No hardware proof offered | Android and iOS | attestation_missing | 3 and 3 |
The weights are the shipped defaults, listed in the signals reference, and you can change them with risk weights. A weight of 9 means the signal adds 9 points to the suspect score when it fires. With the default threshold of 30, the level becomes medium from 15 points and high from 30.
Where to read them
- The stored event, read with a secret key, lists the groups that fired in
triggers, heaviest first. See Get an event. - The
visitor.suspectwebhook names the individual signal and its group when a session reacheshigh. - The app is never told which signals fired. It can read
suspectLevelunless you hide the score on the key, as a convenience and never as the decision.
const MODIFIED_DEVICE = [
'android_emulator',
'ios_simulator',
'rooted_device',
'jailbroken_device',
'cloned_app',
'frida_detected',
'mitm_attack',
]
const event = await fingerly.events.get(requestId)
const found = event.triggers.filter((trigger) => MODIFIED_DEVICE.includes(trigger.signal))
if (found.length > 0) return stepUp() // ask for a second factor, or hold for review
To make one of these decisive, read it from event.triggers as above, or raise its weight in risk weights so the level moves on its own. See signals that matter for account takeover for a policy that uses them.
Prove the report comes from your app
The signals are derived from raw input, and a modified app can forge raw input. Hardware attestation is what resists that: on Android, a key in the device keystore attests to a fresh challenge, and on iOS, App Attest has Apple vouch for the app. A failed check raises the tampering signal, which appears on stored events under browser_tampering.
- Android works without configuration on Android 7.0 (API 24) and newer. List your package names and signing certificates on the key for the server to check the attestation names your app.
- iOS needs the App Attest capability and your Apple app ID on the key.
- A key can require attestation, so a report that does not prove itself is refused with
403instead of scored. Simulators and emulators are refused on such a key. Turn it on only once every device you need to serve can attest.
See mobile app attestation for the states and for what each failure means.
Optional checks you can switch on
- iOS jailbreak apps. To let the SDK check for well-known jailbreak apps, declare their URL schemes in
LSApplicationQueriesSchemes. The SDK only queries the schemes you declare, and reports only how many answered. Without any, the report says it could not ask, so a zero is never read as nothing found. See Optional: jailbreak app checks. - Android packages. The SDK's manifest declares a
<queries>list of 17 root, hooking and cloning packages. It never requestsQUERY_ALL_PACKAGES, it reports other apps only as yes or no and never by name, and you can remove entries. See package visibility.
Test it
- Run your app in an Android emulator or the iOS Simulator with a development key. The event shows
android_emulatororios_simulatorin Identification > Events. - Replay recent events against a policy change before you enforce it. See test your integration.
- Start in observe mode: log what you would have done for a week or two, then add friction before you refuse anyone.
Common questions
How do I detect an emulator in my mobile app?
Identify with the SDK at the action you protect, then read the stored event on your server. android_emulator and ios_simulator appear in triggers when the app runs in one. The app itself is never told which signals fired.
How do I detect a rooted or jailbroken device?
The same way. rooted_device is reported on Android and jailbroken_device on iOS, each with a default weight you can change. The server derives them from the raw input the SDK sends.
Can these checks be bypassed?
A determined attacker with a modified app or device can hide what the SDK reads, which is why attestation is the evidence to add. It raises the cost of forging a report, and it does not make forgery impossible. Treat each answer as evidence.
Does it work in React Native and Flutter apps?
Yes. Those packages are thin bridges to the native SDKs, so the same signals are reported on each platform.
Does the SDK show a permission prompt for these checks?
No. No SDK shows a permission prompt or a dialog. On Android, the permissions it declares are granted at install time. On iOS, no usage description is required.
Can Fingerly detect device farms?
The device_farm group fires when a device looks mass-provisioned or freshly reset, or shares traits with many devices at once. Its cross-device part compares a device with your own traffic and adds nothing to the score until there are at least 1,000 identifications and 7 days of history.