The Security Check That Quietly Stopped Working
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
| Field | Question it answers | Values that matter |
|---|---|---|
| requestDetails | Is this token for *this* request? | Package name, requestHash or nonce, timestamp |
| appIntegrity | Is this our unmodified binary? | PLAY_RECOGNIZED, UNRECOGNIZED_VERSION, UNEVALUATED |
| deviceIntegrity | Is this a genuine, certified device? | MEETS_BASIC_INTEGRITY, MEETS_DEVICE_INTEGRITY, MEETS_STRONG_INTEGRITY |
| accountDetails | Did this user get the app from Play? | LICENSED, UNLICENSED, UNEVALUATED |
| environmentDetails | Is 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
| Standard request | Classic request | |
|---|---|---|
| Latency | A few hundred ms on average | A few seconds on average |
| Setup | Warm up a token provider first | None |
| Request binding | requestHash of the action | nonce you generate server-side |
| Replay protection | Automatic | Yours to implement |
| Use it for | Any action, on demand | Occasional, high-value checks |
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.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
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
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.// 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
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.Key Takeaways
- 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.
- 2A Play Integrity verdict has five parts (request, app, device, account, environment); collapsing them into one boolean discards most of the signal.
- 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.
- 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.
- 5Decrypt and decide on the server, never in the client, and respond in tiers: allow, allow with limits, challenge, deny.
- 6Plan for the 10,000 requests/day default quota, ship the remediation dialogs, and consider device recall for repeat-abuse detection.
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.
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.