SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:22
UP00:01
MOD · ARTICLE · SECURITYS/N · AX-PLAY-IPUBLISHED
SecurityApr 21, 202610 MIN

SafetyNet Is Gone. Play Integrity Is Only as Strong as Your Server

SafetyNet Attestation was turned down in January 2025. Play Integrity replaces it, but a verdict checked on the client, or at one tier, protects almost nothing.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

The Security Check That Quietly Stopped Working

Search your codebase for SafetyNet.getClient. If it is still there, you are running a security control that has been returning the same answer to every device on earth for over a year. Google's deprecation timeline is final: "The SafetyNet Attestation API was deprecated in 2022 and fully turned down in January 2025." It also documents what the call does now: "The attest API returns a task that always invokes the on failure listener with an ApiException and a status code of 7 (NETWORK_ERROR)." Google's final notice to API clients left no room for negotiation: "It is not possible to get an extension to use the SafetyNet Attestation API." What happens next depends on a line of error handling someone wrote years ago. If that handler fails closed, every user has been blocked since January 2025 and you already know about it. If it fails open, and treats a network error as "could not check, let them through", which is the common choice because real network errors are common, then your attestation layer has been a no-op ever since. Nothing crashed. No dashboard went red. The abuse controls simply stopped controlling. The replacement is the Play Integrity API. Migrating the call is an afternoon. Migrating the *thinking* is the real work, because Play Integrity's design punishes the two habits SafetyNet let teams get away with: trusting a verdict on the device, and treating integrity as a single yes-or-no gate.

What a Verdict Actually Tells You

An integrity token decrypts to a JSON payload with five sections. Each answers a different question, and treating them as one boolean throws most of the signal away. From the verdicts reference:
FieldQuestion it answersValues that matter
requestDetailsIs this token for *this* request?Package name, requestHash or nonce, timestamp
appIntegrityIs this our unmodified binary?PLAY_RECOGNIZED, UNRECOGNIZED_VERSION, UNEVALUATED
deviceIntegrityIs this a genuine, certified device?MEETS_BASIC_INTEGRITY, MEETS_DEVICE_INTEGRITY, MEETS_STRONG_INTEGRITY
accountDetailsDid this user get the app from Play?LICENSED, UNLICENSED, UNEVALUATED
environmentDetailsIs anything watching or driving the app?App access risk (capturing, controlling, overlays), Play Protect verdict
PLAY_RECOGNIZED means "the app and certificate match the versions distributed by Google Play." UNRECOGNIZED_VERSION means "the certificate or package name does not match Google Play records", the signature of a repackaged or tampered build. UNLICENSED covers the sideload case: "the user sideloads your app or doesn't acquire it from Google Play." The environment signals are the most underused. appAccessRiskVerdict reports, for example, when "there are apps running that have permissions enabled that could be used to view the screen while your app is running", which is exactly the precondition for overlay and screen-scraping fraud in a banking or payments flow. Google's claim for the payoff is strong: in a November 2025 post it reported that "apps using Play integrity features see 80% lower unauthorized usage on average compared to other apps." That figure is Google's own and it is an average; the architecture below is what decides whether your app lands above or below it.

Standard or Classic: Pick by Frequency, Not by Habit

Play Integrity offers two request types, and the overview is explicit about the trade: "Standard requests have the lowest latency (a few hundred milliseconds on average) and a high reliability of obtaining a usable verdict." Classic requests "have higher latency (a few seconds on average) and you are responsible for mitigating the risk of certain types of attacks."
Standard requestClassic request
LatencyA few hundred ms on averageA few seconds on average
SetupWarm up a token provider firstNone
Request bindingrequestHash of the actionnonce you generate server-side
Replay protectionAutomaticYours to implement
Use it forAny action, on demandOccasional, high-value checks
Standard requests need a one-time warm-up: you "must prepare (or 'warm up') the integrity token provider", and Google notes the "typical warm up latency is a few seconds and the majority of all warm ups are under 10s." Do it at app start or when the user enters the flow that will need it, never on the tap that needs the answer. The requestHash is not optional in practice. The standard request guide warns: "Without the requestHash, the integrity token will be bound only to the device, but not to the specific request, which opens up the possibility of attack." Hash the parameters of the action you are protecting (the transfer amount and recipient, not just a session id), so a token minted for one request cannot be replayed onto another.
kotlin
class IntegrityClient(context: Context, private val cloudProjectNumber: Long) {
    private val manager = IntegrityManagerFactory.createStandard(context)
    private var provider: StandardIntegrityTokenProvider? = null

    // Warm up early: typically a few seconds, most under 10s.
    suspend fun warmUp() {
        provider = manager.prepareIntegrityToken(
            PrepareIntegrityTokenRequest.builder()
                .setCloudProjectNumber(cloudProjectNumber)
                .build()
        ).await()
    }

    // Bind the token to THIS action: hash what the server will act on.
    suspend fun tokenFor(action: TransferRequest): String {
        val requestHash = sha256("${action.fromAccount}|${action.toAccount}|${action.amountMinor}")
        val response = checkNotNull(provider) { "warmUp() first" }.request(
            StandardIntegrityTokenRequest.builder()
                .setRequestHash(requestHash)
                .build()
        ).await()
        return response.token() // opaque: send it to the server, never decode it here
    }
}

The Device Verdict Changed Meaning Under You

