mobile-security-react-native
Secure storage, certificate pinning, biometric auth, jailbreak detection, code obfuscation, network security, screenshot prevention for React Native
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/mobile-security-react-native/skills/mobile-security-react-native
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
React Native Security Patterns
Quick Guide: Defense-in-depth: layer secure storage (expo-secure-store or react-native-keychain), certificate pinning, biometric authentication, jailbreak/root detection, and code obfuscation. Never store secrets in AsyncStorage or JS bundles. Use Hermes bytecode as your first obfuscation layer. iOS Keychain persists across reinstalls; Android Keystore does not. Certificate pins require at least two hashes (primary + backup) on iOS.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST NEVER store tokens, passwords, API keys, or PII in AsyncStorage or plain-text files -- use hardware-backed secure storage)
(You MUST use at least two public key hashes for certificate pinning on iOS -- TrustKit/iOS enforces this and will throw if only one is provided)
(You MUST treat jailbreak/root detection as one layer in defense-in-depth -- client-side checks can be bypassed, always validate server-side too)
(You MUST configure both iOS ATS and Android Network Security Config to enforce HTTPS -- never ship with NSAllowArbitraryLoads: true in production)
(You MUST add NSFaceIDUsageDescription to Info.plist when using Face ID -- the OS silently falls back to passcode without it)
</critical_requirements>
Auto-detection: secure storage, SecureStore, expo-secure-store, react-native-keychain, Keychain, Keystore, certificate pinning, SSL pinning, react-native-ssl-public-key-pinning, TrustKit, jailbreak detection, root detection, jail-monkey, biometric authentication, expo-local-authentication, Face ID, Touch ID, fingerprint, code obfuscation, Hermes bytecode, ProGuard, R8, screen capture prevention, App Transport Security, Network Security Config, MITM
When to use:
- Storing credentials, tokens, or sensitive data on device
- Implementing certificate pinning to prevent MITM attacks
- Adding biometric authentication (Face ID, Touch ID, fingerprint)
- Detecting jailbroken/rooted devices
- Hardening builds with code obfuscation (Hermes, ProGuard/R8)
- Preventing screenshot/screen recording of sensitive screens
- Configuring network security (ATS on iOS, Network Security Config on Android)
When NOT to use:
- General React Native component architecture (not a security concern)
- Server-side API security (use your backend security approach)
- Web-only applications (web security patterns differ fundamentally)
Key patterns covered:
- Secure storage with expo-secure-store and react-native-keychain
- Certificate pinning with react-native-ssl-public-key-pinning
- Biometric authentication with expo-local-authentication and react-native-keychain
- Jailbreak/root detection with jail-monkey
- Code obfuscation: Hermes bytecode, Metro transformer, ProGuard/R8
- Network security: iOS ATS and Android Network Security Config
- Screenshot and screen recording prevention
- Defense-in-depth strategy and security layering
Detailed Resources:
- examples/core.md - Secure storage, certificate pinning, biometric auth
- examples/hardening.md - Code obfuscation, jailbreak detection, screenshot prevention, network config
- reference.md - Security checklist, library API reference, pin hash commands
<decision_framework>
Decision Framework
Secure Storage Choice
Need to store credentials/tokens securely?
+-- Using Expo managed workflow?
| +-- YES -> expo-secure-store (simpler API, Expo-native)
| +-- NO -> react-native-keychain (more control, biometric options)
|
+-- Need biometric-gated credential retrieval?
| +-- YES -> react-native-keychain with ACCESS_CONTROL.BIOMETRY_ANY
| +-- OR -> expo-secure-store with requireAuthentication: true
|
+-- Value larger than 2KB?
| +-- YES -> react-native-keychain (no size limit)
| +-- NO -> Either library works
|
+-- Need credential persistence across app reinstalls?
+-- iOS -> Both persist (Keychain behavior)
+-- Android -> Neither persists (cleared on uninstall)
Biometric Authentication Choice
Need biometric prompt (no credential storage)?
+-- YES -> expo-local-authentication
|
Need biometric-gated credential storage/retrieval?
+-- YES -> react-native-keychain with accessControl
|
Need to distinguish biometric security level (weak vs strong)?
+-- YES -> expo-local-authentication (provides SecurityLevel enum)
Certificate Pinning Approach
Need SSL pinning?
+-- JS-level (works with all HTTP clients)?
| +-- YES -> react-native-ssl-public-key-pinning
|
+-- Native-level (defense-in-depth)?
| +-- iOS -> TrustKit (via CocoaPods)
| +-- Android -> Network Security Config XML
|
+-- Best practice -> Both JS-level AND native-level
Security Layering
Minimum viable security:
1. Secure storage (expo-secure-store or react-native-keychain)
2. HTTPS enforcement (ATS + Network Security Config)
3. Hermes bytecode (default since RN 0.70)
Standard security (most apps):
+ Certificate pinning
+ Biometric authentication
+ ProGuard/R8 on Android
High security (banking, healthcare, fintech):
+ Jailbreak/root detection
+ JS code obfuscation transformer
+ Screenshot prevention
+ Server-side device attestation
+ Runtime integrity checks
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Storing tokens or credentials in AsyncStorage -- it is a plain-text file, trivially readable on jailbroken/rooted devices
- Storing API keys or secrets in the JS bundle -- the bundle is extractable from any published app
- Shipping with
NSAllowArbitraryLoads: truein production Info.plist -- disables ATS entirely, allows HTTP - Using only one public key hash for certificate pinning on iOS -- TrustKit throws, pinning silently fails
- Relying solely on jailbreak detection for security -- client-side checks are bypassable with Frida/Objection
Medium Priority Issues:
- Not checking
hasHardwareAsyncandisEnrolledAsyncbefore callingauthenticateAsync-- crashes or confusing UX on devices without biometrics - Missing
NSFaceIDUsageDescriptionin Info.plist -- Face ID silently falls back to passcode without any error - Using
ACCESSIBLE.ALWAYSfor Keychain items -- allows access even when device is locked (deprecated on iOS) - Skipping ProGuard/R8 on Android release builds -- native code is trivially decompilable without it
- Not setting
expirationDateon certificate pins -- expired certificates brick the app until users update
Gotchas & Edge Cases:
- iOS Keychain data persists across app reinstalls (same bundle ID); Android Keystore data does not -- plan token refresh accordingly
- expo-secure-store has a ~2KB value size limit -- large tokens or data blobs will silently fail or throw
z.coerce.boolean()treats string"false"as truthy -- when parsing security config from strings, use explicit comparison- Android
FLAG_SECUREblocks screenshots reliably but screen recording prevention varies by OS version and manufacturer - Hermes bytecode can be decompiled with tools like
hbctool-- it raises the bar but is not true encryption - react-native-obfuscating-transformer's
stringArrayoption breaks React Native builds -- avoid it - Android biometrics have "weak" (2D face) vs "strong" (fingerprint, 3D face) security levels -- financial apps should require strong
- Certificate pins must be rotated before expiry -- set calendar reminders and use the
expirationDatefield as a safety net Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SETinvalidates credentials when biometrics are re-enrolled -- useBIOMETRY_ANYfor persistence across biometric changes
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST NEVER store tokens, passwords, API keys, or PII in AsyncStorage or plain-text files -- use hardware-backed secure storage)
(You MUST use at least two public key hashes for certificate pinning on iOS -- TrustKit/iOS enforces this and will throw if only one is provided)
(You MUST treat jailbreak/root detection as one layer in defense-in-depth -- client-side checks can be bypassed, always validate server-side too)
(You MUST configure both iOS ATS and Android Network Security Config to enforce HTTPS -- never ship with NSAllowArbitraryLoads: true in production)
(You MUST add NSFaceIDUsageDescription to Info.plist when using Face ID -- the OS silently falls back to passcode without it)
Failure to follow these rules will expose user credentials, enable MITM attacks, and create false security assumptions.
</critical_reminders>
Files (skills)
-
examples
-
core.md 13.2 KB
# React Native Security - Core Patterns > Secure storage, certificate pinning, and biometric authentication. See [SKILL.md](../SKILL.md) for decision guidance and red flags. **Prerequisites**: Familiarity with React Native development and async/await patterns. --- ## Pattern 1: Secure Storage with expo-secure-store ### Basic Token Storage ```typescript import * as SecureStore from "expo-secure-store"; const AUTH_TOKEN_KEY = "auth-token"; const REFRESH_TOKEN_KEY = "refresh-token"; // ---- Store ---- async function storeTokens( accessToken: string, refreshToken: string, ): Promise<void> { await SecureStore.setItemAsync(AUTH_TOKEN_KEY, accessToken); await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refreshToken); } // ---- Retrieve ---- async function getAccessToken(): Promise<string | null> { return SecureStore.getItemAsync(AUTH_TOKEN_KEY); } // ---- Delete ---- async function clearTokens(): Promise<void> { await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY); await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY); } ``` **Why good:** each token has a named constant key, separate store/retrieve/delete functions, hardware-backed encryption on both platforms ### Biometric-Protected Storage ```typescript import * as SecureStore from "expo-secure-store"; const BIOMETRIC_TOKEN_KEY = "biometric-protected-token"; async function storeBiometricProtectedToken(token: string): Promise<void> { await SecureStore.setItemAsync(BIOMETRIC_TOKEN_KEY, token, { requireAuthentication: true, authenticationPrompt: "Authenticate to save your credentials", keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, }); } async function getBiometricProtectedToken(): Promise<string | null> { try { return await SecureStore.getItemAsync(BIOMETRIC_TOKEN_KEY, { requireAuthentication: true, authenticationPrompt: "Authenticate to access your credentials", }); } catch (error) { // User cancelled biometric prompt or auth failed return null; } } ``` **Why good:** `requireAuthentication` gates reads behind biometric/passcode, `WHEN_UNLOCKED_THIS_DEVICE_ONLY` prevents backup extraction, error handling covers cancelled prompts ### Bad Example: AsyncStorage for Sensitive Data ```typescript // BAD: AsyncStorage is a plain-text file on device import AsyncStorage from "@react-native-async-storage/async-storage"; await AsyncStorage.setItem("auth-token", token); // Readable on rooted devices await AsyncStorage.setItem("user-password", password); // NEVER do this ``` **Why bad:** AsyncStorage stores data as unencrypted JSON on the filesystem -- trivially readable on jailbroken/rooted devices, no encryption, no access control --- ## Pattern 2: Secure Storage with react-native-keychain ### Credential Storage with Biometric Protection ```typescript import * as Keychain from "react-native-keychain"; const AUTH_SERVICE = "com.myapp.auth"; async function storeCredentials( username: string, token: string, ): Promise<boolean> { try { await Keychain.setGenericPassword(username, token, { service: AUTH_SERVICE, accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE, accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, authenticationType: Keychain.AUTHENTICATION_TYPE.BIOMETRICS, }); return true; } catch (error) { return false; } } async function getCredentials(): Promise<{ username: string; password: string; } | null> { try { const credentials = await Keychain.getGenericPassword({ service: AUTH_SERVICE, authenticationPrompt: { title: "Authenticate", subtitle: "Verify your identity to access credentials", cancel: "Cancel", }, }); if (credentials === false) return null; return { username: credentials.username, password: credentials.password }; } catch (error) { // Biometric auth failed or was cancelled return null; } } async function clearCredentials(): Promise<void> { await Keychain.resetGenericPassword({ service: AUTH_SERVICE }); } ``` **Why good:** `BIOMETRY_ANY_OR_DEVICE_PASSCODE` provides fallback if biometrics unavailable, service name isolates credentials, `WHEN_UNLOCKED_THIS_DEVICE_ONLY` prevents backup extraction, error handling covers all failure paths ### Checking Biometric Availability ```typescript import * as Keychain from "react-native-keychain"; async function getBiometricInfo(): Promise<{ available: boolean; biometryType: string | null; }> { const biometryType = await Keychain.getSupportedBiometryType(); return { available: biometryType !== null, biometryType, // "TouchID" | "FaceID" | "Fingerprint" | "Face" | "Iris" | null }; } ``` --- ## Pattern 3: expo-secure-store vs react-native-keychain Comparison | Feature | expo-secure-store | react-native-keychain | | ------------------------- | ------------------------------------- | ------------------------------------------------- | | **Workflow** | Expo managed + bare | Bare RN (+ Expo dev builds) | | **API style** | Key-value (string only) | Username/password pairs | | **Value size limit** | ~2KB | No practical limit | | **Biometric gating** | `requireAuthentication` option | `ACCESS_CONTROL` enum (granular) | | **Access control** | Basic (unlock/passcode) | Fine-grained (biometry types, current set vs any) | | **iOS storage** | Keychain (`kSecClassGenericPassword`) | Keychain (`kSecClassGenericPassword`) | | **Android storage** | Keystore-encrypted SharedPreferences | Keystore (RSA or AES) | | **Persists on reinstall** | iOS: yes, Android: no | iOS: yes, Android: no | | **Sync API** | `setItem`/`getItem` (sync) | No sync API | | **Internet credentials** | No | Yes (`setInternetCredentials`) | **Use expo-secure-store when:** Expo managed workflow, simple token storage, values under 2KB. **Use react-native-keychain when:** Need granular biometric access control, storing larger values, bare RN workflow, or need internet credential separation. --- ## Pattern 4: Certificate Pinning ### JS-Level Pinning with react-native-ssl-public-key-pinning ```typescript import { initializeSslPinning } from "react-native-ssl-public-key-pinning"; const PIN_EXPIRATION = "2026-12-31"; // Call as early as possible in app entry (before any network requests) async function setupCertificatePinning(): Promise<void> { try { await initializeSslPinning({ "api.example.com": { includeSubdomains: true, publicKeyHashes: [ // Primary certificate hash (current cert) "CLOmM1/OXvSPjw5UOYbAf9GKOxImEp9hhku9W90fHMk=", // Backup certificate hash (next cert -- REQUIRED on iOS) "hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=", ], expirationDate: PIN_EXPIRATION, }, }); } catch (error) { // Pinning failed to initialize -- block network access or alert throw new Error("Certificate pinning initialization failed"); } } ``` **Why good:** called at app entry before any requests, two hashes (primary + backup), expiration date prevents permanent bricking, error handling prevents silent failure See [reference.md](../reference.md) for pin hash generation commands. ### Certificate Rotation Strategy 1. Generate hash for the NEW certificate before deploying it to the server 2. Ship an app update that pins BOTH the current and new certificate hashes 3. Deploy the new certificate to the server 4. After the app update has propagated, remove the old hash in the next release 5. Always keep `expirationDate` set -- it acts as a safety valve if rotation is missed ### Bad Example: Single Pin Without Expiration ```typescript // BAD: single pin + no expiration await initializeSslPinning({ "api.example.com": { includeSubdomains: true, publicKeyHashes: [ "CLOmM1/OXvSPjw5UOYbAf9GKOxImEp9hhku9W90fHMk=", // Missing backup hash -- iOS will THROW ], // Missing expirationDate -- app bricks if cert rotates }, }); ``` **Why bad:** iOS (TrustKit) requires two hashes and throws with only one, no expiration date means the app permanently breaks when the certificate is rotated until users update --- ## Pattern 5: Biometric Authentication with expo-local-authentication ### Full Biometric Flow ```typescript import * as LocalAuthentication from "expo-local-authentication"; type BiometricResult = | { success: true } | { success: false; reason: "no-hardware" | "not-enrolled" | "failed" | "cancelled"; }; async function authenticateWithBiometrics(): Promise<BiometricResult> { const hasHardware = await LocalAuthentication.hasHardwareAsync(); if (!hasHardware) { return { success: false, reason: "no-hardware" }; } const isEnrolled = await LocalAuthentication.isEnrolledAsync(); if (!isEnrolled) { return { success: false, reason: "not-enrolled" }; } const result = await LocalAuthentication.authenticateAsync({ promptMessage: "Verify your identity", cancelLabel: "Cancel", disableDeviceFallback: false, // Allow passcode as backup fallbackLabel: "Use passcode", // iOS only }); if (result.success) { return { success: true }; } return { success: false, reason: result.error === "user_cancel" ? "cancelled" : "failed", }; } ``` **Why good:** checks hardware and enrollment before prompting (prevents confusing errors), typed result discriminated union, passcode fallback enabled, handles cancellation distinctly from failure ### Checking Available Biometric Types ```typescript import * as LocalAuthentication from "expo-local-authentication"; async function getAvailableBiometrics(): Promise<string[]> { const types = await LocalAuthentication.supportedAuthenticationTypesAsync(); return types.map((type) => { switch (type) { case LocalAuthentication.AuthenticationType.FINGERPRINT: return "Fingerprint"; case LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION: return "Face Recognition"; case LocalAuthentication.AuthenticationType.IRIS: return "Iris"; // Android only default: return "Unknown"; } }); } ``` --- ## Pattern 6: Combined Biometric + Secure Storage Flow The most secure pattern: biometric-gated credential retrieval. The credentials never leave secure storage without biometric verification. ### With react-native-keychain ```typescript import * as Keychain from "react-native-keychain"; const CREDENTIAL_SERVICE = "com.myapp.credentials"; async function setupBiometricLogin( username: string, token: string, ): Promise<boolean> { const biometryType = await Keychain.getSupportedBiometryType(); if (!biometryType) return false; try { await Keychain.setGenericPassword(username, token, { service: CREDENTIAL_SERVICE, accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE, accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, }); return true; } catch { return false; } } async function biometricLogin(): Promise<{ username: string; token: string; } | null> { try { const credentials = await Keychain.getGenericPassword({ service: CREDENTIAL_SERVICE, authenticationPrompt: { title: "Sign In", subtitle: "Use biometrics to access your account", cancel: "Cancel", }, }); if (credentials === false) return null; return { username: credentials.username, token: credentials.password }; } catch { // Biometric auth failed, cancelled, or credentials don't exist return null; } } ``` **Why good:** credentials are stored once during initial login, subsequent logins require biometric verification to read them back, the OS handles the biometric prompt natively (no custom UI), `BIOMETRY_ANY_OR_DEVICE_PASSCODE` provides fallback ### With expo-secure-store ```typescript import * as SecureStore from "expo-secure-store"; import * as LocalAuthentication from "expo-local-authentication"; const SECURE_TOKEN_KEY = "biometric-session-token"; async function setupBiometricSession(token: string): Promise<boolean> { const hasHardware = await LocalAuthentication.hasHardwareAsync(); const isEnrolled = await LocalAuthentication.isEnrolledAsync(); if (!hasHardware || !isEnrolled) return false; await SecureStore.setItemAsync(SECURE_TOKEN_KEY, token, { requireAuthentication: true, authenticationPrompt: "Authenticate to enable biometric login", }); return true; } async function biometricSessionRetrieve(): Promise<string | null> { try { // The OS automatically prompts for biometric when requireAuthentication was set return await SecureStore.getItemAsync(SECURE_TOKEN_KEY, { requireAuthentication: true, authenticationPrompt: "Authenticate to sign in", }); } catch { return null; } } ``` **Why good:** `requireAuthentication` on both set and get ensures biometric gating, the OS handles prompt presentation, two-step check (hardware + enrollment) before setup -
hardening.md 13 KB
# React Native Security - App Hardening > Code obfuscation, jailbreak detection, screenshot prevention, and network security configuration. See [SKILL.md](../SKILL.md) for decision guidance and red flags. **Related**: [core.md](core.md) for secure storage, certificate pinning, and biometric auth. --- ## Pattern 1: Jailbreak/Root Detection ### Basic Detection with jail-monkey ```typescript import JailMonkey from "jail-monkey"; interface DeviceSecurityReport { isCompromised: boolean; isJailbroken: boolean; canMockLocation: boolean; isDebugMode: boolean; isOnExternalStorage: boolean; // Android only } function assessDeviceSecurity(): DeviceSecurityReport { const isJailbroken = JailMonkey.isJailBroken(); const canMockLocation = JailMonkey.canMockLocation(); const isDebugMode = JailMonkey.isDebuggedMode(); const isOnExternalStorage = JailMonkey.isOnExternalStorage(); return { isCompromised: isJailbroken || isDebugMode, isJailbroken, canMockLocation, isDebugMode, isOnExternalStorage, }; } ``` **Why good:** checks multiple indicators, combines jailbreak + debug for `isCompromised` flag, structured return for logging/reporting ### Response Strategy ```typescript import { Alert } from "react-native"; type SecurityAction = "block" | "warn" | "log"; const SECURITY_ACTIONS: Record<string, SecurityAction> = { jailbroken: "warn", // Alert user but allow access debugMode: "log", // Log only (may be legitimate dev use) mockLocation: "block", // Block for location-sensitive features }; function handleCompromisedDevice(report: DeviceSecurityReport): void { if (report.isJailbroken && SECURITY_ACTIONS.jailbroken === "warn") { Alert.alert( "Security Notice", "This device appears to be modified. Some features may be restricted.", [{ text: "I Understand" }], ); } if (report.canMockLocation && SECURITY_ACTIONS.mockLocation === "block") { // Disable location-dependent features } // Always report to server for monitoring reportSecurityEvent({ type: "device-integrity", ...report, timestamp: Date.now(), }); } ``` **Why good:** configurable response per threat type (not blanket block), server-side reporting for monitoring, user-friendly messaging ### Bad Example: Blocking Without Server Validation ```typescript // BAD: relying solely on client-side detection if (JailMonkey.isJailBroken()) { // Attacker hooks JailMonkey.isJailBroken() to return false exitApp(); // Bypassed trivially with Frida } ``` **Why bad:** client-side checks are bypassable with hooking frameworks (Frida, Objection), no server-side validation means the check provides false security assurance --- ## Pattern 2: Code Obfuscation ### Layer 1: Hermes Bytecode (Default) Hermes is enabled by default since React Native 0.70. It compiles JavaScript to optimized bytecode at build time. **Verify Hermes is enabled:** ```json // app.json (Expo) { "expo": { "jsEngine": "hermes" } } ``` ```groovy // android/gradle.properties (bare RN) hermesEnabled=true ``` Hermes bytecode is NOT encrypted -- tools like `hbctool` can decompile it. It raises the reverse engineering bar but is not a substitute for proper secret management. ### Layer 2: Metro Obfuscation Transformer Adds JavaScript-level obfuscation (variable renaming, control flow flattening, dead code injection) on top of Hermes. ```javascript // metro.config.js const { getDefaultConfig } = require("@react-native/metro-config"); const config = getDefaultConfig(__dirname); config.transformer = { ...config.transformer, babelTransformerPath: require.resolve("react-native-obfuscating-transformer"), }; module.exports = config; ``` **Obfuscation config (obfuscating-transformer.config.js):** ```javascript module.exports = { // Files to obfuscate (regex pattern) filter: (filename) => { return ( filename.startsWith("src/") && !filename.includes("__tests__") && !filename.includes(".test.") ); }, // Safe options -- avoid stringArray (breaks builds) compact: true, controlFlowFlattening: true, controlFlowFlatteningThreshold: 0.5, deadCodeInjection: true, deadCodeInjectionThreshold: 0.2, identifierNamesGenerator: "hexadecimal", renameGlobals: false, // Renaming globals breaks RN module system // DO NOT enable stringArray -- known to break React Native builds }; ``` **Why good:** filter excludes test files, conservative thresholds balance security vs performance, `renameGlobals: false` prevents module resolution breaks, explicit warning about `stringArray` ### Layer 3: ProGuard/R8 for Android R8 (ProGuard replacement) shrinks, obfuscates, and optimizes Android native/Java code. ```groovy // android/app/build.gradle android { buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" } } } ``` ```proguard # android/app/proguard-rules.pro # Keep React Native bridge classes -keep class com.facebook.react.** { *; } -keep class com.facebook.hermes.** { *; } -keep class com.facebook.jni.** { *; } # Keep classes used via reflection -keepclassmembers class * { @com.facebook.react.uimanager.annotations.ReactProp <methods>; } # Keep native methods -keepclasseswithmembernames class * { native <methods>; } # Keep serializable classes (if using) -keepclassmembers class * implements java.io.Serializable { static final long serialVersionUID; private static final java.io.ObjectStreamField[] serialPersistentFields; !static !transient <fields>; private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); } # Keep your app's model classes if accessed via reflection # -keep class com.yourapp.models.** { *; } ``` **Why good:** `minifyEnabled true` activates R8, React Native bridge classes preserved, native methods preserved, reflection-accessed classes protected with `-keep` rules **Important:** R8 only runs in release builds. Test release builds thoroughly -- crashes from missing classes only appear in production. --- ## Pattern 3: Screenshot and Screen Recording Prevention ### Expo ScreenCapture (Managed Workflow) ```typescript import { useEffect } from "react"; import { useIsFocused } from "@react-navigation/native"; import * as ScreenCapture from "expo-screen-capture"; /** * Hook to prevent screen capture while a screen is focused. * Enables capture again when navigating away or unmounting. */ function usePreventCapture(): void { const isFocused = useIsFocused(); useEffect(() => { if (isFocused) { ScreenCapture.preventScreenCaptureAsync(); } else { ScreenCapture.allowScreenCaptureAsync(); } return () => { ScreenCapture.allowScreenCaptureAsync(); }; }, [isFocused]); } // Usage in a sensitive screen function BankingScreen() { usePreventCapture(); return <AccountDetails />; } ``` **Why good:** per-screen control (not global), cleanup restores capture on navigate away, isFocused integration with navigation lifecycle ### Screenshot Listener (Detection Instead of Prevention) ```typescript import { useEffect } from "react"; import * as ScreenCapture from "expo-screen-capture"; function useScreenshotListener(onCapture: () => void): void { useEffect(() => { const subscription = ScreenCapture.addScreenshotListener(() => { onCapture(); }); return () => subscription.remove(); }, [onCapture]); } // Usage: alert user and log event function SensitiveScreen() { useScreenshotListener(() => { Alert.alert("Screenshot Detected", "Screenshots of this screen are monitored."); reportSecurityEvent({ type: "screenshot-detected", screen: "sensitive" }); }); return <SensitiveContent />; } ``` **Why good:** useful when you cannot prevent screenshots (some iOS scenarios) but want to detect and log them, subscription cleanup prevents memory leaks ### Android FLAG_SECURE (Bare RN) For bare React Native without Expo, use the native Android `FLAG_SECURE` flag: ```typescript import { Platform, NativeModules } from "react-native"; // Requires a small native module that calls: // getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) function enableSecureMode(): void { if (Platform.OS === "android") { NativeModules.ScreenSecurity?.enableSecureFlag(); } } function disableSecureMode(): void { if (Platform.OS === "android") { NativeModules.ScreenSecurity?.disableSecureFlag(); } } ``` **Platform differences:** | Platform | Screenshots | Screen Recording | App Switcher Thumbnail | | --------------------- | --------------- | ---------------- | ---------------------- | | iOS (ScreenCapture) | Blocked (blank) | Blocked | Not affected | | Android (FLAG_SECURE) | Blocked (black) | Blocked (black) | Black thumbnail | --- ## Pattern 4: Network Security Configuration ### iOS App Transport Security (ATS) ATS enforces HTTPS by default since iOS 9. The correct production configuration is to NOT add exceptions. ```xml <!-- ios/YourApp/Info.plist --> <!-- PRODUCTION: Do NOT include NSAppTransportSecurity at all (defaults are secure) --> <!-- OR explicitly enforce: --> <key>NSAppTransportSecurity</key> <dict> <key>NSAllowArbitraryLoads</key> <false/> </dict> <!-- Face ID permission (REQUIRED when using biometrics) --> <key>NSFaceIDUsageDescription</key> <string>Use Face ID to securely access your account</string> ``` **Development-only exception (NEVER ship this):** ```xml <!-- For local development server only --> <key>NSAppTransportSecurity</key> <dict> <key>NSExceptionDomains</key> <dict> <key>localhost</key> <dict> <key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key> <true/> </dict> </dict> </dict> ``` **Why good:** no blanket `NSAllowArbitraryLoads`, development exceptions scoped to localhost only, includes `NSFaceIDUsageDescription` for biometric features ### Bad Example: Disabling ATS ```xml <!-- BAD: disables ALL transport security --> <key>NSAppTransportSecurity</key> <dict> <key>NSAllowArbitraryLoads</key> <true/> </dict> ``` **Why bad:** allows plain HTTP to any domain, Apple may reject apps with this in production, exposes all network traffic to interception ### Android Network Security Config ```xml <!-- android/app/src/main/res/xml/network_security_config.xml --> <network-security-config> <!-- Block all clear text traffic by default --> <base-config cleartextTrafficPermitted="false"> <trust-anchors> <certificates src="system" /> </trust-anchors> </base-config> <!-- Certificate pinning for your API --> <domain-config> <domain includeSubdomains="true">api.example.com</domain> <pin-set expiration="2026-12-31"> <pin digest="SHA-256">CLOmM1/OXvSPjw5UOYbAf9GKOxImEp9hhku9W90fHMk=</pin> <pin digest="SHA-256">hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=</pin> </pin-set> </domain-config> <!-- Debug override: allow local dev server (debug builds only) --> <debug-overrides> <trust-anchors> <certificates src="user" /> </trust-anchors> </debug-overrides> </network-security-config> ``` **Reference the config in AndroidManifest.xml:** ```xml <!-- android/app/src/main/AndroidManifest.xml --> <application android:networkSecurityConfig="@xml/network_security_config" ...> ``` **Why good:** clear text blocked globally, native pinning as defense-in-depth alongside JS-level pinning, debug-overrides allow local dev without compromising release builds, expiration date on pins ### Expo Configuration (app.json) ```json { "expo": { "plugins": [ [ "expo-build-properties", { "android": { "enableProguardInReleaseBuilds": true, "enableShrinkResourcesInReleaseBuilds": true } } ] ] } } ``` --- ## Pattern 5: Secure Data Serialization Never store structured sensitive data as raw JSON strings. Use a wrapper that encrypts before storing and decrypts after retrieval. ```typescript import * as SecureStore from "expo-secure-store"; const SESSION_KEY = "encrypted-session"; const MAX_SECURE_STORE_BYTES = 2048; interface SecureSession { userId: string; accessToken: string; expiresAt: number; } async function storeSession(session: SecureSession): Promise<void> { const serialized = JSON.stringify(session); if (new Blob([serialized]).size > MAX_SECURE_STORE_BYTES) { throw new Error("Session data exceeds secure storage limit"); } await SecureStore.setItemAsync(SESSION_KEY, serialized); } async function getSession(): Promise<SecureSession | null> { const raw = await SecureStore.getItemAsync(SESSION_KEY); if (!raw) return null; const session: SecureSession = JSON.parse(raw); // Check expiration if (session.expiresAt < Date.now()) { await SecureStore.deleteItemAsync(SESSION_KEY); return null; } return session; } ``` **Why good:** size check before storing prevents silent failures, expiration check on retrieval prevents using stale tokens, typed session interface, automatic cleanup of expired sessions
-
-
reference.md 5.2 KB
# React Native Security Reference > Checklists, library API reference, and quick-lookup commands. See [SKILL.md](SKILL.md) for decision frameworks, red flags, and anti-patterns. --- ## Security Checklist ### Before Release - [ ] No tokens/credentials in AsyncStorage (use secure storage) - [ ] No secrets hardcoded in JS bundle (use secure config) - [ ] HTTPS enforced (ATS on iOS, Network Security Config on Android) - [ ] `NSAllowArbitraryLoads` is NOT `true` in production Info.plist - [ ] `NSFaceIDUsageDescription` set (if using biometrics) - [ ] Hermes enabled (bytecode compilation) - [ ] ProGuard/R8 enabled for Android release builds - [ ] Certificate pinning initialized at app entry - [ ] At least 2 pin hashes per domain (iOS requirement) - [ ] Pin expiration date set (prevent bricking) - [ ] Debug-only network exceptions not in release builds ### For High-Security Apps - [ ] Jailbreak/root detection implemented - [ ] Security events reported to server - [ ] Screenshot prevention on sensitive screens - [ ] Biometric authentication on sensitive operations - [ ] JS code obfuscation transformer configured - [ ] Server-side device attestation integrated - [ ] Minimum TLS 1.2 enforced --- ## Library Quick Reference ### expo-secure-store ```typescript import * as SecureStore from "expo-secure-store"; // Store await SecureStore.setItemAsync(key, value, options?); SecureStore.setItem(key, value, options?); // Sync // Retrieve const val = await SecureStore.getItemAsync(key, options?); const val = SecureStore.getItem(key, options?); // Sync // Delete await SecureStore.deleteItemAsync(key, options?); // Check biometric support SecureStore.canUseBiometricAuthentication(); // boolean // Options: { keychainService?, keychainAccessible?, requireAuthentication?, authenticationPrompt? } // Accessibility: WHEN_UNLOCKED (default), AFTER_FIRST_UNLOCK, WHEN_UNLOCKED_THIS_DEVICE_ONLY, etc. // Size limit: ~2KB per value ``` ### react-native-keychain ```typescript import * as Keychain from "react-native-keychain"; // Store credentials await Keychain.setGenericPassword(username, password, options?); // Retrieve (triggers biometric if accessControl set) const creds = await Keychain.getGenericPassword(options?); // Returns { username, password, service, storage } or false // Delete await Keychain.resetGenericPassword(options?); // Check biometrics const type = await Keychain.getSupportedBiometryType(); // "TouchID" | "FaceID" | "Fingerprint" | "Face" | "Iris" | null // Key options: // accessControl: ACCESS_CONTROL.BIOMETRY_ANY | BIOMETRY_CURRENT_SET | BIOMETRY_ANY_OR_DEVICE_PASSCODE // accessible: ACCESSIBLE.WHEN_UNLOCKED | WHEN_UNLOCKED_THIS_DEVICE_ONLY | AFTER_FIRST_UNLOCK // authenticationType: AUTHENTICATION_TYPE.BIOMETRICS | DEVICE_PASSCODE_OR_BIOMETRICS // service: string (namespace for multiple credential sets) ``` ### expo-local-authentication ```typescript import * as LocalAuthentication from "expo-local-authentication"; // Check hardware support const hasHardware = await LocalAuthentication.hasHardwareAsync(); // Check enrollment const isEnrolled = await LocalAuthentication.isEnrolledAsync(); // Authenticate const result = await LocalAuthentication.authenticateAsync({ promptMessage: "Verify identity", cancelLabel: "Cancel", disableDeviceFallback: false, fallbackLabel: "Use passcode", // iOS only requireConfirmation: true, // Android only biometricsSecurityLevel: "strong", // Android: "weak" | "strong" }); // result: { success: true } | { success: false, error: string, warning?: string } // Available types const types = await LocalAuthentication.supportedAuthenticationTypesAsync(); // AuthenticationType.FINGERPRINT (1) | FACIAL_RECOGNITION (2) | IRIS (3) // Cancel (Android only) await LocalAuthentication.cancelAuthenticate(); ``` ### react-native-ssl-public-key-pinning ```typescript import { initializeSslPinning } from "react-native-ssl-public-key-pinning"; // Initialize at app entry (before any network requests) await initializeSslPinning({ "api.example.com": { includeSubdomains: true, // Pin subdomains too publicKeyHashes: [ // Minimum 2 on iOS "hash1...", // Primary (current cert) "hash2...", // Backup (next cert) ], expirationDate: "2026-12-31", // Safety valve (optional but recommended) }, }); ``` ### jail-monkey ```typescript import JailMonkey from "jail-monkey"; JailMonkey.isJailBroken(); // boolean - jailbreak/root detected JailMonkey.canMockLocation(); // boolean - mock location enabled JailMonkey.isDebuggedMode(); // boolean - debugger attached JailMonkey.isOnExternalStorage(); // boolean - Android only ``` --- ## Pin Hash Generation Commands ```bash # From certificate file openssl x509 -in cert.pem -pubkey -noout | \ openssl pkey -pubin -outform DER | \ openssl dgst -sha256 -binary | \ openssl enc -base64 # From live server openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null | \ openssl x509 -pubkey -noout | \ openssl pkey -pubin -outform DER | \ openssl dgst -sha256 -binary | \ openssl enc -base64 # Verify a pin against a server openssl s_client -connect api.example.com:443 2>/dev/null | \ openssl x509 -noout -fingerprint -sha256 ``` -
SKILL.md 19.2 KB
--- name: mobile-security-react-native description: Secure storage, certificate pinning, biometric auth, jailbreak detection, code obfuscation, network security, screenshot prevention for React Native --- # React Native Security Patterns > **Quick Guide:** Defense-in-depth: layer secure storage (expo-secure-store or react-native-keychain), certificate pinning, biometric authentication, jailbreak/root detection, and code obfuscation. Never store secrets in AsyncStorage or JS bundles. Use Hermes bytecode as your first obfuscation layer. iOS Keychain persists across reinstalls; Android Keystore does not. Certificate pins require at least two hashes (primary + backup) on iOS. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST NEVER store tokens, passwords, API keys, or PII in AsyncStorage or plain-text files -- use hardware-backed secure storage)** **(You MUST use at least two public key hashes for certificate pinning on iOS -- TrustKit/iOS enforces this and will throw if only one is provided)** **(You MUST treat jailbreak/root detection as one layer in defense-in-depth -- client-side checks can be bypassed, always validate server-side too)** **(You MUST configure both iOS ATS and Android Network Security Config to enforce HTTPS -- never ship with `NSAllowArbitraryLoads: true` in production)** **(You MUST add `NSFaceIDUsageDescription` to Info.plist when using Face ID -- the OS silently falls back to passcode without it)** </critical_requirements> --- **Auto-detection:** secure storage, SecureStore, expo-secure-store, react-native-keychain, Keychain, Keystore, certificate pinning, SSL pinning, react-native-ssl-public-key-pinning, TrustKit, jailbreak detection, root detection, jail-monkey, biometric authentication, expo-local-authentication, Face ID, Touch ID, fingerprint, code obfuscation, Hermes bytecode, ProGuard, R8, screen capture prevention, App Transport Security, Network Security Config, MITM **When to use:** - Storing credentials, tokens, or sensitive data on device - Implementing certificate pinning to prevent MITM attacks - Adding biometric authentication (Face ID, Touch ID, fingerprint) - Detecting jailbroken/rooted devices - Hardening builds with code obfuscation (Hermes, ProGuard/R8) - Preventing screenshot/screen recording of sensitive screens - Configuring network security (ATS on iOS, Network Security Config on Android) **When NOT to use:** - General React Native component architecture (not a security concern) - Server-side API security (use your backend security approach) - Web-only applications (web security patterns differ fundamentally) **Key patterns covered:** - Secure storage with expo-secure-store and react-native-keychain - Certificate pinning with react-native-ssl-public-key-pinning - Biometric authentication with expo-local-authentication and react-native-keychain - Jailbreak/root detection with jail-monkey - Code obfuscation: Hermes bytecode, Metro transformer, ProGuard/R8 - Network security: iOS ATS and Android Network Security Config - Screenshot and screen recording prevention - Defense-in-depth strategy and security layering **Detailed Resources:** - [examples/core.md](examples/core.md) - Secure storage, certificate pinning, biometric auth - [examples/hardening.md](examples/hardening.md) - Code obfuscation, jailbreak detection, screenshot prevention, network config - [reference.md](reference.md) - Security checklist, library API reference, pin hash commands --- <philosophy> ## Philosophy Mobile security is **defense-in-depth** -- no single measure is sufficient. Attackers can bypass any individual protection, so layer multiple defenses: secure storage protects data at rest, certificate pinning protects data in transit, biometric authentication protects access, jailbreak detection identifies compromised environments, and code obfuscation raises the cost of reverse engineering. **Core principles:** 1. **Never trust the client** -- all sensitive operations need server-side validation. Client-side checks are speed bumps, not walls. 2. **Hardware-backed storage** -- iOS Keychain and Android Keystore provide hardware-level encryption. AsyncStorage is a plain-text file. 3. **HTTPS everywhere** -- enforce TLS for all network communication. Certificate pinning adds a second layer against compromised CAs. 4. **Minimal data exposure** -- store the least sensitive data possible on device. Prefer short-lived tokens over long-lived credentials. 5. **Fail secure** -- when security checks fail (biometric, jailbreak), deny access by default rather than falling back to insecure paths. **Mental model:** Think of mobile security as concentric rings. Each ring (secure storage, pinning, biometrics, obfuscation, jailbreak detection) independently slows attackers. The combination creates a security posture that makes exploitation impractical for most threat models. **Platform differences that matter:** | Concern | iOS | Android | | --------------------- | ------------------------------------------ | -------------------------------------------------------------- | | Secure storage | Keychain (persists across reinstalls) | Keystore + SharedPreferences (cleared on uninstall) | | Biometrics | Face ID / Touch ID | Fingerprint / Face Unlock (weak vs strong) | | Network security | ATS (default HTTPS since iOS 9) | Network Security Config (clear text blocked API 28+) | | Code protection | Hermes bytecode (no ProGuard for JS) | Hermes bytecode + ProGuard/R8 for native/Java | | Screenshot prevention | Effective (screen recording + screenshots) | FLAG_SECURE (effective for screenshots, partial for recording) | </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Secure Storage Two main libraries: **expo-secure-store** (Expo-managed, simpler API, 2KB value limit) and **react-native-keychain** (bare RN, biometric-protected credentials, no size limit). **expo-secure-store** uses iOS Keychain and Android Keystore-encrypted SharedPreferences. Values are strings with a ~2KB limit. Supports biometric gating via `requireAuthentication`. ```typescript import * as SecureStore from "expo-secure-store"; const AUTH_TOKEN_KEY = "auth-token"; // Store securely with biometric protection await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token, { requireAuthentication: true, authenticationPrompt: "Authenticate to save credentials", }); // Retrieve (prompts biometric if requireAuthentication was set) const stored = await SecureStore.getItemAsync(AUTH_TOKEN_KEY); ``` **react-native-keychain** provides credential storage with granular access control and biometric gating via `ACCESS_CONTROL` options. ```typescript import * as Keychain from "react-native-keychain"; await Keychain.setGenericPassword("user@example.com", token, { accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY_OR_DEVICE_PASSCODE, accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, }); ``` **Why good:** hardware-backed encryption, biometric gating prevents unauthorized reads, `WHEN_UNLOCKED_THIS_DEVICE_ONLY` prevents extraction from backups See [examples/core.md](examples/core.md) for full secure storage patterns with error handling and library comparison. --- ### Pattern 2: Certificate Pinning Pin your server's public key hashes to prevent MITM attacks even when a device's CA store is compromised. Use **react-native-ssl-public-key-pinning** for a JS-level approach that works with all HTTP clients. ```typescript import { initializeSslPinning } from "react-native-ssl-public-key-pinning"; const PIN_EXPIRATION_DATE = "2026-12-31"; await initializeSslPinning({ "api.example.com": { includeSubdomains: true, publicKeyHashes: [ "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // Primary "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", // Backup (REQUIRED on iOS) ], expirationDate: PIN_EXPIRATION_DATE, }, }); ``` **Why good:** intercepts all fetch/XMLHttpRequest calls globally, no native code changes required, expiration date prevents bricking when certificates rotate **Gotcha:** iOS requires at least two hashes per domain. Providing only one causes `initializeSslPinning` to throw. See [examples/core.md](examples/core.md) for rotation strategy and [reference.md](reference.md) for pin hash generation commands. --- ### Pattern 3: Biometric Authentication Two approaches: **expo-local-authentication** (standalone biometric prompt, no credential storage) and **react-native-keychain** (biometric-gated credential retrieval). ```typescript import * as LocalAuthentication from "expo-local-authentication"; async function authenticateUser(): Promise<boolean> { const hasHardware = await LocalAuthentication.hasHardwareAsync(); const isEnrolled = await LocalAuthentication.isEnrolledAsync(); if (!hasHardware || !isEnrolled) return false; const result = await LocalAuthentication.authenticateAsync({ promptMessage: "Verify your identity", disableDeviceFallback: false, cancelLabel: "Cancel", }); return result.success; } ``` **Why good:** checks hardware + enrollment before prompting, disableDeviceFallback: false allows passcode as backup, clean boolean result **Gotcha:** Face ID requires `NSFaceIDUsageDescription` in Info.plist. Without it, iOS silently falls back to passcode (no error, no Face ID prompt). Face ID is not supported in Expo Go -- use a development build. See [examples/core.md](examples/core.md) for biometric-gated credential flow combining both libraries. --- ### Pattern 4: Jailbreak/Root Detection Detect compromised devices where security controls (sandboxing, code signing) are disabled. Use **jail-monkey** for detection checks. ```typescript import JailMonkey from "jail-monkey"; function getDeviceSecurityStatus() { return { isJailbroken: JailMonkey.isJailBroken(), canMockLocation: JailMonkey.canMockLocation(), isDebugMode: JailMonkey.isDebuggedMode(), isOnExternalStorage: JailMonkey.isOnExternalStorage(), // Android only }; } ``` **Why good:** checks multiple indicators (not just one file path), includes location mocking and debug detection **Important:** Jailbreak detection is a speed bump, not a wall. Determined attackers bypass client-side checks with hooking frameworks (Frida, Objection). Always pair with server-side device attestation for high-security apps. See [examples/hardening.md](examples/hardening.md) for response strategies and server-side validation. --- ### Pattern 5: Code Obfuscation Layer 1: **Hermes bytecode** (enabled by default since RN 0.70) compiles JS to bytecode, making casual reverse engineering difficult. Layer 2: **Metro obfuscation transformer** for additional string encryption and control flow flattening. Layer 3: **ProGuard/R8** for Android native/Java code. ```javascript // metro.config.js -- adding obfuscation transformer const { getDefaultConfig } = require("@react-native/metro-config"); const config = getDefaultConfig(__dirname); config.transformer = { ...config.transformer, babelTransformerPath: require.resolve("react-native-obfuscating-transformer"), }; module.exports = config; ``` **Why good:** layered approach -- Hermes handles baseline, transformer adds string/flow obfuscation, ProGuard/R8 covers native code **Gotcha:** Not all obfuscation options work. The `stringArray` option in react-native-obfuscating-transformer is known to break builds. Test thoroughly. See [examples/hardening.md](examples/hardening.md) for ProGuard/R8 configuration and obfuscation options. --- ### Pattern 6: Network Security Configuration Enforce HTTPS at the OS level. iOS uses **App Transport Security** (ATS), Android uses **Network Security Config**. **iOS (Info.plist):** ATS enforces HTTPS by default since iOS 9. Never ship with `NSAllowArbitraryLoads: true`. **Android (network_security_config.xml):** Clear text blocked by default since API 28. Pin certificates natively for defense-in-depth alongside JS-level pinning. ```xml <!-- android/app/src/main/res/xml/network_security_config.xml --> <network-security-config> <base-config cleartextTrafficPermitted="false"> <trust-anchors> <certificates src="system" /> </trust-anchors> </base-config> <domain-config> <domain includeSubdomains="true">api.example.com</domain> <pin-set expiration="2026-12-31"> <pin digest="SHA-256">AAAAAAAAAA...=</pin> <pin digest="SHA-256">BBBBBBBBBB...=</pin> </pin-set> </domain-config> </network-security-config> ``` **Why good:** OS-level enforcement, clear text blocked globally, native pinning adds second layer beyond JS-level pinning See [examples/hardening.md](examples/hardening.md) for iOS ATS configuration and debug vs release network policies. --- ### Pattern 7: Screenshot and Screen Recording Prevention Prevent screen capture on sensitive screens (banking, credentials, personal data). ```typescript import { useIsFocused } from "@react-navigation/native"; import * as ScreenCapture from "expo-screen-capture"; import { useEffect } from "react"; function usePreventCapture() { const isFocused = useIsFocused(); useEffect(() => { if (isFocused) { ScreenCapture.preventScreenCaptureAsync(); } else { ScreenCapture.allowScreenCaptureAsync(); } return () => { ScreenCapture.allowScreenCaptureAsync(); }; }, [isFocused]); } ``` **Why good:** per-screen control (not global), cleanup on unfocus/unmount, works for both screenshots and screen recording on iOS **Gotcha:** On Android, `FLAG_SECURE` reliably blocks screenshots but screen recording prevention varies by Android version. On iOS, screenshot content is replaced with blank but the user can still trigger the screenshot action. See [examples/hardening.md](examples/hardening.md) for non-Expo alternatives and listener-based approaches. </patterns> --- <decision_framework> ## Decision Framework ### Secure Storage Choice ``` Need to store credentials/tokens securely? +-- Using Expo managed workflow? | +-- YES -> expo-secure-store (simpler API, Expo-native) | +-- NO -> react-native-keychain (more control, biometric options) | +-- Need biometric-gated credential retrieval? | +-- YES -> react-native-keychain with ACCESS_CONTROL.BIOMETRY_ANY | +-- OR -> expo-secure-store with requireAuthentication: true | +-- Value larger than 2KB? | +-- YES -> react-native-keychain (no size limit) | +-- NO -> Either library works | +-- Need credential persistence across app reinstalls? +-- iOS -> Both persist (Keychain behavior) +-- Android -> Neither persists (cleared on uninstall) ``` ### Biometric Authentication Choice ``` Need biometric prompt (no credential storage)? +-- YES -> expo-local-authentication | Need biometric-gated credential storage/retrieval? +-- YES -> react-native-keychain with accessControl | Need to distinguish biometric security level (weak vs strong)? +-- YES -> expo-local-authentication (provides SecurityLevel enum) ``` ### Certificate Pinning Approach ``` Need SSL pinning? +-- JS-level (works with all HTTP clients)? | +-- YES -> react-native-ssl-public-key-pinning | +-- Native-level (defense-in-depth)? | +-- iOS -> TrustKit (via CocoaPods) | +-- Android -> Network Security Config XML | +-- Best practice -> Both JS-level AND native-level ``` ### Security Layering ``` Minimum viable security: 1. Secure storage (expo-secure-store or react-native-keychain) 2. HTTPS enforcement (ATS + Network Security Config) 3. Hermes bytecode (default since RN 0.70) Standard security (most apps): + Certificate pinning + Biometric authentication + ProGuard/R8 on Android High security (banking, healthcare, fintech): + Jailbreak/root detection + JS code obfuscation transformer + Screenshot prevention + Server-side device attestation + Runtime integrity checks ``` </decision_framework> --- <red_flags> ## RED FLAGS **High Priority Issues:** - Storing tokens or credentials in AsyncStorage -- it is a plain-text file, trivially readable on jailbroken/rooted devices - Storing API keys or secrets in the JS bundle -- the bundle is extractable from any published app - Shipping with `NSAllowArbitraryLoads: true` in production Info.plist -- disables ATS entirely, allows HTTP - Using only one public key hash for certificate pinning on iOS -- TrustKit throws, pinning silently fails - Relying solely on jailbreak detection for security -- client-side checks are bypassable with Frida/Objection **Medium Priority Issues:** - Not checking `hasHardwareAsync` and `isEnrolledAsync` before calling `authenticateAsync` -- crashes or confusing UX on devices without biometrics - Missing `NSFaceIDUsageDescription` in Info.plist -- Face ID silently falls back to passcode without any error - Using `ACCESSIBLE.ALWAYS` for Keychain items -- allows access even when device is locked (deprecated on iOS) - Skipping ProGuard/R8 on Android release builds -- native code is trivially decompilable without it - Not setting `expirationDate` on certificate pins -- expired certificates brick the app until users update **Gotchas & Edge Cases:** - iOS Keychain data persists across app reinstalls (same bundle ID); Android Keystore data does not -- plan token refresh accordingly - expo-secure-store has a ~2KB value size limit -- large tokens or data blobs will silently fail or throw - `z.coerce.boolean()` treats string `"false"` as truthy -- when parsing security config from strings, use explicit comparison - Android `FLAG_SECURE` blocks screenshots reliably but screen recording prevention varies by OS version and manufacturer - Hermes bytecode can be decompiled with tools like `hbctool` -- it raises the bar but is not true encryption - react-native-obfuscating-transformer's `stringArray` option breaks React Native builds -- avoid it - Android biometrics have "weak" (2D face) vs "strong" (fingerprint, 3D face) security levels -- financial apps should require strong - Certificate pins must be rotated before expiry -- set calendar reminders and use the `expirationDate` field as a safety net - `Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET` invalidates credentials when biometrics are re-enrolled -- use `BIOMETRY_ANY` for persistence across biometric changes </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** **(You MUST NEVER store tokens, passwords, API keys, or PII in AsyncStorage or plain-text files -- use hardware-backed secure storage)** **(You MUST use at least two public key hashes for certificate pinning on iOS -- TrustKit/iOS enforces this and will throw if only one is provided)** **(You MUST treat jailbreak/root detection as one layer in defense-in-depth -- client-side checks can be bypassed, always validate server-side too)** **(You MUST configure both iOS ATS and Android Network Security Config to enforce HTTPS -- never ship with `NSAllowArbitraryLoads: true` in production)** **(You MUST add `NSFaceIDUsageDescription` to Info.plist when using Face ID -- the OS silently falls back to passcode without it)** **Failure to follow these rules will expose user credentials, enable MITM attacks, and create false security assumptions.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.