Blog Infos
Author
Published
Topics
, , , ,
Published

Generated using Google Gemini

Introduction

In an age where malicious actors can spoof devices, clone apps, and game your system faster than you can say isDeviceTrusted(), ensuring the integrity of your Android app isn’t just a nice-to-have — it’s a security baseline.

Enter the Play Integrity API, Google’s security gatekeeper for Android apps. Its job is simple: help you determine whether a device and request can be trusted before you hand over access to sensitive features 🔐.

In this post, we’ll walk through how attestation(the process of validating the app, device, or user’s authenticity) works on Android, why Google offers two options (Classic and Standard), and how to implement retry logic that handles errors gracefully instead of adding complexity.

We’ll keep things practical and easy to follow, with just enough lightness to make the topic less dry. By the end, you’ll know:

  • Which attestation mode to choose (Classic vs. Standard) and why.
  • How to integrate attestation flows on the client, including request details and token handling.
  • What role your server plays at a high level in verifying attestation results.
  • How to make retries reliable without hitting quotas or rate limits.
  • The common pitfalls to watch for before rolling out to production.

Grab a coffee ☕ (or tea if that’s your thing), and let’s dive in.

👀 Classic vs. Standard at a glance

Choosing an attestation mode is about trade-offs: confidence, speed, and integration model ⚖️.

Key Takeaways

Classic = higher confidence 🛡️

  • Server-provided nonce(One time random unique value) ensures strong request binding.
  • Harder for attackers to replay or spoof.
  • Slower (seconds per request) and capped at 5 tokens/minute per app instance.
  • Best for security-critical flows like login, session refresh, password reset, or high-value actions (e.g., sending money).

Standard = speed + convenience ⚡

  • Relies on an on-device requestHash tied to the user action.
  • Faster (hundreds of ms) after a one-time warm-up.
  • Uses internal caching → slightly lower confidence compared to Classic.
  • Best for frequent, on-the-go checks where low latency matters (e.g., in-app browsing, lightweight feature gating).

👉 A common strategy:

  • Run Classic on your critical trust boundaries (login, session refresh, password reset, money movement).
  • Use Standard for repeated, lower-risk checks where user experience and speed are top priorities.
⚡ Standard Attestation Flow

Standard attestation is designed for frequent, on-demand checks where latency matters. The trade-off: you need to manage a token provider lifecycle up front. Here’s the overall lifecycle of the attestation:

  1. Warm-up the provider
  2. Request a token
  3. Handle provider invalidation
1. Warm-up the provider

Before you can request tokens, you must prepare an IntegrityTokenProvider. It’s like preheating your oven before baking — one warm-up, then you’re set for multiple batches.

  • Typical warm-up time: a few seconds, most under 10s.
  • Timeout recommendation: allow up to ~1 min for the long tail.
  • Rate limit: up to 5 warm-ups per app instance per minute.

If the warm-up succeeds, you can hold onto the provider and reuse it for multiple token requests.

suspend fun prepareStandardProvider(
    integrityManager: IntegrityManager,
    cloudProjectNumber: Long
): IntegrityTokenProvider {
    val req = PrepareIntegrityTokenRequest.builder()
        .setCloudProjectNumber(cloudProjectNumber)
        .build()

    return integrityManager.prepareIntegrityToken(req).await()
}

👉 Pro tip: warm-up early (e.g., app launch or screen entry) so user actions aren’t blocked.

2. Request a token

Once you have a provider, requesting a token is lightweight (hundreds of ms).

// Later, when attestation is needed:
val request = StandardIntegrityTokenRequest.builder()
  .setRequestHash(requestHash) // SHA-256, scoped to the protected action
  .build()

try {
  val token = Tasks.await(tokenProvider.request(request)).token()
  // Send this token to your backend for verification
} catch (e: Exception) {
  // Handle errors (network, Play Services, quota, etc.)
}

// Your backend verifies verdict
// and can return a decision back to the client
  • Binding: the requestHash ties the attestation to the exact action you’re protecting.
  • Latency: typically <500ms after warm-up.
3. Handle provider invalidation

Providers can expire (INTEGRITY_TOKEN_PROVIDER_INVALID).
If that happens, recreate the provider with a new warm-up.

👉 For retries, see the dedicated ⚙ Retries & Quotas section.

🪶 Classic Attestation Flow (Client)