Teams that migrated early should re-read their policy, because the most-used verdict no longer means what it meant when the policy was written. In December 2024 Google moved device verdicts on Android 13 and higher onto hardware-backed signals and announced that "all API integrations will automatically transition to the new verdicts in May 2025." The same post quantified the benefit: signals collected and evaluated server-side dropped by "~90%" and "verdict latency can improve by up to ~80%." The definitions changed with it. Per the current reference, on Android 13 and higher: - MEETS_STRONG_INTEGRITY "requires MEETS_DEVICE_INTEGRITY and security updates in the last year for all partitions of the device, including an Android OS partition patch and a vendor partition patch." - MEETS_DEVICE_INTEGRITY means "there is hardware-backed proof that the device bootloader is locked and the loaded Android OS is a certified device manufacturer image." - MEETS_BASIC_INTEGRITY "requires only that the attestation root of trust is provided by Google." On Android 12 and lower, strong integrity "does not require the device to have a recent security update." So the same label means different things on different OS versions, and the strong verdict is now partly a statement about patch recency. A policy that demands MEETS_STRONG_INTEGRITY for sign-in is no longer "block rooted phones"; it is "block anyone whose manufacturer stopped shipping patches more than a year ago." That may be the right call for a wire transfer. It is the wrong call for reading a news feed.

Decide on the Server, and Decide in Tiers

Every serious Play Integrity failure has the same root cause: the decision was made where the attacker lives. Google's setup guide says it in one line: "Never decrypt tokens or expose keys within your client app." The client's only job is to obtain an opaque token and forward it. Your server decrypts it (Google recommends decrypting on Google's servers; self-managed keys are supported), checks that requestDetails matches the request it is serving, and only then reads the verdicts. The second failure is binary enforcement. Google's overview recommends graded responses instead, "a series of related responses such as Allow, Allow with limits, Allow with limits after CAPTCHA completion, and Deny." A graded policy survives the verdict drift described above and degrades gracefully for the long tail of legitimate users on old or unusual hardware, instead of turning a security control into a support-ticket generator.
kotlin
// Server side, after decoding the token through Google's decodeIntegrityToken endpoint.
fun decide(payload: IntegrityPayload, expected: ExpectedRequest, action: Action): Decision {
    val req = payload.requestDetails
    if (req.requestPackageName != expected.packageName) return Decision.Deny
    if (req.requestHash != expected.requestHash) return Decision.Deny          // replayed or swapped
    if (expected.now - req.timestampMillis > 60_000) return Decision.Deny      // stale token

    val genuineApp = payload.appIntegrity.appRecognitionVerdict == "PLAY_RECOGNIZED"
    val labels = payload.deviceIntegrity.deviceRecognitionVerdict.orEmpty()
    val screenWatched = payload.environmentDetails?.appAccessRiskVerdict
        ?.appsDetected.orEmpty().any { it.endsWith("_CAPTURING") }

    return when {
        !genuineApp -> Decision.Deny
        action.isHighValue && "MEETS_STRONG_INTEGRITY" !in labels -> Decision.StepUp   // re-auth, limits
        action.isHighValue && screenWatched -> Decision.StepUp
        "MEETS_DEVICE_INTEGRITY" in labels -> Decision.Allow
        "MEETS_BASIC_INTEGRITY" in labels -> Decision.AllowWithLimits
        else -> Decision.Challenge                                               // CAPTCHA, then limits
    }
}

The Operational Details That Bite in Production

Three platform facts belong in the design review, not the incident review. Quota is shared and finite. "Your app or SDK has a default daily limit of 10,000 total requests, tied to the associated Cloud Project Number," and the setup guide notes that quota increases "apply to both client-side token generation and server-side decryption calls." A popular app that requests a token on every screen will exhaust the default on its first busy day. Request on protected actions, and file the quota increase before launch, not during it. Users can often fix a failing verdict themselves. Play Integrity ships remediation dialogs shown through showDialog(): GET_LICENSED for a sideloaded install, and GET_INTEGRITY / GET_STRONG_INTEGRITY for device problems, which "requires the Integrity API Android library version 1.5.0 or higher." A denial that offers a fix converts a support ticket into a retry. Repeat abuse survives a factory reset, and now so can your memory of it. Device recall, announced as a public beta at Google I/O 2025, lets you "read three custom values or bits for each device", stored on Google's servers "even after your app is reinstalled or the device is reset." Three bits are enough to mark "already claimed the sign-up bonus" without collecting a device identifier. The migration checklist, in order: delete every SafetyNet call and its fail-open branch; move all decryption and decisions to the server; bind every token to its action with requestHash; replace the single gate with graded responses; and wire the remediation dialogs before you turn enforcement on.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1SafetyNet Attestation was fully turned down in January 2025; every attest call now fails with a network error, so a fail-open handler means zero protection.
  2. 2A Play Integrity verdict has five parts (request, app, device, account, environment); collapsing them into one boolean discards most of the signal.
  3. 3Use standard requests with a warm-up and a requestHash bound to the protected action; reserve classic requests and nonces for occasional high-value checks.
  4. 4On Android 13 and higher, MEETS_STRONG_INTEGRITY now requires a security update within the last year, so it is partly a patch-recency check.
  5. 5Decrypt and decide on the server, never in the client, and respond in tiers: allow, allow with limits, challenge, deny.
  6. 6Plan for the 10,000 requests/day default quota, ship the remediation dialogs, and consider device recall for repeat-abuse detection.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

Does SafetyNet Attestation still work?

No. Google fully turned it down in January 2025, and the attest call now always fails with an ApiException carrying status code 7 (NETWORK_ERROR). Migrate to the Play Integrity API.

Can I check the Play Integrity verdict inside the app?

No. Google’s guidance is never to decrypt tokens or expose keys in the client. Send the opaque token to your server, decrypt it there, and make the decision server-side.

Should I require MEETS_STRONG_INTEGRITY for every user?

Usually not. On Android 13 and higher it requires a security update within the last year, so it excludes users on unpatched but genuine devices. Reserve it for high-value actions and use graded responses elsewhere.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA