App Attest and Play Integrity in .NET MAUI 10: Stop API Abuse and Modded Apps (2026)
A hands-on guide to wiring App Attest and the Play Integrity API into a .NET MAUI 10 app. Includes server-side verification, nonces, request hashes, quota planning, and the production pitfalls I've hit shipping this in three apps.
App Attest and the Play Integrity API let a .NET MAUI 10 app prove to your backend that the request is coming from a genuine, unmodified copy of your app running on a real device. App Attest handles iOS via a hardware-backed key in the Secure Enclave, and Play Integrity handles Android by returning a signed verdict from Google Play. You call the platform API from a MAUI partial class, forward the resulting token to your server, decode and verify it, then decide whether to trust the request. That's the whole loop, and getting each step right is what separates a real defense from theater.
SafetyNet Attestation was fully turned down on January 31, 2025. Play Integrity is the only supported path on Android, and existing MAUI apps still calling SafetyNetClient will silently fail.
Standard Play Integrity requests use warm-up caching and cost 10x less quota than classic requests. Use classic only for high-value, low-frequency actions like purchase validation.
App Attest on iOS 14+ generates a hardware-backed key pair in the Secure Enclave and requires a two-step attest-then-assert flow with a server-issued challenge.
Both APIs are worthless without server-side token verification. Never trust a boolean returned from the client, and always bind tokens to a fresh nonce.
Expect legitimate failures on emulators, rooted devices, non-Play-distributed installs, and MDM-managed enterprise devices. Plan the fallback UX before you ship.
Why device attestation matters in 2026
The moment your app has anything valuable behind an API (subscriptions, in-app currency, tournament scores, referral bonuses, private data), someone will script it. I've watched three separate apps get hit within two weeks of their first App Store feature: emulated clients hammering the signup endpoint, modded APKs faking premium status, headless scripts brute-forcing coupon codes. Rate limiting slows attackers down, but it doesn't tell you who's legitimate. That's what device attestation is for.
Attestation answers a different question than authentication. Authentication says "the user has a valid token." Attestation says "the request is coming from an unmodified build of your app, running on hardware Google or Apple has signed off on." Those are complementary. An attacker with a valid stolen token still can't generate a valid Play Integrity verdict from a Frida-hooked APK, because the verdict is signed by Google using keys the attacker never sees. That's the property you're buying.
Every serious mobile SDK now assumes you've got this in place. Google Play Billing's server-side purchase verification is dramatically stronger when combined with a Play Integrity check, and Firebase App Check will refuse Firestore reads without one. If you're shipping a MAUI app in 2026 and skipping attestation, you're one Reddit post away from a very bad Monday.
Play Integrity API vs SafetyNet: what changed
SafetyNet Attestation was Google's original API for this, and it was fully turned down on January 31, 2025. If your MAUI app still has references to SafetyNetClient or SafetyNet.getClient(), those calls now return errors (silently, in some flows). Every Xamarin.Forms project I've migrated to MAUI had at least one, usually buried in a helper class from 2019. Grep for it before you ship your next release.
The Play Integrity API is the replacement, and honestly, it's a genuinely better design. Where SafetyNet returned a JWS you had to send to Google's servers on every call, Play Integrity gives you two modes: standard requests (introduced late 2023) that use a cached, pre-warmed token provider, and classic requests that behave like the old SafetyNet flow. Standard requests are cheaper on quota, faster on the wire, and better on battery. Classic requests give you a fully fresh verdict at the cost of latency and API budget.
The verdict itself is richer. You get four fields worth caring about: appIntegrity (is this the exact APK we uploaded to Play?), deviceIntegrity (is the device trustworthy? MEETS_DEVICE_INTEGRITY, MEETS_BASIC_INTEGRITY, MEETS_STRONG_INTEGRITY), accountDetails (does the Google account own a valid Play license for this app?), and requestDetails (nonce and package name echo). The strongest signal is MEETS_STRONG_INTEGRITY, which requires a hardware-backed key attestation and, in 2026, is available on essentially every device shipped after Android 13.
Setting up Play Integrity in Google Cloud
Before any code, you need three things: a Google Cloud project, the Play Integrity API enabled in that project, and the project linked to your Play Console app. Miss the third and every call returns INTEGRITY_API_NOT_ENABLED even though the API is technically on. I've seen that exact error waste two hours of a room.
Open the Play Integrity API page in Cloud Console and enable it. Then in Play Console, go to your app, Monitoring and improvements, App integrity, Integrity API, click "Link a Cloud project", and pick the same project. You should also decrypt tokens on Google's servers rather than locally with the license key. The local path is a legacy option and requires you to fetch and rotate an AES key that lives in Play Console. Google's server-side decryption is the recommended flow in 2026.
Create a service account in the Cloud project with the "Service Account Token Creator" role and download a JSON key. Your backend will use it to call playintegrity.googleapis.com/v1/{packageName}:decodeIntegrityToken. Store this key in your secrets manager, not in the repo. If you're using GitHub Actions to deploy, load it as a base64 secret and write it to a temp file at runtime; the same pattern I described in the code signing guide for CI/CD works here.
How to request an integrity verdict from .NET MAUI
The Play Integrity SDK lives in com.google.android.play:integrity. You can't call it from shared MAUI code because this is a Google Play Services API and it needs a Java-side callback. The clean pattern is a partial class using MAUI's platform-specific file layout: one interface in Platforms/Android/IIntegrityService.cs hidden behind a shared abstraction.
First, add the Gradle dependency. In your Platforms/Android/MainApplication.cs the SDK is loaded automatically once you declare the AAR. Create Platforms/Android/build.gradle.props and add the package via <AndroidMavenLibrary Include="com.google.android.play:integrity" Version="1.4.0" /> in your csproj. Then wrap the API:
// Platforms/Android/IntegrityService.cs
using Android.Gms.Tasks;
using Com.Google.Android.Play.Core.Integrity;
public class IntegrityService : IIntegrityService
{
private StandardIntegrityManager.StandardIntegrityTokenProvider? _provider;
private const long CloudProjectNumber = 123456789012L; // from Cloud Console
public async Task WarmUpAsync()
{
var manager = IntegrityManagerFactory.CreateStandard(
Platform.CurrentActivity!.ApplicationContext);
var request = PrepareIntegrityTokenRequest.Builder()
.SetCloudProjectNumber(CloudProjectNumber)
.Build();
_provider = await manager.PrepareIntegrityToken(request).AsAsync();
}
public async Task<string> RequestTokenAsync(string requestHash)
{
if (_provider is null) await WarmUpAsync();
var req = StandardIntegrityTokenRequest.Builder()
.SetRequestHash(requestHash) // SHA-256 of the action being protected
.Build();
var response = await _provider!.Request(req).AsAsync();
return response.Token();
}
}
Call WarmUpAsync once at app start, ideally right after MauiProgram.CreateMauiApp() returns so it runs in parallel with your first screen. Token requests after warm-up complete in under 100ms because the provider has already fetched a cached integrity credential. Never block the UI thread waiting for it; treat the first call as fire-and-forget with a timeout fallback.
The requestHash parameter is the killer feature people miss. It's a SHA-256 of whatever action you're protecting (a purchase order ID, a login payload, a leaderboard submission) and it comes back inside the signed verdict. So a valid token for "buy 1000 gems" cannot be replayed against "buy 100000 gems". Bind the hash to the actual server-side action, not a generic session ID.
Setting up App Attest on iOS in .NET MAUI
App Attest is available on iOS 14 and later, uses hardware-backed keys in the Secure Enclave, and works in two phases: first you generate and attest a key (once per install), then you produce signed assertions for each request you want to protect. The MAUI binding surface for DCAppAttestService is complete as of .NET 10, so no manual native binding is needed. Just add using DeviceCheck; and go.
// Platforms/iOS/AppAttestService.cs
using DeviceCheck;
using Foundation;
public class AppAttestService : IAttestService
{
private const string KeyIdPref = "app_attest_key_id";
public async Task<(string keyId, string attestation)> AttestOnceAsync(byte[] serverChallenge)
{
var service = DCAppAttestService.SharedService;
if (!service.IsSupported)
throw new NotSupportedException("App Attest requires iOS 14+ on real hardware.");
// Reuse the key across installs; generate on first launch only.
var keyId = Preferences.Get(KeyIdPref, string.Empty);
if (string.IsNullOrEmpty(keyId))
{
keyId = await service.GenerateKeyAsync();
Preferences.Set(KeyIdPref, keyId);
}
var clientDataHash = NSData.FromArray(SHA256.HashData(serverChallenge));
var attestation = await service.AttestKeyAsync(keyId, clientDataHash);
return (keyId, Convert.ToBase64String(attestation.ToArray()));
}
public async Task<string> GenerateAssertionAsync(string keyId, byte[] requestPayload)
{
var clientDataHash = NSData.FromArray(SHA256.HashData(requestPayload));
var assertion = await DCAppAttestService.SharedService
.GenerateAssertionAsync(keyId, clientDataHash);
return Convert.ToBase64String(assertion.ToArray());
}
}
Two things that'll bite you if you're not careful. First, AttestKeyAsync fails if the device has no network, because the call goes out to Apple's attestation servers to sign the key. Wrap it in a retry with backoff and never treat first-call failure as "device is jailbroken." Second, IsSupported returns false on the iOS simulator, always. Your dev builds cannot exercise the real flow. Feature-flag a bypass for internal builds and log loudly when you use it.
App Attest also refuses to work if your app's Team ID does not match the one Apple has on file, which happens after you switch Apple Developer accounts. If you're in a Xamarin-to-MAUI migration and the bundle ID changed, expect to reset all installed attestations. Apple has no forwarding mechanism.
Server-side verification: decoding and validating tokens
The client-side code above is easy. The server-side verification is where 90% of mistakes happen, and where 100% of your actual security lives. A verdict is only as good as the code that checks it.
For Play Integrity, POST the client token to https://playintegrity.googleapis.com/v1/{packageName}:decodeIntegrityToken authenticated with your service account. The response is a JSON document with the four fields I described earlier. Then check, in this exact order:
// C# ASP.NET Core minimal API, server side
app.MapPost("/api/verify", async (VerifyRequest req, PlayIntegrityClient google) =>
{
var verdict = await google.DecodeAsync(req.Token, packageName: "com.acme.app");
// 1. Request came from the correct app.
if (verdict.RequestDetails.RequestPackageName != "com.acme.app")
return Results.Forbid();
// 2. Nonce matches what the server issued for this action.
if (verdict.RequestDetails.Nonce != req.ExpectedNonce)
return Results.Forbid();
// 3. App binary is the exact one we uploaded to Play.
if (verdict.AppIntegrity.AppRecognitionVerdict != "PLAY_RECOGNIZED")
return Results.Forbid();
// 4. Device has hardware-backed integrity. Accept MEETS_DEVICE_INTEGRITY
// or higher; reject MEETS_BASIC_INTEGRITY unless you want to allow
// older or rooted-but-Play-approved devices.
if (!verdict.DeviceIntegrity.DeviceRecognitionVerdict
.Contains("MEETS_DEVICE_INTEGRITY"))
return Results.Forbid();
// 5. Timestamp inside the token is within a small skew window.
var age = DateTimeOffset.UtcNow - verdict.RequestDetails.TimestampMillis;
if (age > TimeSpan.FromMinutes(5)) return Results.Forbid();
return Results.Ok();
});
Skip any of those five checks and the whole scheme collapses. I've code-reviewed backends that verified the token signature but forgot to check the package name, meaning a token from any Play-integrated app on the same device would pass. Don't do that.
For App Attest, verification is more involved because you're decoding a CBOR-encoded attestation object and walking a certificate chain rooted in Apple's App Attest CA. The Apple validation spec is the reference. There's a decent open-source implementation in .NET called AppAttest.NET if you don't want to hand-roll the CBOR parser. Store the key ID and the resulting public key server-side, one row per install. On subsequent assertions, look up the public key by key ID and verify the signature over the client data hash.
Nonces, freshness, and preventing replay attacks
A nonce is a value the server generates, hands to the client, and expects to see echoed back inside the signed verdict. Its whole job is to prove the token was minted for this specific request, not replayed from a previous one. Attackers who capture a valid token from network traffic want to reuse it against the same endpoint later, and a nonce makes that impossible.
The rules are boring but critical. Nonces must be at least 16 bytes of cryptographically random data. They must be single-use (store issued nonces in Redis with a short TTL, 5 minutes is plenty, and delete on first use). And they must be scoped: a nonce for "submit score" cannot be accepted at "redeem coupon". The easiest way is to embed the endpoint name in the nonce derivation, or store the endpoint alongside the nonce in your cache.
On Android specifically, Play Integrity accepts a request hash up to 500 bytes. Use it. Compute SHA-256(server_nonce || action_payload) and pass that as the requestHash. Now the token binds together the nonce, the action, and the payload, so an attacker can't swap out any of them without invalidating the signature.
On iOS, App Attest's clientDataHash serves the same purpose. Feed it the same construction: a hash of the server nonce concatenated with the request body. If you've already read the MAUI authentication and token management guide, this pairs naturally with your existing challenge-response flow. And if you're worried about the memory cost of holding cached credentials, my notes on memory leak detection in .NET MAUI cover the profiler workflow I actually use.
Standard vs classic requests: choosing the right mode
Play Integrity's two modes exist because Google learned from SafetyNet that one-size-fits-all was the wrong shape. Standard requests are the default for high-volume calls, and classic requests are for the rare, high-stakes moments.
Dimension
Standard requests
Classic requests
Typical latency
50-150 ms after warm-up
500-2000 ms
Free daily quota
10,000 per app per day
10,000 per app per day, plus stricter per-device caps
Battery/network cost
Low (pre-warmed, cached)
Higher, full round-trip to Play each call
Freshness
Verdict may reflect state up to 15 min old
Real-time snapshot at call time
Use it for
Every authenticated request, feed loads, analytics
Payments, sensitive account changes, first login
SDK class
StandardIntegrityManager
IntegrityManager
In my current app the split is roughly 99/1: every backend call carries a standard verdict, and only the "confirm subscription purchase" endpoint additionally requires a fresh classic verdict. That combination gives me continuous coverage without blowing my quota budget. Honestly, if you tried to run a classic request on every single API call, you'd burn through quota in an hour and add a full second to every screen.
Handling failure modes and legitimate false positives
Not every attestation failure is an attacker. Real users hit these too, and blocking them will show up in your reviews faster than you think.
The predictable false positives, in rough order of frequency: dev builds installed via ADB (fail appIntegrity because they were never Play-signed), users on Chinese Android forks with no Google Play Services, MDM-managed enterprise devices with custom certificates, jailbroken/rooted users who paid for your app anyway, iOS simulator (App Attest simply refuses), and users on very old devices that predate hardware-backed keystores.
Your policy needs three tiers. Tier one is "hard block": refuse the request, log the verdict, no explanation to the client. Reserve this for payments and administrative actions. Tier two is "degrade": allow the request but flag the session as low-trust, skip in-app currency awards, hide leaderboard submission, extra CAPTCHA. Tier three is "monitor": allow everything, record the verdict, and use it to inform future policy. Most requests should sit in tier two by default.
Quotas and rate limits to plan around
Play Integrity's free quota is 10,000 decoded tokens per app per day. Sounds like a lot; it disappears fast. An app with 500 daily active users each making 30 API calls will need 15,000 attestations a day. You've got three levers.
First, batch: an attested session token is valid for a window you define server-side. Verify once at login, mint a short-lived signed cookie, and let API requests inside the window skip fresh attestation. Ten minutes is a reasonable window for consumer apps, two minutes for financial. Second, request a quota increase from Google. For standard requests this is usually granted within a few days if you can show you're handling verdicts correctly. Third, use standard mode aggressively; it doesn't consume the same quota bucket as classic mode.
App Attest has no daily quota per se, but Apple rate-limits key generation per device to prevent abuse (typically no more than a few keys per week per device). This is why the code above stores the key ID in Preferences and reuses it forever. Generate a new key every launch and you'll get throttled inside a day of testing.
Production pitfalls I've hit shipping this
Three things I've shipped this in three apps and hit every time. First: the Play Console to Cloud project link is fragile. If someone renames or deletes the Cloud project without updating Play Console, every verdict decodes to an error that reads like a config problem. Set up a monitoring alert on your verify endpoint's error rate at the 99th percentile, not the average, because breakage looks like a small subset of users failing, not a global outage.
Second: Play Integrity's standard mode caches verdicts, which means a device that was clean at warm-up but got rooted since will still return a "clean" verdict for the cache window. This is deliberate (Google chose availability over freshness) but it means high-stakes actions need a classic-mode override on top of the standard flow. Don't lull yourself into thinking standard mode is a security ceiling.
Third: your test devices need attention. CI runners on emulators will always fail attestation, so your integration tests need a bypass path controlled by a build-time symbol, not a runtime flag. A runtime flag in a shipped APK is a bypass an attacker can flip. The same story applies to the crash reporting bypass pattern from the crash reporting article. Bake environment differences at compile time, not runtime.
Frequently Asked Questions
Is SafetyNet Attestation still available in 2026?
No. SafetyNet Attestation was fully turned down on January 31, 2025. Existing calls return an error, and there is no grace period. Migrate to the Play Integrity API; it's the only supported path on Android.
Can I use Play Integrity on devices without Google Play Services?
No. Play Integrity requires Play Services, which excludes Chinese Android forks (Huawei EMUI, Xiaomi HyperOS on the mainland), some enterprise deployments, and Amazon Fire tablets. If those users matter to your business, you'll need a fallback attestation path or accept that they run in a lower trust tier.
Does App Attest work on jailbroken iPhones?
App Attest can still generate and attest keys on jailbroken devices, but Apple's attestation server may refuse to sign the key, and the receipt embeds device state signals your server can inspect. Assume a jailbroken device can produce a technically valid assertion. That's why you also need server-side risk scoring on the actions you protect.
How do I test Play Integrity during .NET MAUI development?
Emulators fail integrity checks by default. In Play Console under Integrity API you can add specific test accounts that get an "unevaluated" verdict on internal-test tracks so your app doesn't hard-block during dev. Never ship this bypass to production; it's a bypass gated by build configuration, not a runtime flag.
What is the difference between App Attest and DeviceCheck?
DeviceCheck (iOS 11+) gives you two bits of per-device storage you can set from your server to track abuse across app reinstalls. App Attest (iOS 14+) is a full cryptographic attestation of your app's binary and the device, using hardware-backed keys. They solve different problems and are often used together: App Attest for request-level integrity, DeviceCheck for a persistent abuse flag.
Learn how to detect, diagnose, and fix memory leaks in .NET MAUI apps. Covers finalizer logging, Visual Studio diagnostics, MemoryToolkit.Maui, event handler cleanup, and DisconnectHandler patterns with working code examples.
A practical guide to building accessible .NET MAUI apps — covering SemanticProperties, screen reader support for TalkBack, VoiceOver, and Narrator, WCAG compliance checklists, and working code examples.