All articles Mobile

Android Device Fingerprinting in Kotlin: A Practical Guide

Mobile apps feel safer than the web because users can’t open dev tools. In practice, Android fraud has its own toolkit: rooted phones, emulators running dozens of instances, Frida scripts that patch your checks at runtime, and repackaged APKs with your security removed. A device signal that only lives in the app is a signal an attacker can switch off.

This guide integrates the Prynt Android SDK in Kotlin with the right split: the app collects, your server decides.

Add the SDK

The Android SDK is a Kotlin library with minSdk 21. Add it as a Gradle module or AAR from the Prynt repository (packages/mobile-android); the SDKs page has the current instructions. Then create one instance, typically in your Application class or DI graph:

import dev.prynt.Prynt

class App : Application() {
    lateinit var prynt: Prynt
    override fun onCreate() {
        super.onCreate()
        prynt = Prynt(this, "pk_live_…", "https://api.pryntid.com")
    }
}

Only the public key (pk_…) goes in the app. The constructor rejects anything else. Your secret key (sk_…) never ships in an APK, because anything in an APK can be extracted.

Identify at the moments that matter

identify runs off the main thread and calls back with Success or Failure. Tag the event with the action so you can tell sign-ups from logins in the console:

prynt.identify(tag = mapOf("action" to "signup")) { outcome ->
    when (outcome) {
        is PryntOutcome.Success -> submitSignup(form, requestId = outcome.result.requestId)
        is PryntOutcome.Failure -> submitSignup(form, requestId = null)
    }
}

The callback runs on the SDK’s own background thread, not the main thread, so switch to the main thread before you touch any views. If you’re already in a coroutine, blockingIdentify is the synchronous version. Run it on an IO dispatcher:

suspend fun identifyFor(action: String, userId: String? = null): String? =
    withContext(Dispatchers.IO) {
        runCatching { app.prynt.blockingIdentify(mapOf("action" to action), userId).requestId }
            .getOrNull()
    }

// login
val rid = identifyFor("login", userId = null)
api.login(email, password, pryntRequestId = rid)

On failure, send null and let the server apply its missing-id policy. Never block the user in the app because identification failed; a network hiccup is not fraud.

The IdentifyResult also contains visitorId and confidence, but don’t send those as proof of anything. Send the requestId. Your server fetches the authoritative result from Prynt, where the app can’t alter it.

What the SDK collects

The SDK gathers raw tells and leaves the decision to the server:

  • Identity: a persisted device id, ANDROID_ID and a device fingerprint.
  • Root: su binaries, Magisk, test-keys builds, dangerous system properties, a writable /system, root manager apps, busybox.
  • Instrumentation: Frida’s default port, Frida or gum libraries in the process maps, gum threads, a tracer attached.
  • Emulator: QEMU, goldfish and ranchu images, Genymotion, missing sensors, emulator build fingerprints.
  • Cloned or side-loaded app: installer package, signing certificate hash, package name, debuggable flag.

That last group catches repackaged APKs: someone decompiles your app, strips your checks, re-signs it and distributes it. The signing certificate won’t match yours.

Read the verdict on your server

Your backend fetches the event and reads the mobile Smart Signals alongside the usual decision:

// Ktor / Spring backend, or any JVM server; plain HTTP shown for clarity
val req = HttpRequest.newBuilder(URI("https://api.pryntid.com/v1/events/$requestId"))
    .header("Authorization", "Bearer ${System.getenv("PRYNT_SECRET_KEY")}")
    .build()
val ev = JSONObject(http.send(req, HttpResponse.BodyHandlers.ofString()).body())

val ss = ev.getJSONObject("smartSignals")
val rooted = ss.optJSONObject("rooted")?.optBoolean("result") == true
val frida = ss.optJSONObject("frida")?.optBoolean("result") == true
val emulator = ss.optJSONObject("emulator")?.optBoolean("result") == true
val cloned = ss.optJSONObject("clonedApp")?.optBoolean("result") == true
val accounts = ev.getJSONObject("accountsOnDevice").getInt("count")
val decision = ev.getString("decision")   // allow | challenge | block

There is also a Java server SDK, plus Node, Python, Go, PHP, Ruby and .NET, if you’d rather not hand-roll the call. The reason codes you’ll see on mobile events are ROOTED_OR_JAILBROKEN, INSTRUMENTATION, CLONED_APP and FAILED_ATTESTATION, plus VIRTUAL_MACHINE for emulators (the mobile emulator result also feeds the cross-platform virtualMachine signal) and the usual network and behaviour codes.

After a successful sign-up, link the account so the next sign-up from the same phone sees it:

PUT /v1/events/{requestId}
{ "linkedId": "<your user id>" }

Choose responses per signal

Not every flag deserves a block. A reasonable starting policy:

SignalTypical response
INSTRUMENTATION (Frida)Block sensitive actions; almost never legitimate in production
CLONED_APPBlock; this isn’t your app
FAILED_ATTESTATIONBlock or require strong verification
VIRTUAL_MACHINE (emulator) on signupStep up; block if combined with MULTI_ACCOUNT
ROOTED_OR_JAILBROKENAllow browsing, step up for payments and payouts

Rooted phones are the case to be careful with. Enthusiasts root their devices for legitimate reasons. Blocking them outright generates angry reviews. Gating only money movement on a rooted device is usually the right balance. The details are in detecting rooted Android devices.

Add Play Integrity

Prynt’s tells catch common setups, but a determined attacker with a hidden root can mask many of them. Google’s Play Integrity API adds a hardware-backed attestation. Prynt doesn’t call Google for you from the app; you provide a token through a hook, and the SDK forwards it with each identification:

import dev.prynt.PlayIntegrity

PlayIntegrity.provider = { nonce ->
    // Request a Play Integrity token for `nonce` with your Cloud project number.
    // Return the token string, or null on failure. Called on a background thread.
    obtainPlayIntegrityToken(nonce)
}

If no provider is set, attestation is simply absent and identify still works. When a token is present but fails verification, the event carries FAILED_ATTESTATION. Setup on the Google side is covered in our Play Integrity guide.

Cross-platform apps

If your app is built with Flutter or React Native, use the Prynt SDKs for those frameworks instead. See fraud detection in Flutter. Because mobile and web events land in the same account and share linkedId, a user who signs up on the web and logs in on Android shows up as one account across two devices, which is exactly what you want for takeover detection.

Ship it in stages

Release the SDK with identification on sign-up and login, and log the server verdicts without acting on them for a release cycle. You’ll learn how many of your users are rooted, how many sign-ups come from emulators, and whether any repackaged builds of your app are in circulation. Then turn on the responses that matter. The device fingerprinting page lists the mobile signals by plan.

Try it free

Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.

Keep reading