Bypassing Sideload Blocks: Implementing Android Play Integrity API Verification in TypeScript
Learn how to build a secure backend verification service using Android Play Integrity API verification in TypeScript to prevent sideloading blocks and secure your apps.
Google is systematically killing unverified sideloading. With the recent enforcement of strict developer verification requirements on Android, apps distributed outside the Play Store—or those without verified developer credentials—will trigger aggressive Play Protect blocks, installation failures, and runtime API denials.
If you are shipping an Android app, relying on client-side security is a recipe for disaster. You must verify that the app running on the user's device is your exact, unmodified binary, distributed by you (a verified developer), and running on a genuine Android device.
This tutorial builds a production-ready, high-throughput TypeScript backend service to decrypt, parse, and validate attestation tokens using android play integrity api verification.
Here is the exact verification response our service will generate after validating an integrity token:
{
"success": true,
"appLicensingVerdict": "LICENSED",
"deviceRecognitionVerdict": "MEETS_STRONG_INTEGRITY",
"packageName": "com.company.secureapp",
"certificateSha256Digest": "a1b2c3d4e5f6g7h8i9j0...",
"timestampMs": 1716584200000
}Why You Must Implement Android Play Integrity API Verification Right Now
Google's updated security baseline mandates that only apps from verified developers can easily run on modern Android devices. If your app connects to a backend API, attackers can easily patch your APK, bypass client-side checks, run it in an emulator with a debugger attached, and scrape your endpoints.
By executing android play integrity api verification on your backend, you shift the trust anchor from the untrusted client device to Google's secure infrastructure.
The Architecture of Attestation
The verification flow requires a secure handshake between your client app, Google Play Services, your backend, and Google's verification servers.
Do not attempt to decrypt these tokens locally using your own private keys. Local decryption requires managing private keys on your servers and manually handling JSON Web Encryption (JWE) parsing, which is error-prone. Instead, use Google-hosted verification. It is simpler, auto-updates to support new security baselines, and leverages Google's global scale.
Step 1: Setting Up Google Cloud and Service Account
Before touching TypeScript code, you must provision a Google Cloud Service Account with permissions to call the Play Integrity API.
- Go to the Google Cloud Console.
- Create a new Project or select the one linked to your Google Play Console.
- Enable the Play Integrity API in the API Library.
- Navigate to IAM & Admin > Service Accounts and create a service account named
play-integrity-verifier. - Grant the service account the Service Account User role.
- Generate a new JSON Key for this service account. Download it securely—this contains your private credentials.
- Go to your Google Play Console, navigate to Setup > API Access, link your Google Cloud project, and ensure the service account has the View app information (read-only) permission.
Step 2: The Verification Service Implementation
We will write a robust TypeScript class that handles Google Authentication, issues anti-replay nonces, and validates incoming integrity tokens.
First, install the official Google API client:
npm install googleapis @types/node typescript --saveHere is the complete PlayIntegrityService implementation.
import { google } from 'googleapis';
import * as crypto from 'crypto';
export interface VerificationResult {
success: boolean;
packageName?: string;
deviceRecognitionVerdict?: 'MEETS_STRONG_INTEGRITY' | 'MEETS_DEVICE_INTEGRITY' | 'MEETS_BASIC_INTEGRITY' | 'FAILED';
appLicensingVerdict?: 'LICENSED' | 'UNLICENSED' | 'UNEVALUATED';
certificateSha256Digest?: string;
error?: string;
}
export class PlayIntegrityService {
private playintegrity;
private allowedPackageName: string;
private allowedCertificateDigests: Set<string>;
constructor(
googleCredentialsJson: string, // Parsed JSON string of your service account key
allowedPackageName: string,
allowedCertificateDigests: string[]
) {
this.allowedPackageName = allowedPackageName;
this.allowedCertificateDigests = new Set(
allowedCertificateDigests.map(digest => digest.toLowerCase().replace(/:/g, ''))
);
const credentials = JSON.parse(googleCredentialsJson);
// Authenticate with Google API using OAuth2 Service Account JWT
const auth = new google.auth.JWT(
credentials.client_email,
undefined,
credentials.private_key,
['https://www.googleapis.com/auth/playintegrity']
);
this.playintegrity = google.playintegrity({
version: 'v1',
auth: auth,
});
}
/**
* Generates a secure, cryptographically random nonce to prevent replay attacks.
* Send this to your client app before it calls the Play Integrity API.
*/
public generateNonce(): string {
return crypto
.randomBytes(32)
.toString('base64url'); // web-safe base64 without padding
}
/**
* Decrypts and validates the integrity token against our security baselines.
*/
public async verifyToken(
token: string,
expectedNonce: string
): Promise<VerificationResult> {
try {
const response = await this.playintegrity.degreesofintegrity.decodeIntegrityToken({
packageName: this.allowedPackageName,
requestBody: {
integrityToken: token,
},
});
const payload = response.data.tokenPayloadExternal;
if (!payload) {
return { success: false, error: 'Empty token payload returned from Google API.' };
}
// 1. Verify Request Details (Nonce and Package Name)
const requestDetails = payload.requestDetails;
if (!requestDetails) {
return { success: false, error: 'Missing request details in token payload.' };
}
if (requestDetails.requestPackageName !== this.allowedPackageName) {
return {
success: false,
error: `Package name mismatch. Expected ${this.allowedPackageName}, got ${requestDetails.requestPackageName}`,
};
}
if (requestDetails.nonce !== expectedNonce) {
return { success: false, error: 'Nonce mismatch. Potential replay attack detected.' };
}
// 2. Evaluate Device Integrity
const deviceIntegrity = payload.deviceIntegrity;
const deviceRecognitionVerdict = deviceIntegrity?.deviceRecognitionVerdict || [];
let finalDeviceVerdict: VerificationResult['deviceRecognitionVerdict'] = 'FAILED';
if (deviceRecognitionVerdict.includes('MEETS_STRONG_INTEGRITY')) {
finalDeviceVerdict = 'MEETS_STRONG_INTEGRITY';
} else if (deviceRecognitionVerdict.includes('MEETS_DEVICE_INTEGRITY')) {
finalDeviceVerdict = 'MEETS_DEVICE_INTEGRITY';
} else if (deviceRecognitionVerdict.includes('MEETS_BASIC_INTEGRITY')) {
finalDeviceVerdict = 'MEETS_BASIC_INTEGRITY';
}
// 3. Evaluate App Integrity (Developer Verification & Signatures)
const appLicensingVerdict = payload.appLicensingVerdict as VerificationResult['appLicensingVerdict'] || 'UNEVALUATED';
const appIntegrity = payload.appIntegrity;
if (!appIntegrity) {
return { success: false, error: 'Missing app integrity payload.' };
}
// Verify that the app is signed with your official developer certificate
const clientDigests = appIntegrity.certificateSha256Digest || [];
const hasValidSignature = clientDigests.some(digest =>
this.allowedCertificateDigests.has(digest.toLowerCase())
);
if (!hasValidSignature) {
return {
success: false,
error: `App signature unrecognized. Untrusted developer certificate digest: ${clientDigests.join(', ')}`
};
}
// If we made it here, the signature, package name, and replay checks pass.
return {
success: true,
packageName: appIntegrity.packageName,
deviceRecognitionVerdict: finalDeviceVerdict,
appLicensingVerdict: appLicensingVerdict,
certificateSha256Digest: clientDigests[0]
};
} catch (error: any) {
return {
success: false,
error: `Google Play Integrity API execution failed: ${error.message || error}`,
};
}
}
}Step 3: Integrating High-Throughput Nonce Validation
You cannot reuse nonces. Each verification request must use a single-use, time-bound nonce generated by your backend. Storing these nonces in memory will crash your server under load and break if you scale horizontally across multiple container instances.
To handle high-throughput validation safely, use a fast, distributed in-memory store like Redis. If you are tuning your async pipelines for performance, check out our guide on advanced TypeScript async/await patterns for high-throughput services to prevent thread pool starvation.
Here is a simple Redis-backed nonce manager:
import { createClient } from 'redis';
export class NonceStore {
private redisClient;
constructor(redisUrl: string) {
this.redisClient = createClient({ url: redisUrl });
this.redisClient.connect().catch(console.error);
}
/**
* Stores a generated nonce with a strict 5-minute TTL.
*/
public async storeNonce(userId: string, nonce: string): Promise<void> {
const key = `nonce:${userId}:${nonce}`;
await this.redisClient.set(key, '1', {
EX: 300, // 5 minutes validity
});
}
/**
* Verifies if a nonce exists, and immediately deletes it (atomic check-and-delete)
* to prevent token reuse.
*/
public async consumeNonce(userId: string, nonce: string): Promise<boolean> {
const key = `nonce:${userId}:${nonce}`;
const result = await this.redisClient.del(key);
return result === 1; // Returns true if the key existed and was deleted
}
}Step 4: The Express API Implementation
Now, let's wire these components into an Express API endpoint. This endpoint exposes a route to request a nonce and another route to verify the payload.
import express, { Request, Response } from 'express';
import { PlayIntegrityService } from './PlayIntegrityService';
import { NonceStore } from './NonceStore';
const app = express();
app.use(express.json());
const GOOGLE_CREDENTIALS = process.env.GOOGLE_SERVICE_ACCOUNT_JSON || '{}';
const ALLOWED_PACKAGE = 'com.company.secureapp';
const APPROVED_DIGESTS = ['A1:B2:C3:D4:E5:F6:G7:H8:I9:J0:A1:B2:C3:D4:E5:F6:G7:H8:I9:J0'];
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const integrityService = new PlayIntegrityService(
GOOGLE_CREDENTIALS,
ALLOWED_PACKAGE,
APPROVED_DIGESTS
);
const nonceStore = new NonceStore(REDIS_URL);
// Route 1: Issue a unique nonce for the client session
app.post('/api/v1/attestation/challenge', async (req: Request, res: Response): Promise<void> => {
const { userId } = req.body;
if (!userId) {
res.status(400).json({ error: 'userId is required' });
return;
}
const nonce = integrityService.generateNonce();
await nonceStore.storeNonce(userId, nonce);
res.json({ nonce });
});
// Route 2: Verify the generated token from the client
app.post('/api/v1/attestation/verify', async (req: Request, res: Response): Promise<void> => {
const { userId, integrityToken, nonce } = req.body;
if (!userId || !integrityToken || !nonce) {
res.status(400).json({ error: 'Missing required parameters: userId, integrityToken, and nonce.' });
return;
}
// Protect against replay attacks by consuming the nonce
const isValidNonce = await nonceStore.consumeNonce(userId, nonce);
if (!isValidNonce) {
res.status(403).json({ error: 'Invalid or expired nonce.' });
return;
}
// Perform Android Play Integrity API verification
const verification = await integrityService.verifyToken(integrityToken, nonce);
if (!verification.success) {
res.status(401).json({
error: 'Device attestation failed.',
details: verification.error
});
return;
}
// Evaluate the device security posture according to your business needs
if (verification.deviceRecognitionVerdict === 'FAILED') {
res.status(403).json({ error: 'Device fails integrity benchmarks (rooted, emulator, or modified OS).' });
return;
}
// Issue session token on success
res.json({
status: 'verified',
devicePosture: verification.deviceRecognitionVerdict,
licensing: verification.appLicensingVerdict,
sessionToken: 'jwt_secure_session_token'
});
});
app.listen(3000, () => {
console.log('Attestation API listening on port 3000');
});When reviewing this implementation with your team, make sure your security architecture follows strict standards. You can read more on how to identify subtle structural issues in our analysis of what senior engineers actually look for in code reviews.
Common Gotchas & Production Realities
Implementing device attestation is deceptively simple until you hit production edge cases. Keep these gotchas in mind:
1. The Classic Nonce Replay Vulnerability
If your backend does not strictly check and immediately delete nonces, an attacker can extract a valid integrity token from a legitimate device and replay it repeatedly to authorize unverified clients. Always use an atomic operation like Redis DEL to ensure a nonce is consumed only once.
2. Handling Play Protect Legacy Approvals
If your app is sideloaded by users under Google's new verification requirements, they may see a prompt to verify the app or send it to Google for scanning. During this window, appLicensingVerdict might return UNEVALUATED. Ensure your backend handles UNEVALUATED gracefully for legitimate beta-testing phases, but enforce strict LICENSED verdicts for production environments.
3. Emulator False Positives
Emulators will fail both MEETS_STRONG_INTEGRITY and MEETS_DEVICE_INTEGRITY. They will only return MEETS_BASIC_INTEGRITY if they pass basic software checks, but in most cases, they will fail attestation completely. Do not block basic integrity during local debugging, but enforce at least MEETS_DEVICE_INTEGRITY in production to weed out automated scripting bots running inside virtualized containers.
4. API Quotas and Rate Limits
The Play Integrity API has a daily limit on the number of token decryptions allowed per app (typically 10,000 requests per day for standard tier). You must request a quota increase through the Google Play Console long before you scale your production traffic, or your service will start throwing 429 errors, locking your users out of your application.
Related Posts
The Production-Grade Backend API Security Checklist
Secure your production services with this comprehensive backend api security checklist covering rate limiting, JWT validation, token rotation, and secure architecture.
How to Run Docker Containers Inside Vercel Sandbox for Secure Code Execution
Learn how to build, deploy, and run docker containers inside vercel sandbox to execute untrusted code securely with TypeScript.
Secure NestJS Backend Development: Fixing What AI Generation Gets Wrong
Learn how to implement secure NestJS backend development by fixing common security vulnerabilities found in AI-generated code, including mass assignment, data leaks, and insecure defaults.