androidengineers.Book a session

Security Architecture

Play Integrity/SafetyNet Basics

article20 minHard

Play Integrity API (successor to SafetyNet Attestation) lets your server verify that a request comes from a genuine, unmodified version of your app running on a certified Android device. It protects against bot attacks, cheating, and API abuse.

What Play Integrity Verifies

A Play Integrity token contains three verdicts:

  1. App integrity: Is this a genuine, unmodified APK from Google Play?
  2. Device integrity: Is this device certified (passes CTS), not rooted, not an emulator?
  3. Account details: Is the user's Google account licensed for this app?

Requesting a Token

// implementation("com.google.android.play:integrity:1.3.0")

class IntegrityChecker(private val context: Context) {
    private val integrityManager = IntegrityManagerFactory.create(context)

    suspend fun getIntegrityToken(requestHash: String): String = suspendCoroutine { cont ->
        val request = IntegrityTokenRequest.newBuilder()
            .setNonce(requestHash)  // should be a hash of the request content
            .build()

        integrityManager.requestIntegrityToken(request)
            .addOnSuccessListener { response ->
                cont.resume(response.token())
            }
            .addOnFailureListener { exception ->
                cont.resumeWithException(exception)
            }
    }
}

Server-Side Verification

The token is opaque — it must be sent to your server for verification via the Play Integrity API:

Client:
  1. Generate nonce = SHA256(request_body + timestamp + user_id)
  2. Request token with this nonce
  3. Send API request + token to your server

Server:
  1. POST token to: https://playintegrity.googleapis.com/v1/{package}:decodeIntegrityToken
  2. Parse verdicts: appIntegrity, deviceIntegrity, accountDetails
  3. Verify nonce matches the request (anti-replay)
  4. Accept or reject based on verdict thresholds
// Retrofit interface for sending token to your backend
interface ApiService {
    @POST("api/protected-action")
    suspend fun performAction(
        @Body request: ActionRequest
    ): ActionResponse
}

data class ActionRequest(
    val actionData: String,
    val integrityToken: String,     // Play Integrity token
    val nonce: String               // client-generated nonce
)

// Usage in ViewModel
class PurchaseViewModel(
    private val integrityChecker: IntegrityChecker,
    private val apiService: ApiService
) : ViewModel() {
    fun purchaseItem(itemId: String) = viewModelScope.launch {
        val requestData = "purchase:$itemId:${System.currentTimeMillis()}"
        val nonce = sha256(requestData)

        val token = try {
            integrityChecker.getIntegrityToken(nonce)
        } catch (e: IntegrityServiceException) {
            handleError(e); return@launch
        }

        apiService.performAction(ActionRequest(requestData, token, nonce))
    }
}

Verdicts and What They Mean

APP_INTEGRITY:
  PLAY_RECOGNIZED     — genuine, non-tampered app from Google Play ✅
  UNRECOGNIZED_VERSION — side-loaded APK or unreleased version ⚠️
  UNEVALUATED         — not enough information ⚠️

DEVICE_INTEGRITY:
  MEETS_DEVICE_INTEGRITY  — CTS certified device ✅
  MEETS_BASIC_INTEGRITY   — passes basic but not CTS (custom ROM) ⚠️
  (empty)                 — failed all checks ❌

ACCOUNT_DETAILS:
  LICENSED     — user's Google account licensed this app ✅
  UNLICENSED   — not licensed (pirated) ❌
  UNEVALUATED  — couldn't verify ⚠️

Choosing Your Enforcement Level

Not all use cases warrant the same strictness:

// Server-side policy (pseudocode)
fun evaluateIntegrity(verdict: IntegrityVerdict): Action {
    return when {
        // Strict: require full integrity for financial operations
        verdict.appIntegrity != "PLAY_RECOGNIZED" ||
        verdict.deviceIntegrity.isEmpty() ->
            Action.BLOCK

        // Medium: allow degraded experience for basic integrity
        verdict.deviceIntegrity == "MEETS_BASIC_INTEGRITY" && !isFinancialAction ->
            Action.ALLOW_WITH_CAPTCHA

        // Allow: passes all checks
        else -> Action.ALLOW
    }
}

Important Limitations

  • Rate limits: Play Integrity has per-app daily limits (~10,000 tokens/day by default; request quota increase for higher volume)
  • Latency: Token requests can take 1-10 seconds; don't block UI on them — request proactively
  • Not a silver bullet: determined attackers can root with hidden root detectors; Play Integrity is one layer of defense
  • Nonce anti-replay: always bind the nonce to the specific request; a static nonce can be replayed

SafetyNet vs Play Integrity

SafetyNet Attestation is deprecated (was shut down in 2024). Play Integrity API is the replacement with stronger guarantees and better server-side verification flow.

Key Takeaways

ConceptRule
Token purposeVerify app genuineness and device integrity server-side
NonceAlways bind to request content — prevents token replay
Server verificationToken must be verified server-side; don't trust client-side parsing
EnforcementMatch strictness to risk; block only for high-value operations
Rate limitsRequest tokens proactively; cache within a short window (< 1 min)
Not a root checkCannot fully block rooted devices; use as one layer among many

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Play Integrity/SafetyNet Basics | Android System Design | Android Engineers