- Mobile Security Threat Model
- Secure Storage with react-native-keychain
- SSL Pinning
- API Key Protection
- Code Obfuscation
- Authentication Best Practices
- Jailbreak and Root Detection
- Security Checklist
- Interview Questions & Answers
- Navigation
React Native apps face unique security challenges because JavaScript bundles are readable and the bridge exposes native capabilities.
| Threat | Risk | Mitigation |
|---|---|---|
| Reverse engineering | Exposed API keys, business logic | Obfuscation, server-side secrets |
| Man-in-the-middle | Intercepted API traffic | SSL pinning, certificate validation |
| Insecure storage | Token theft from AsyncStorage | Keychain / EncryptedSharedPreferences |
| Jailbreak/root | Runtime manipulation, key extraction | Detection + risk-based response |
| Deep link hijacking | Unauthorized navigation | Verified links, intent filters |
Principle: Client-side security is defense in depth, not absolute protection. Never trust the client.
AsyncStorage stores data in plain text — never use it for tokens, passwords, or PII.
react-native-keychain stores credentials in:
- iOS: Keychain Services (hardware-backed when available)
- Android: EncryptedSharedPreferences / Keystore
npm install react-native-keychainimport * as Keychain from 'react-native-keychain';
const SERVICE_NAME = 'com.myapp.auth';
export async function saveTokens(
accessToken: string,
refreshToken: string,
): Promise<void> {
await Keychain.setGenericPassword(
'tokens',
JSON.stringify({ accessToken, refreshToken }),
{
service: SERVICE_NAME,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
},
);
}
export async function getTokens(): Promise<{
accessToken: string;
refreshToken: string;
} | null> {
const credentials = await Keychain.getGenericPassword({
service: SERVICE_NAME,
});
if (!credentials) return null;
return JSON.parse(credentials.password) as {
accessToken: string;
refreshToken: string;
};
}
export async function clearTokens(): Promise<void> {
await Keychain.resetGenericPassword({ service: SERVICE_NAME });
}import * as Keychain from 'react-native-keychain';
export async function saveWithBiometrics(secret: string): Promise<boolean> {
return Keychain.setGenericPassword('user', secret, {
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
accessible: Keychain.ACCESSIBLE.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
authenticationPrompt: {
title: 'Authenticate to access your account',
cancel: 'Cancel',
},
});
}
export async function getBiometricProtectedSecret(): Promise<string | null> {
try {
const creds = await Keychain.getGenericPassword({
authenticationPrompt: {
title: 'Unlock your vault',
},
});
return creds ? creds.password : null;
} catch {
return null;
}
}| Data Type | Storage Solution |
|---|---|
| Access/refresh tokens | Keychain |
| User preferences | AsyncStorage / MMKV |
| Cached API responses | MMKV / SQLite (encrypt sensitive) |
| Encryption keys | Keychain / Android Keystore |
| Large offline datasets | SQLite with SQLCipher |
SSL pinning validates that the server's certificate matches a known trusted certificate or public key, preventing MITM attacks even with a compromised CA.
import { fetch as sslFetch } from 'react-native-ssl-pinning';
export async function secureApiCall<T>(endpoint: string): Promise<T> {
const response = await sslFetch(`https://api.example.com${endpoint}`, {
method: 'GET',
sslPinning: {
certs: ['api-example-com'], // cert files in android/app/src/main/assets/ and iOS bundle
},
headers: {
Accept: 'application/json',
Authorization: `Bearer ${await getAccessToken()}`,
},
});
if (response.status !== 200) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<T>;
}ios/
api-example-com.cer # Add to Xcode project bundle
android/app/src/main/assets/
api-example-com.cer # Referenced by ssl-pinning lib
| Pros | Cons |
|---|---|
| Blocks MITM with rogue certificates | Certificate rotation requires app update |
| Protects against compromised CAs | Misconfiguration breaks all API calls |
| Required for some compliance (finance) | Public key pinning is more rotation-friendly |
Best practice: Pin the public key hash (SPKI) rather than full certificate when possible. Plan rotation with backup pins and OTA-capable pinning libraries.
For advanced pinning with reporting and backup pins, integrate TrustKit on iOS and Android via native modules or community wrappers.
Never embed secret API keys in JavaScript bundles — they can be extracted via decompilation.
| Safe (with limits) | Never Client-Side |
|---|---|
| Firebase client config (with rules) | Stripe secret key |
| Google Maps SDK key (restricted) | AWS secret access key |
| Analytics write keys (rate-limited) | Database credentials |
| OAuth client ID (public) | OAuth client secret |
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ RN App │────▶│ Your Backend │────▶│ Third-Party│
│ (public) │ │ (secrets) │ │ API │
└─────────────┘ └──────────────┘ └─────────────┘
// BAD — secret in client
const STRIPE_SECRET = 'sk_live_xxxx'; // Extractable from bundle!
// GOOD — proxy through your backend
export async function createPaymentIntent(amount: number): Promise<string> {
const response = await fetch('https://api.myapp.com/payments/intent', {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAccessToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount }),
});
const { clientSecret } = await response.json();
return clientSecret; // Safe to use client-side
}For keys that must be client-side (Maps, Firebase):
<!-- Android: restrict by package name + SHA-1 in Google Cloud Console -->
<!-- iOS: restrict by bundle ID -->Use environment-specific keys via build flavors — never production keys in debug builds committed to git.
JavaScript bundles are readable by default. Obfuscation adds friction but is not foolproof.
// metro.config.js
const jsoMetroPlugin = require('obfuscator-io-metro-plugin')(
{
compact: true,
controlFlowFlattening: true,
deadCodeInjection: true,
stringArray: true,
stringArrayEncoding: ['base64'],
},
{ runInDev: false, logObfuscatedFiles: false },
);
module.exports = {
...jsoMetroPlugin,
// other metro config
};// android/app/build.gradle
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}# proguard-rules.pro — keep React Native and native module classes
-keep class com.facebook.react.** { *; }
-keep class com.facebook.hermes.** { *; }
-keep class com.myapp.security.** { *; }
- Enable bitcode (where applicable) and strip symbols in release
- Avoid storing secrets in Info.plist
- Use ios-deploy symbol stripping for release archives
Obfuscation slows down attackers; it does not stop determined reverse engineers. Combine with:
- Server-side validation of all sensitive operations
- Short-lived tokens
- Certificate pinning
- Runtime integrity checks
import * as Keychain from 'react-native-keychain';
interface AuthState {
isAuthenticated: boolean;
accessToken: string | null;
}
const TOKEN_REFRESH_BUFFER_MS = 60_000; // Refresh 1 min before expiry
export async function getValidAccessToken(): Promise<string | null> {
const tokens = await getTokens();
if (!tokens) return null;
const payload = parseJwtPayload(tokens.accessToken);
const expiresAt = payload.exp * 1000;
const now = Date.now();
if (expiresAt - now > TOKEN_REFRESH_BUFFER_MS) {
return tokens.accessToken;
}
// Refresh token flow
const newTokens = await refreshTokens(tokens.refreshToken);
if (!newTokens) {
await clearTokens();
return null;
}
await saveTokens(newTokens.accessToken, newTokens.refreshToken);
return newTokens.accessToken;
}
function parseJwtPayload(token: string): { exp: number } {
const base64 = token.split('.')[1];
return JSON.parse(atob(base64)) as { exp: number };
}- Short-lived access tokens (15-60 min) with refresh token rotation
- Store tokens in Keychain, never AsyncStorage
- Clear tokens on logout and when refresh fails
- Certificate pinning on auth endpoints
- Biometric re-auth for sensitive actions (payments, settings)
- Prevent screenshots on sensitive screens (Android:
FLAG_SECURE)
import { Platform } from 'react-native';
// Android FLAG_SECURE via native module or react-native-screenshot-prevent
export function enableScreenshotPrevention(): void {
if (Platform.OS === 'android') {
// NativeModule.setSecureFlag(true);
}
}Jailbroken iOS and rooted Android devices allow:
- Keychain extraction tools
- SSL pinning bypass (Frida, Objection)
- Runtime method swizzling
- Access to other apps' sandbox data
npm install jail-monkeyimport JailMonkey from 'jail-monkey';
import { Alert, BackHandler, Platform } from 'react-native';
export function performSecurityChecks(): boolean {
const isCompromised =
JailMonkey.isJailBroken() ||
JailMonkey.canMockLocation() ||
JailMonkey.isOnExternalStorage() ||
JailMonkey.AdbEnabled();
if (__DEV__) return true; // Skip in development
if (isCompromised) {
handleCompromisedDevice();
return false;
}
return true;
}
function handleCompromisedDevice(): void {
Alert.alert(
'Security Notice',
'This app cannot run on modified devices for your protection.',
[{ text: 'Exit', onPress: () => BackHandler.exitApp() }],
{ cancelable: false },
);
}| Risk Level | Response |
|---|---|
| Low sensitivity app | Warn user, continue |
| Banking/fintech | Block app entirely |
| Enterprise | Block + report to MDM |
| Moderate | Disable biometrics, require re-auth, limit features |
- Frida detection — Check for Frida server ports and loaded libraries
- Debugger detection —
ptracechecks (native) - Integrity verification — Check app signature hasn't been modified
- SafetyNet / Play Integrity API (Android) — Google's device attestation
- DeviceCheck / App Attest (iOS) — Apple's device integrity APIs
Detection is a cat-and-mouse game — combine multiple signals and accept that determined attackers can bypass client checks. Server-side attestation is the strongest approach for high-security apps.
## Production Security Checklist
### Storage
- [ ] Tokens in Keychain, not AsyncStorage
- [ ] No secrets in JS bundle or git
- [ ] Sensitive cache encrypted (MMKV encryption key in Keychain)
### Network
- [ ] HTTPS only (ATS on iOS, cleartext disabled on Android)
- [ ] SSL pinning on auth and payment endpoints
- [ ] Certificate rotation plan documented
### Authentication
- [ ] Short-lived JWT with refresh rotation
- [ ] Biometric gate for sensitive actions
- [ ] Secure logout clears all stored credentials
### Code
- [ ] ProGuard/R8 enabled for Android release
- [ ] JS obfuscation for release builds
- [ ] No sensitive data in logs (even in __DEV__ patterns leaking to prod)
### Device
- [ ] Jailbreak/root detection with appropriate response
- [ ] Screenshot prevention on sensitive screens
- [ ] Deep links validated before navigation
### Compliance
- [ ] Privacy policy and data handling documented
- [ ] GDPR/CCPA consent flows if applicable
- [ ] Penetration test before major releasesAnswer:
AsyncStorage on both platforms stores data in plain text:
- iOS: Unencrypted files in app's sandbox (accessible on jailbroken devices)
- Android: XML files in
/data/data/<package>/(accessible on rooted devices with backup enabled)
Any XSS-like vulnerability, backup extraction, or device compromise exposes tokens directly.
Use react-native-keychain instead, which leverages:
- iOS Keychain (encrypted, hardware-backed on Secure Enclave devices)
- Android Keystore + EncryptedSharedPreferences
Additional protections:
ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY— No iCloud backup of secretsACCESS_CONTROL.BIOMETRY_CURRENT_SET— Require Face ID / fingerprint- Short-lived access tokens to limit exposure window
Answer:
SSL pinning ensures the app only trusts specific server certificates or public keys, rather than any certificate signed by a trusted CA. This prevents MITM attacks using rogue CA certificates (common on corporate proxies or compromised CAs).
Implementation:
- Bundle server certificate (.cer) or pin SPKI hash in the app
- Configure networking library to validate server cert against pinned cert
- Reject connections that don't match
When to use:
- Banking, healthcare, fintech apps
- Apps handling PII or payment data
- Compliance requirements (PCI-DSS considerations)
- High-value API endpoints (auth, transactions)
Challenges:
- Certificate expiry requires app update or dynamic pinning with backup pins
- Misconfiguration causes total API failure
- Public key pinning with multiple backup pins is recommended
Libraries: react-native-ssl-pinning, TrustKit, or custom native module with OkHttp CertificatePinner (Android) and NSURLSession delegate (iOS).
Answer:
Rule: Secrets never belong in the client.
- Proxy through backend — App calls your API; server holds third-party secrets (Stripe, AWS, etc.)
- Public keys only — OAuth client IDs, Firebase config, Maps SDK keys (with restrictions)
- Restrict keys — Google Cloud / AWS console: limit by bundle ID, package name, SHA-1, IP
- Environment flavors — Different keys for dev/staging/prod; prod keys never in debug builds
- Assume extraction — Any string in the JS bundle can be found via
stringson the bundle file
For Firebase: security comes from Firestore/Storage rules, not hiding the config object.
For analytics: use write-only keys with rate limiting.
Never commit .env files with production secrets to git — use CI secrets injection.
Answer:
JavaScript layer:
- Metro plugins (
obfuscator-io-metro-plugin) — control flow flattening, string encryption, dead code injection - Hermes bytecode — not obfuscation but harder to read than plain JS
- Remove source maps from production builds
Android native layer:
- ProGuard/R8 — shrinks, optimizes, and obfuscates Java/Kotlin
- Custom ProGuard rules to keep RN bridge classes
iOS native layer:
- Strip debug symbols in release archive
- Avoid storing logic in easily readable plist files
Limitations: Determined attackers decompile Hermes bytecode and bypass ProGuard. Obfuscation is one layer — never rely on it alone for security. All authorization must be server-validated.
Answer:
| Solution | Security Level | Best For |
|---|---|---|
| AsyncStorage | None (plain text) | UI preferences, non-sensitive flags |
| MMKV | Low (unencrypted default) | Fast key-value, non-sensitive cache |
| MMKV encrypted | Medium | Cached data with encryption key in Keychain |
| react-native-keychain | High | Tokens, passwords, encryption keys |
| expo-secure-store | High | Expo projects, small secrets |
| SQLite + SQLCipher | High | Encrypted structured data at rest |
Recommended pattern:
- Keychain stores master encryption key
- MMKV/SQLite stores encrypted bulk data
- Never store refresh tokens without Keychain protection
- Clear all secure storage on logout
Answer:
Detection libraries (jail-monkey, custom native checks) look for:
iOS jailbreak indicators:
- Presence of Cydia, Sileo, suspicious file paths (
/Applications/Cydia.app) - Write access outside sandbox
- Suspicious dylibs loaded
Android root indicators:
subinary presence- Magisk/SuperSU apps
- Test-keys build tags
ro.debuggablesystem property
Response strategies (risk-based):
- Warn and continue — Low-risk consumer apps
- Disable sensitive features — No biometrics, no payments
- Block app — Banking, enterprise
- Server attestation — Play Integrity API / Apple App Attest for server-side decision
Never rely solely on client-side detection — it can be bypassed with Frida. For high-security apps, use hardware attestation and server-side validation.
Answer:
- Tokens in AsyncStorage — Most common and critical mistake
- Secrets in JS bundle — API keys committed to git or embedded in code
- No certificate pinning on auth endpoints — Vulnerable to MITM
- Logging sensitive data — Tokens and PII in console.log reaching crash reports
- Disabled ATS/cleartext — Allowing HTTP traffic
- No certificate validation — Custom fetch ignoring SSL errors (never
rejectUnauthorized: false) - Deep link injection — Navigating to sensitive screens without auth check via crafted URLs
- Insecure WebView —
javaScriptEnabledwith untrusted content, no URL filtering - No root/jailbreak response — Treating compromised devices same as secure ones
- Client-side authorization — Hiding UI elements instead of server-enforcing permissions
Fix approach: Threat model each feature, apply defense in depth, and assume the client is hostile in security-critical flows.
Previous: Publishing
Next: Advanced Topics