Classic is the simpler path: your server issues a nonce, the app requests a token using that nonce, and then the app sends the token back for verification. Trade-offs: higher latency (seconds, not ms) and a per-app-instance cap of ~5 tokens/min — best for infrequent, high-value checks 🔒.

1. Get a nonce from your backend

The nonce can come from your server in two ways:

  • Explicit API call (e.g., /attestation/nonce) right before the action.
  • Piggy-backed as a field in a previous server response (e.g., when fetching action details), which avoids an extra round-trip.

Nonce requirements:

  • URL-safe Base64 (no wrap, no padding).
  • 16–500 characters.
  • Opaque data only — no PII.
  • Server must be able to verify/recompute what went into it.
2. Request a token with the nonce

 

val classicIntegrityManager = IntegrityManagerFactory.createClassic(context)

// Assume `nonce` comes from your backend (explicit call or included in another response)
val request = ClassicIntegrityTokenRequest.builder()
  .setNonce(nonce)
  // Cloud project number usually NOT needed for Play-distributed apps
  .build()
try {
  val token = Tasks.await(classicIntegrityManager.request(request)).token()
  // Send token back to your backend for verification
} catch (e: Exception) {
  // Handle errors (network, Play Services state, quota)
}

// Backend checks the nonce + verdict
// and returns a decision outcome to the client

 

👉 Error handling and retries follow the same rules as Standard — see ⚙ Retries & Quotas.

📝 Logging Smartly (for both Classic & Standard)

Good logs save hours of debugging. Capture the essentials — so you can quickly spot patterns when things go wrong:

  • errorCode (from exception)
  • attempt number
  • elapsed time
  • provider lifecycle events (warm-up, invalidation)
  • nonce length/token presence (sanity checks)
  • local Play Services & Play Store versions
⚙ Retries & Quotas

Attestation calls don’t always succeed. Networks hiccup, Play Services misbehaves, or Google’s backend takes a coffee break ☕️. The goal is predictable recovery without hammering the API or burning through quotas ⚖️.

🧨 Exceptions to Expect

Both APIs wrap their errors in different exception types:

  • Classic Attestation → throws IntegrityServiceException
  • Standard Attestation → throws StandardIntegrityException

👉 Make sure your retry logic (or logging) distinguishes these. Both exceptions expose useful fields like:

  • errorCode (Play Integrity-specific error code)
  • statusCode (from Play Services)

These are the values you’ll use to decide whether the error is retry-able or fatal.

Retry the right way

Only retry errors that are transient. Google’s docs explicitly list these as retry-able:

Everything else (e.g., outdated Play Services/Play Store, unsupported device) should fail fast with user messaging or graceful degradation.

Retries should be capped and spaced out: 5s → 10s → 20s (or similar exponential strategy), then stop. Three attempts total is the sweet spot.

A minimal retry utility ⚙️

Here’s a simple coroutine-based helper that wraps your attestation call:

private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(5_000L, 10_000L, 20_000L) // 5s → 10s → 20s

private val RETRYABLE_CODES = setOf(
    IntegrityErrorCode.NETWORK_ERROR,
    IntegrityErrorCode.TOO_MANY_REQUESTS,
    IntegrityErrorCode.GOOGLE_SERVER_UNAVAILABLE,
    IntegrityErrorCode.CLIENT_TRANSIENT_ERROR,
    IntegrityErrorCode.INTERNAL_ERROR,
    IntegrityErrorCode.CANNOT_BIND_TO_SERVICE,
    IntegrityErrorCode.STANDARD_INTEGRITY_INITIALIZATION_FAILED
)

suspend fun <T> retryIntegrity(block: suspend () -> T): T {
    var attempt = 0
    var lastError: Throwable? = null
    while (attempt < MAX_ATTEMPTS) {
        try {
            return block()
        } catch (t: Throwable) {
            lastError = t
            val retryable = when (t) {
                is StandardIntegrityException -> t.errorCode in RETRYABLE_CODES
                is TimeoutException, is InterruptedException -> true
                else -> false
            }
            if (!retryable || attempt == MAX_ATTEMPTS - 1) break
            delay(BACKOFF_MS[attempt.coerceAtMost(BACKOFF_MS.lastIndex)])
            attempt++
        }
    }
    throw lastError ?: IllegalStateException("Integrity retry failed without throwable")
}

Usage examples:

