react-native-device-attestation
v1.0.0
Published
Device attestation for React Native — Apple App Attest (iOS) and Google Play Integrity (Android). Built on the New Architecture (Turbo Native Modules).
Maintainers
Readme
react-native-device-attestation
Device attestation for React Native apps.
- iOS — Apple App Attest (DeviceCheck framework, iOS 14+)
- Android — Google Play Integrity API
Built on the New Architecture (Turbo Native Modules) following the official React Native Turbo Module spec. No third-party wrappers — uses Apple and Google APIs directly.
What this solves
Any secret bundled into an app binary can be extracted by decompiling the APK/IPA. Device attestation proves to your backend that:
- The request is coming from your genuine, unmodified app
- The app is running on a real device (not an emulator or script)
- The app binary has not been tampered with or repackaged
Your backend can then safely return secrets (API keys, tokens, signed credentials) only to verified requests.
Requirements
| Platform | Requirement | |---|---| | iOS | iOS 14+, Xcode 12+, App Attest capability enabled | | Android | minSdkVersion 24+, Google Play Services, app published on Google Play | | React Native | 0.73+ (New Architecture) |
Installation
npm install react-native-device-attestationiOS
cd ios && pod installEnable the App Attest capability in Xcode:
- Open your project in Xcode
- Select your app target
- Go to Signing & Capabilities
- Click + Capability and add App Attest
Set the environment for each build scheme:
| Scheme | Value |
|---|---|
| Debug | development |
| Release | production |
In your Info.plist:
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>production</string>Use
developmentfor debug builds. Apple's servers treat development and production attestations separately.
Android
Add the Play Integrity dependency to android/app/build.gradle:
dependencies {
implementation 'com.google.android.play:integrity:1.3.0'
}Register the package in MainApplication.kt:
import com.deviceattestation.DeviceAttestationPackage
// inside getPackages():
packages.add(DeviceAttestationPackage())Enable Play Integrity in Google Play Console:
- Go to Release → Setup → App Integrity
- Enable the Play Integrity API
- Link your Google Cloud project
During development, use the Play Integrity testing tool to get test tokens. Tokens from sideloaded or unsigned APKs will return
UNEVALUATED.
Usage
Cross-platform (recommended)
The attest() helper handles the full flow for the current platform.
import { attest } from 'react-native-device-attestation';
// 1. Get a one-time nonce from your backend
const { nonce } = await fetch('/attestation/nonce').then(r => r.json());
// 2. Run attestation
const result = await attest(nonce);
// iOS → { token: '<base64 attestation>', keyId: '<key id>' }
// Android → { token: '<play integrity token>' }
// 3. Send token to your backend for verification
await fetch('/attestation/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: result.token,
keyId: result.keyId, // iOS only, undefined on Android
platform: Platform.OS,
}),
});iOS — Step by step
import {
isSupported,
generateKey,
attestKey,
generateAssertion,
} from 'react-native-device-attestation';
import { Platform } from 'react-native';
// Check support first
const supported = await isSupported();
if (!supported) return; // simulator or unsupported device
// Step 1 — Generate and persist the key (once per install)
// Store keyId in Keychain via react-native-keychain
const keyId = await generateKey();
await Keychain.setGenericPassword('attestKeyId', keyId);
// Step 2 — Attest the key (once per install)
const { nonce } = await fetch('/attestation/nonce').then(r => r.json());
const attestationToken = await attestKey(keyId, nonce);
// Send attestation to backend
await fetch('/attestation/verify', {
method: 'POST',
body: JSON.stringify({ token: attestationToken, keyId, platform: 'ios' }),
});
// Step 3 — Generate assertion for every sensitive request thereafter
const { nonce: reqNonce } = await fetch('/attestation/nonce').then(r => r.json());
const requestPayload = JSON.stringify({ secretRequest: true, nonce: reqNonce });
const hash = sha256Base64(requestPayload); // see Helpers section below
const assertion = await generateAssertion(keyId, hash);
// Send assertion in header
await fetch('/config/secrets', {
headers: { 'X-Attestation-Assertion': assertion },
});Android — Step by step
import { requestIntegrityToken } from 'react-native-device-attestation';
// Get nonce from your backend
const { nonce } = await fetch('/attestation/nonce').then(r => r.json());
// Request integrity token
const token = await requestIntegrityToken(nonce);
// Send to backend for verification
await fetch('/attestation/verify', {
method: 'POST',
body: JSON.stringify({ token, platform: 'android' }),
});API
isSupported(): Promise<boolean>
Returns true if the device supports attestation.
- iOS: checks
DCAppAttestService.shared.isSupported— returnsfalseon simulators and unsupported devices - Android: always returns
trueon GMS devices
generateKey(): Promise<string> — iOS only
Generates a new App Attest key pair. Returns the keyId.
Call once per device install. Store the keyId in the iOS Keychain. Calling this again generates a new key, invalidating the previous attestation.
const keyId = await generateKey();attestKey(keyId: string, challenge: string): Promise<string> — iOS only
Attests the key with Apple's servers. Returns a base64-encoded attestation object.
keyId— returned fromgenerateKey()challenge— base64-encoded nonce from your backend
Send the returned token to your backend. Your backend verifies it with Apple's API. Call once per install (not on every request — use generateAssertion for subsequent requests).
const attestation = await attestKey(keyId, nonce);generateAssertion(keyId: string, requestHash: string): Promise<string> — iOS only
Generates a cryptographic assertion for a specific request. Use this for every sensitive API call after the initial attestation.
keyId— the attested key IDrequestHash— base64-encoded SHA256 of your request body + nonce
const assertion = await generateAssertion(keyId, requestHash);requestIntegrityToken(nonce: string): Promise<string> — Android only
Requests a Play Integrity token.
nonce— base64-encoded, URL-safe string (max 500 bytes) from your backend
const token = await requestIntegrityToken(nonce);attest(nonce: string, keyId?: string): Promise<AttestationResult> — cross-platform
Runs the full attestation flow for the current platform.
type AttestationResult = {
token: string;
keyId?: string; // iOS only
};Pass a stored keyId on iOS to skip generateKey().
Backend — Verification
Nonce endpoint
// GET /attestation/nonce
app.get('/attestation/nonce', async (req, res) => {
const nonce = crypto.randomBytes(32).toString('base64url');
await redis.setex(`nonce:${nonce}`, 120, '1'); // 2 min TTL, single use
res.json({ nonce });
});iOS — Verify attestation with Apple
import { AppAttest } from 'apple-appattest'; // or use your own cbor parser
app.post('/attestation/verify', async (req, res) => {
const { token, keyId, platform, nonce } = req.body;
// Consume nonce (prevent replay)
const valid = await redis.getdel(`nonce:${nonce}`);
if (!valid) return res.status(401).json({ error: 'Invalid or expired nonce' });
if (platform === 'ios') {
const attestation = Buffer.from(token, 'base64');
const result = await AppAttest.verifyAttestation({
attestation,
challenge: Buffer.from(nonce, 'base64'),
bundleId: 'com.your.bundleid',
teamId: 'YOUR_APPLE_TEAM_ID',
// set to true for development environment
isDevelopment: process.env.NODE_ENV !== 'production',
});
if (!result.verified) {
return res.status(403).json({ error: 'Attestation failed' });
}
// Store the public key for assertion verification
await db.upsert('device_keys', { keyId, publicKey: result.publicKey });
}
if (platform === 'android') {
const { GoogleAuth } = require('google-auth-library');
const auth = new GoogleAuth({ scopes: 'https://www.googleapis.com/auth/playintegrity' });
const client = await auth.getClient();
const accessToken = await client.getAccessToken();
const response = await fetch(
`https://playintegrity.googleapis.com/v1/${PACKAGE_NAME}:decodeIntegrityToken`,
{
method: 'POST',
headers: { Authorization: `Bearer ${accessToken.token}` },
body: JSON.stringify({ integrityToken: token }),
}
);
const data = await response.json();
const verdict = data.tokenPayloadExternal?.appIntegrity?.appRecognitionVerdict;
if (verdict !== 'PLAY_RECOGNIZED') {
return res.status(403).json({ error: `Integrity check failed: ${verdict}` });
}
}
// Return secrets from AWS Secrets Manager
const secrets = await secretsManager.getSecretValue({ SecretId: 'app/config' });
res.json(JSON.parse(secrets.SecretString));
});Helpers
SHA256 base64 in React Native
import { Buffer } from '@craftzdog/react-native-buffer';
import Crypto from 'react-native-quick-crypto';
export function sha256Base64(input: string): string {
const hash = Crypto.createHash('sha256');
hash.update(input);
return hash.digest('base64');
}Or using crypto-js (already in many RN projects):
import CryptoJS from 'crypto-js';
export function sha256Base64(input: string): string {
return CryptoJS.SHA256(input).toString(CryptoJS.enc.Base64);
}Storing the keyId (iOS)
The keyId must survive app restarts but should not be in AsyncStorage (clearable). Use the Keychain:
npm install react-native-keychainimport * as Keychain from 'react-native-keychain';
export const saveKeyId = (keyId: string) =>
Keychain.setGenericPassword('device_attestation', keyId, {
service: 'com.yourapp.attestation',
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
export const getKeyId = async (): Promise<string | null> => {
const creds = await Keychain.getGenericPassword({ service: 'com.yourapp.attestation' });
return creds ? creds.password : null;
};Full integration example
import { Platform } from 'react-native';
import { attest, isSupported } from 'react-native-device-attestation';
import { getKeyId, saveKeyId } from './keychain';
let _secureConfig: Record<string, string> | null = null;
export const initSecureConfig = async () => {
try {
const supported = await isSupported();
if (!supported) return; // simulator — skip in dev
const { nonce } = await fetch('/attestation/nonce').then(r => r.json());
let keyId: string | undefined;
if (Platform.OS === 'ios') {
keyId = (await getKeyId()) ?? undefined;
}
const result = await attest(nonce, keyId);
if (Platform.OS === 'ios' && result.keyId) {
await saveKeyId(result.keyId); // persist for next launch
}
const res = await fetch('/attestation/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: result.token,
keyId: result.keyId,
platform: Platform.OS,
}),
});
if (!res.ok) throw new Error('Attestation rejected by server');
_secureConfig = await res.json();
} catch (err) {
if (__DEV__) console.log('[Attestation] Error:', err);
}
};
export const getSecureConfig = () => _secureConfig;Call initSecureConfig() once in App.tsx before any API calls:
useEffect(() => {
initSecureConfig();
}, []);Error codes
| Code | Platform | Meaning |
|---|---|---|
| NOT_SUPPORTED | iOS | Device does not support App Attest (simulator or old device) |
| GENERATE_KEY_FAILED | iOS | DCAppAttestService failed to generate a key |
| ATTEST_KEY_FAILED | iOS | Apple server rejected the attestation |
| ASSERTION_FAILED | iOS | Assertion generation failed |
| INTEGRITY_ERROR | Android | Play Integrity API returned an error |
| PLATFORM_ERROR | Both | Method called on wrong platform |
Security notes
- Never persist secrets returned from your backend in AsyncStorage, MMKV, or any unencrypted store. Keep them in memory only.
- Nonces must be single-use. Store them in Redis with a short TTL and delete on consumption to prevent replay attacks.
- Rooted/jailbroken devices — App Attest will fail on jailbroken iOS devices. Play Integrity returns a
MEETS_BASIC_INTEGRITYverdict on rooted Android devices. Your backend should decide how to handle these verdicts based on your policy. - Development builds — App Attest works in the
developmentenvironment on real devices. Simulators are not supported. - Production rollout — Apple rate-limits App Attest attestations per device per day. The key should be generated and attested once per install, not on every launch.
License
MIT