// Standard
val token = retryIntegrity {
    tokenProvider.request(standardRequest).await().token()
}

// Classic
val token = retryIntegrity {
    classicManager.request(classicRequest).await().token()
}
// With Tasks.await instead of coroutines:
val token = retryIntegrity {
    Tasks.await(classicManager.request(classicRequest)).token()
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Securing Mobile Apps with OWASP MASVS & MSTG

There are numerous ways of developing mobile apps today, but how do you ensure that your app is properly secured? What are the threats you should be concerned about and what can you do to…
Watch Video

Securing Mobile Apps with OWASP MASVS & MSTG

CARLOS HOLGUERA
OWASP Mobile Security Project Lead / NowSecure, Mobile ...

Securing Mobile Apps with OWASP MASVS & MSTG

CARLOS HOLGUERA
OWASP Mobile Securit ...

Securing Mobile Apps with OWASP MASVS & MSTG

CARLOS HOLGUERA
OWASP Mobile Security Pro ...

Jobs

Quotas to keep in mind 📊

Per-minute limits (per app instance):

  • Classic: up to 5 tokens/minute
  • Standard: up to 5 provider warm-ups/minute (no documented limit on token requests)

Daily quotas (per app, across all users)

  • 10,000 token requests
  • 10,000 server-side decodeIntegrityToken calls
  • Higher quotas available by request

Don’t be that app hammering Google’s servers like a woodpecker 🪵🐦

🖥 Backend: High-Level Lifecycle

The server’s role is simple:

  1. Receive the token from the client (Classic or Standard)
  2. Call decodeIntegrityToken with your service account
  3. Validate the nonce (Classic only)
  4. Evaluate the verdict (device integrity, account status, licensing)
  5. Allow or deny the user action, log results
  6. Send result to client (optional)

That’s it — your server doesn’t need to know how Google decrypts the token, only how to validate that the response matches what you sent and act on the verdict.

💡 Pro tip: Attestation should be just one piece of your overall trust strategy. On the backend, these verdicts can also feed into AI or rules-based models that combine multiple signals (e.g., device integrity, account behavior, fraud heuristics). This way, attestation strengthens a larger decision framework instead of acting in isolation.

🕵️ Common Pitfalls & Checklist

Even with a clean integration, the Play Integrity API has a few common gotchas. Here’s what to watch out for️:

  • ✅ Nonce is URL-safe Base64, 16–500 chars, and contains no PII
  • ✅ Server can recompute/verify what went into the nonce
  • ✅ App is linked to the correct Play Console project
  • ✅ SHA-256 signing certs are configured correctly
  • ✅ Cloud project number is provided during Standard warm-up
  • ✅ Retries use backoff (5s → 10s → 20s) and stop after ~3 attempts
  • ✅ Don’t cache Classic verdicts for frequent checks — use Standard instead
  • ✅ Respect quotas: Classic = 5/min, Standard warm-ups = 5/min, daily = 10k token requests + 10k decodes
  • ✅ Recreate Standard provider if you see INTEGRITY_TOKEN_PROVIDER_INVALID or STANDARD_INTEGRITY_INITIALIZATION_FAILED
  • ✅ Log smartly: error code, attempt #, elapsed time, and Play Services/Play Store versions
✍️ Conclusion & Resources

Play Integrity attestation may look intimidating at first, but the flow boils down to a few simple rules:

  • Classic vs Standard: pick Classic for simplicity, infrequent, high confidence checks; Standard for frequent, low-latency checks, on-demand checks.
  • Client-side: Classic = server-provided nonce; Standard = warm-up then request with a request hash(client-generated).
  • Retries & quotas: retry only transient errors with backoff, respect per-instance and daily limits.
  • Backend: always validate request details (nonce or request hash) before trusting the verdict.
  • Pitfalls: link the right Play project, configure certs, and don’t cache verdicts.

👉 Keep it simple: validate what you send, retry only transient errors, and log smartly. That’s it.

📚 Resources

Here are the official docs we leaned on throughout this guide:

🙏 Thanks for reading!

If you found this post helpful, feel free to share it with your team or on Twitter/LinkedIn. And if you’re working on security-sensitive mobile experiences — or just love obsessing over great architecture — I’d love to hear from you.

Let’s keep pushing Android security forward. 🔐📱

This article was previously published on proandroiddev.com.

Menu