Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Security Best Practices

Module Overview

Estimated Time: 4 hours | Difficulty: Advanced | Prerequisites: Authentication, Storage modules
Security in mobile apps is a different beast from web security. On the web, your code runs on a server you control. In a mobile app, your code runs on the user’s device — a device that might be jailbroken, connected to a malicious WiFi network, or actively being reverse-engineered. Every piece of data you store locally is potentially readable, every network request is potentially interceptable, and your JavaScript bundle can be extracted and decompiled. This is not a reason to panic — it is a reason to think defensively. This module covers comprehensive security practices for React Native apps, from secure storage to preventing reverse engineering. The principle is defense in depth: no single measure is bulletproof, but layers of protection raise the cost of attack until it is not worth the effort. What You’ll Learn:
  • Secure storage solutions
  • Certificate pinning
  • Code obfuscation
  • Jailbreak/root detection
  • Data encryption
  • Security auditing

Security Threat Model

┌─────────────────────────────────────────────────────────────────────────────┐
│                    Mobile App Security Threats                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   Data at Rest                          Data in Transit                      │
│   ─────────────                         ────────────────                     │
│   • Insecure storage                    • Man-in-the-middle attacks         │
│   • Unencrypted databases               • SSL stripping                     │
│   • Exposed credentials                 • Certificate spoofing              │
│   • Backup vulnerabilities              • Insecure protocols                │
│                                                                              │
│   Code & Binary                         Runtime                              │
│   ─────────────                         ───────                              │
│   • Reverse engineering                 • Jailbreak/root exploits           │
│   • Code injection                      • Memory tampering                  │
│   • Hardcoded secrets                   • Debugger attachment               │
│   • Unobfuscated code                   • Dynamic instrumentation           │
│                                                                              │
│   Authentication                        Input/Output                         │
│   ──────────────                        ────────────                         │
│   • Weak credentials                    • Injection attacks                 │
│   • Session hijacking                   • Deep link exploitation            │
│   • Token theft                         • Clipboard leakage                 │
│   • Biometric bypass                    • Screenshot capture                │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Secure Storage

The most common security mistake in React Native apps is storing sensitive data (auth tokens, API keys, user credentials) in AsyncStorage or plain files. AsyncStorage is backed by an unencrypted SQLite database on Android and an unencrypted plist on iOS — anyone with physical device access (or a jailbroken device) can read it trivially. Secure storage uses the platform’s hardware-backed security: iOS Keychain and Android Keystore. Data stored here is encrypted at the OS level, protected by the device lock, and inaccessible to other apps.

expo-secure-store

For sensitive data like tokens and credentials:
npx expo install expo-secure-store
// services/secureStorage.ts
import * as SecureStore from 'expo-secure-store';

const KEYS = {
  ACCESS_TOKEN: 'access_token',
  REFRESH_TOKEN: 'refresh_token',
  USER_CREDENTIALS: 'user_credentials',
  BIOMETRIC_KEY: 'biometric_key',
} as const;

export const secureStorage = {
  // Store sensitive data
  async setItem(key: string, value: string): Promise<void> {
    try {
      await SecureStore.setItemAsync(key, value, {
        keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
      });
    } catch (error) {
      console.error('SecureStore setItem error:', error);
      throw new Error('Failed to store secure data');
    }
  },

  // Retrieve sensitive data
  async getItem(key: string): Promise<string | null> {
    try {
      return await SecureStore.getItemAsync(key);
    } catch (error) {
      console.error('SecureStore getItem error:', error);
      return null;
    }
  },

  // Delete sensitive data
  async removeItem(key: string): Promise<void> {
    try {
      await SecureStore.deleteItemAsync(key);
    } catch (error) {
      console.error('SecureStore removeItem error:', error);
    }
  },

  // Token management
  async setTokens(accessToken: string, refreshToken: string): Promise<void> {
    await Promise.all([
      this.setItem(KEYS.ACCESS_TOKEN, accessToken),
      this.setItem(KEYS.REFRESH_TOKEN, refreshToken),
    ]);
  },

  async getTokens(): Promise<{ accessToken: string | null; refreshToken: string | null }> {
    const [accessToken, refreshToken] = await Promise.all([
      this.getItem(KEYS.ACCESS_TOKEN),
      this.getItem(KEYS.REFRESH_TOKEN),
    ]);
    return { accessToken, refreshToken };
  },

  async clearTokens(): Promise<void> {
    await Promise.all([
      this.removeItem(KEYS.ACCESS_TOKEN),
      this.removeItem(KEYS.REFRESH_TOKEN),
    ]);
  },

  // Clear all secure data
  async clearAll(): Promise<void> {
    await Promise.all(
      Object.values(KEYS).map(key => this.removeItem(key))
    );
  },
};

react-native-keychain (CLI)

For React Native CLI projects:
npm install react-native-keychain
cd ios && pod install
// services/keychain.ts
import * as Keychain from 'react-native-keychain';

export const keychainService = {
  // Store credentials with biometric protection
  async setCredentials(
    username: string,
    password: string,
    options?: { biometricProtection?: boolean }
  ): Promise<boolean> {
    try {
      const result = await Keychain.setGenericPassword(username, password, {
        service: 'com.myapp.credentials',
        accessControl: options?.biometricProtection
          ? Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE
          : undefined,
        accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
      });
      return !!result;
    } catch (error) {
      console.error('Keychain setCredentials error:', error);
      return false;
    }
  },

  // Retrieve credentials
  async getCredentials(): Promise<{ username: string; password: string } | null> {
    try {
      const credentials = await Keychain.getGenericPassword({
        service: 'com.myapp.credentials',
      });
      if (credentials) {
        return {
          username: credentials.username,
          password: credentials.password,
        };
      }
      return null;
    } catch (error) {
      console.error('Keychain getCredentials error:', error);
      return null;
    }
  },

  // Check biometric availability
  async getBiometryType(): Promise<Keychain.BIOMETRY_TYPE | null> {
    try {
      return await Keychain.getSupportedBiometryType();
    } catch (error) {
      return null;
    }
  },

  // Reset credentials
  async resetCredentials(): Promise<boolean> {
    try {
      return await Keychain.resetGenericPassword({
        service: 'com.myapp.credentials',
      });
    } catch (error) {
      console.error('Keychain resetCredentials error:', error);
      return false;
    }
  },
};

Encrypted Storage with MMKV

npm install react-native-mmkv
// services/encryptedStorage.ts
import { MMKV } from 'react-native-mmkv';

// Create encrypted storage instance
const storage = new MMKV({
  id: 'secure-storage',
  encryptionKey: 'your-encryption-key', // In production, derive from secure source
});

export const encryptedStorage = {
  set(key: string, value: string): void {
    storage.set(key, value);
  },

  get(key: string): string | undefined {
    return storage.getString(key);
  },

  setObject<T>(key: string, value: T): void {
    storage.set(key, JSON.stringify(value));
  },

  getObject<T>(key: string): T | null {
    const value = storage.getString(key);
    if (value) {
      try {
        return JSON.parse(value) as T;
      } catch {
        return null;
      }
    }
    return null;
  },

  delete(key: string): void {
    storage.delete(key);
  },

  clearAll(): void {
    storage.clearAll();
  },

  getAllKeys(): string[] {
    return storage.getAllKeys();
  },
};

Certificate Pinning

Standard HTTPS validates that a server’s certificate was issued by a trusted Certificate Authority (CA). But what if an attacker compromises a CA, or installs a rogue root certificate on the user’s device (common in corporate environments or jailbroken devices)? They could issue a valid-looking certificate for your API domain and intercept all traffic. Certificate pinning solves this by telling your app to trust only specific certificates or public keys for your domain, not the entire CA chain. Think of it as adding a secret handshake on top of the standard TLS process.
Certificate rotation pitfall: When your server certificate expires and you rotate it, pinned apps will immediately break because the new certificate does not match the pin. Always include a backup pin for your next certificate, and plan rotation carefully. Some teams pin the public key of an intermediate CA certificate rather than the leaf, giving more rotation flexibility.

Using react-native-ssl-pinning

npm install react-native-ssl-pinning
// services/api/pinnedClient.ts
import { fetch as pinnedFetch } from 'react-native-ssl-pinning';

const API_BASE_URL = 'https://api.example.com';

// Certificate pins (SHA-256 hashes)
const CERTIFICATE_PINS = {
  'api.example.com': [
    'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
    'sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', // Backup pin
  ],
};

export async function secureFetch(
  endpoint: string,
  options: RequestInit = {}
): Promise<Response> {
  const url = `${API_BASE_URL}${endpoint}`;
  
  try {
    const response = await pinnedFetch(url, {
      method: options.method || 'GET',
      headers: options.headers as Record<string, string>,
      body: options.body as string,
      sslPinning: {
        certs: ['cert1', 'cert2'], // Certificate names in assets
      },
      timeoutInterval: 30000,
    });

    return new Response(JSON.stringify(response.json()), {
      status: response.status,
      headers: response.headers,
    });
  } catch (error) {
    if (error.message?.includes('SSL')) {
      // Certificate pinning failed - possible MITM attack
      console.error('SSL Pinning failed - possible security threat');
      throw new Error('Security verification failed');
    }
    throw error;
  }
}

Using Axios with Certificate Pinning

// services/api/secureAxios.ts
import axios, { AxiosInstance } from 'axios';
import { Platform } from 'react-native';

// For Expo, use a custom native module or proxy
// For CLI, configure in native code

// iOS: Add to Info.plist
// <key>NSAppTransportSecurity</key>
// <dict>
//   <key>NSPinnedDomains</key>
//   <dict>
//     <key>api.example.com</key>
//     <dict>
//       <key>NSIncludesSubdomains</key>
//       <true/>
//       <key>NSPinnedLeafIdentities</key>
//       <array>
//         <dict>
//           <key>SPKI-SHA256-BASE64</key>
//           <string>YOUR_PIN_HERE</string>
//         </dict>
//       </array>
//     </dict>
//   </dict>
// </dict>

export function createSecureClient(): AxiosInstance {
  const client = axios.create({
    baseURL: 'https://api.example.com',
    timeout: 30000,
    headers: {
      'Content-Type': 'application/json',
    },
  });

  // Add request interceptor for additional security headers
  client.interceptors.request.use(config => {
    // Add timestamp to prevent replay attacks
    config.headers['X-Request-Timestamp'] = Date.now().toString();
    
    // Add request signature (implement your signing logic)
    // config.headers['X-Request-Signature'] = signRequest(config);
    
    return config;
  });

  return client;
}

Code Obfuscation

Your React Native app’s JavaScript bundle can be extracted from the APK/IPA file in minutes. Without obfuscation, an attacker can read your business logic, API endpoints, validation rules, and any hardcoded strings in plain text. Obfuscation does not make reverse engineering impossible — it makes it expensive and time-consuming enough to deter casual attackers.

JavaScript Obfuscation

// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');

const config = getDefaultConfig(__dirname);

// Enable minification in production
config.transformer.minifierConfig = {
  keep_classnames: false,
  keep_fnames: false,
  mangle: {
    toplevel: true,
  },
  output: {
    ascii_only: true,
    quote_style: 3,
    wrap_iife: true,
  },
  sourceMap: {
    includeSources: false,
  },
  toplevel: true,
  compress: {
    reduce_funcs: true,
    drop_console: true, // Remove console.log in production
    drop_debugger: true,
  },
};

module.exports = config;

Android ProGuard

# android/app/proguard-rules.pro

# React Native
-keep class com.facebook.react.** { *; }
-keep class com.facebook.hermes.** { *; }

# Keep native methods
-keepclassmembers class * {
    @com.facebook.react.uimanager.annotations.ReactProp <methods>;
}

# Obfuscate everything else
-repackageclasses ''
-allowaccessmodification
-optimizations !code/simplification/arithmetic

# Remove logging
-assumenosideeffects class android.util.Log {
    public static *** d(...);
    public static *** v(...);
    public static *** i(...);
}
// android/app/build.gradle
android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

Jailbreak/Root Detection

Jailbroken (iOS) and rooted (Android) devices have their security sandbox removed. On these devices, other apps can read your app’s data, inject code, or attach debuggers. Detection is not foolproof — sophisticated users can bypass it — but it raises the bar and lets you make informed decisions about what functionality to allow. The practical question is: should you block jailbroken devices entirely, or just restrict sensitive features? Most apps take the second approach: banking and payment features require a secure device, but browsing and reading do not.
npm install jail-monkey
// services/security/deviceSecurity.ts
import JailMonkey from 'jail-monkey';
import { Platform, Alert } from 'react-native';

interface SecurityCheckResult {
  isSecure: boolean;
  issues: string[];
}

export const deviceSecurity = {
  // Comprehensive security check
  async checkDeviceSecurity(): Promise<SecurityCheckResult> {
    const issues: string[] = [];

    // Check for jailbreak/root
    if (JailMonkey.isJailBroken()) {
      issues.push('Device is jailbroken/rooted');
    }

    // Check for debugging
    if (JailMonkey.isDebuggedMode()) {
      issues.push('App is being debugged');
    }

    // Check for mock location (Android)
    if (Platform.OS === 'android' && JailMonkey.hookDetected()) {
      issues.push('Hooking framework detected');
    }

    // Check if running on emulator
    if (await this.isEmulator()) {
      issues.push('Running on emulator');
    }

    return {
      isSecure: issues.length === 0,
      issues,
    };
  },

  // Check if running on emulator
  async isEmulator(): Promise<boolean> {
    // JailMonkey doesn't have direct emulator detection
    // Implement custom check based on device properties
    if (Platform.OS === 'android') {
      // Check for common emulator indicators
      const DeviceInfo = require('react-native-device-info');
      const isEmulator = await DeviceInfo.isEmulator();
      return isEmulator;
    }
    return false;
  },

  // Handle security violations
  handleSecurityViolation(issues: string[]): void {
    // Log security event (to your analytics/security service)
    console.warn('Security violation detected:', issues);

    // Show warning to user
    Alert.alert(
      'Security Warning',
      'This device may be compromised. Some features may be restricted.',
      [
        {
          text: 'I Understand',
          style: 'default',
        },
      ]
    );
  },

  // Enforce security policy
  async enforceSecurityPolicy(
    options: { allowJailbroken?: boolean; allowDebug?: boolean } = {}
  ): Promise<boolean> {
    const { isSecure, issues } = await this.checkDeviceSecurity();

    if (!isSecure) {
      // Filter issues based on policy
      const criticalIssues = issues.filter(issue => {
        if (issue.includes('jailbroken') && options.allowJailbroken) return false;
        if (issue.includes('debugged') && options.allowDebug) return false;
        return true;
      });

      if (criticalIssues.length > 0) {
        this.handleSecurityViolation(criticalIssues);
        return false;
      }
    }

    return true;
  },
};

Security Provider Component

// providers/SecurityProvider.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { deviceSecurity } from '@/services/security/deviceSecurity';

interface SecurityContextType {
  isSecure: boolean;
  isChecking: boolean;
  securityIssues: string[];
  recheckSecurity: () => Promise<void>;
}

const SecurityContext = createContext<SecurityContextType | undefined>(undefined);

export function SecurityProvider({ children }: { children: ReactNode }) {
  const [isSecure, setIsSecure] = useState(true);
  const [isChecking, setIsChecking] = useState(true);
  const [securityIssues, setSecurityIssues] = useState<string[]>([]);

  const checkSecurity = async () => {
    setIsChecking(true);
    try {
      const result = await deviceSecurity.checkDeviceSecurity();
      setIsSecure(result.isSecure);
      setSecurityIssues(result.issues);

      if (!result.isSecure) {
        deviceSecurity.handleSecurityViolation(result.issues);
      }
    } catch (error) {
      console.error('Security check failed:', error);
    } finally {
      setIsChecking(false);
    }
  };

  useEffect(() => {
    checkSecurity();
  }, []);

  return (
    <SecurityContext.Provider
      value={{
        isSecure,
        isChecking,
        securityIssues,
        recheckSecurity: checkSecurity,
      }}
    >
      {children}
    </SecurityContext.Provider>
  );
}

export function useSecurity() {
  const context = useContext(SecurityContext);
  if (!context) {
    throw new Error('useSecurity must be used within SecurityProvider');
  }
  return context;
}

Data Encryption

Sometimes you need to store structured data (not just simple strings) securely. Secure Store and Keychain have size limits and are designed for small values like tokens. For larger data — like cached user profiles, draft messages, or offline records that contain PII — you need application-level encryption on top of regular storage. The pattern: generate an encryption key on first launch, store it in Secure Store (hardware-protected), and use it to encrypt/decrypt data before writing to AsyncStorage or MMKV.

Encrypting Sensitive Data

// services/security/encryption.ts
import CryptoJS from 'crypto-js';
import * as SecureStore from 'expo-secure-store';

const ENCRYPTION_KEY_ID = 'app_encryption_key';

export const encryption = {
  // Generate or retrieve encryption key
  async getEncryptionKey(): Promise<string> {
    let key = await SecureStore.getItemAsync(ENCRYPTION_KEY_ID);
    
    if (!key) {
      // Generate new key
      key = CryptoJS.lib.WordArray.random(256 / 8).toString();
      await SecureStore.setItemAsync(ENCRYPTION_KEY_ID, key);
    }
    
    return key;
  },

  // Encrypt data
  async encrypt(data: string): Promise<string> {
    const key = await this.getEncryptionKey();
    const encrypted = CryptoJS.AES.encrypt(data, key).toString();
    return encrypted;
  },

  // Decrypt data
  async decrypt(encryptedData: string): Promise<string> {
    const key = await this.getEncryptionKey();
    const decrypted = CryptoJS.AES.decrypt(encryptedData, key);
    return decrypted.toString(CryptoJS.enc.Utf8);
  },

  // Encrypt object
  async encryptObject<T>(data: T): Promise<string> {
    const jsonString = JSON.stringify(data);
    return this.encrypt(jsonString);
  },

  // Decrypt object
  async decryptObject<T>(encryptedData: string): Promise<T | null> {
    try {
      const jsonString = await this.decrypt(encryptedData);
      return JSON.parse(jsonString) as T;
    } catch {
      return null;
    }
  },

  // Hash sensitive data (one-way)
  hash(data: string): string {
    return CryptoJS.SHA256(data).toString();
  },

  // Generate secure random string
  generateSecureRandom(length: number = 32): string {
    return CryptoJS.lib.WordArray.random(length).toString();
  },
};

Biometric Authentication

Biometric auth (Face ID, Touch ID, fingerprint) does not replace password authentication — it provides a faster way to re-authenticate returning users. The typical flow: user logs in with email/password on first use, tokens are stored in Secure Store protected by biometric access control, and subsequent launches prompt for biometric verification to unlock the stored tokens.
// services/security/biometrics.ts
import * as LocalAuthentication from 'expo-local-authentication';
import { Platform } from 'react-native';

export const biometrics = {
  // Check if biometrics are available
  async isAvailable(): Promise<boolean> {
    const compatible = await LocalAuthentication.hasHardwareAsync();
    const enrolled = await LocalAuthentication.isEnrolledAsync();
    return compatible && enrolled;
  },

  // Get available biometric types
  async getAvailableTypes(): Promise<LocalAuthentication.AuthenticationType[]> {
    return LocalAuthentication.supportedAuthenticationTypesAsync();
  },

  // Authenticate with biometrics
  async authenticate(
    options: {
      promptMessage?: string;
      cancelLabel?: string;
      fallbackLabel?: string;
      disableDeviceFallback?: boolean;
    } = {}
  ): Promise<{ success: boolean; error?: string }> {
    try {
      const result = await LocalAuthentication.authenticateAsync({
        promptMessage: options.promptMessage || 'Authenticate to continue',
        cancelLabel: options.cancelLabel || 'Cancel',
        fallbackLabel: options.fallbackLabel || 'Use Passcode',
        disableDeviceFallback: options.disableDeviceFallback || false,
      });

      if (result.success) {
        return { success: true };
      }

      return {
        success: false,
        error: result.error || 'Authentication failed',
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error',
      };
    }
  },

  // Get biometric type name
  getBiometricTypeName(type: LocalAuthentication.AuthenticationType): string {
    switch (type) {
      case LocalAuthentication.AuthenticationType.FINGERPRINT:
        return Platform.OS === 'ios' ? 'Touch ID' : 'Fingerprint';
      case LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION:
        return Platform.OS === 'ios' ? 'Face ID' : 'Face Recognition';
      case LocalAuthentication.AuthenticationType.IRIS:
        return 'Iris';
      default:
        return 'Biometric';
    }
  },
};

Biometric Login Component

// components/BiometricLogin.tsx
import { View, Text, Pressable, StyleSheet, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useEffect, useState } from 'react';
import { biometrics } from '@/services/security/biometrics';
import * as LocalAuthentication from 'expo-local-authentication';

interface BiometricLoginProps {
  onSuccess: () => void;
  onFallback: () => void;
}

export function BiometricLogin({ onSuccess, onFallback }: BiometricLoginProps) {
  const [isAvailable, setIsAvailable] = useState(false);
  const [biometricType, setBiometricType] = useState<string>('Biometric');

  useEffect(() => {
    checkBiometrics();
  }, []);

  const checkBiometrics = async () => {
    const available = await biometrics.isAvailable();
    setIsAvailable(available);

    if (available) {
      const types = await biometrics.getAvailableTypes();
      if (types.length > 0) {
        setBiometricType(biometrics.getBiometricTypeName(types[0]));
      }
    }
  };

  const handleBiometricAuth = async () => {
    const result = await biometrics.authenticate({
      promptMessage: `Login with ${biometricType}`,
    });

    if (result.success) {
      onSuccess();
    } else {
      Alert.alert('Authentication Failed', result.error || 'Please try again');
    }
  };

  if (!isAvailable) {
    return null;
  }

  return (
    <View style={styles.container}>
      <Pressable
        style={({ pressed }) => [
          styles.button,
          pressed && styles.buttonPressed,
        ]}
        onPress={handleBiometricAuth}
      >
        <Ionicons
          name={biometricType.includes('Face') ? 'scan' : 'finger-print'}
          size={32}
          color="#3b82f6"
        />
        <Text style={styles.buttonText}>Login with {biometricType}</Text>
      </Pressable>

      <Pressable onPress={onFallback}>
        <Text style={styles.fallbackText}>Use password instead</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    alignItems: 'center',
    gap: 16,
  },
  button: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
    backgroundColor: '#eff6ff',
    paddingVertical: 16,
    paddingHorizontal: 24,
    borderRadius: 12,
    borderWidth: 1,
    borderColor: '#bfdbfe',
  },
  buttonPressed: {
    backgroundColor: '#dbeafe',
  },
  buttonText: {
    fontSize: 16,
    fontWeight: '600',
    color: '#3b82f6',
  },
  fallbackText: {
    fontSize: 14,
    color: '#6b7280',
    textDecorationLine: 'underline',
  },
});

Preventing Screenshot/Screen Recording

For financial, healthcare, or enterprise apps, preventing screenshots and screen recordings protects sensitive data from being casually shared. Android supports programmatic prevention via the FLAG_SECURE window flag. iOS does not support prevention (Apple intentionally prevents this), but you can detect screenshots and respond (show a warning, log a security event, or blur sensitive content).
// hooks/useScreenSecurity.ts
import { useEffect } from 'react';
import { Platform } from 'react-native';

// For Expo, use expo-screen-capture
import * as ScreenCapture from 'expo-screen-capture';

export function useScreenSecurity(preventCapture: boolean = true) {
  useEffect(() => {
    if (!preventCapture) return;

    const enableProtection = async () => {
      if (Platform.OS === 'android') {
        await ScreenCapture.preventScreenCaptureAsync();
      }
      // iOS doesn't support preventing screenshots programmatically
      // but you can detect them
    };

    const disableProtection = async () => {
      if (Platform.OS === 'android') {
        await ScreenCapture.allowScreenCaptureAsync();
      }
    };

    enableProtection();

    return () => {
      disableProtection();
    };
  }, [preventCapture]);

  // Listen for screenshot events (iOS)
  useEffect(() => {
    const subscription = ScreenCapture.addScreenshotListener(() => {
      console.warn('Screenshot detected!');
      // Log security event, show warning, etc.
    });

    return () => subscription.remove();
  }, []);
}

// Usage in sensitive screens
function SensitiveDataScreen() {
  useScreenSecurity(true);

  return (
    <View>
      <Text>Sensitive information here</Text>
    </View>
  );
}

Choosing the Right Storage for Sensitive Data

The most common security mistake is using the wrong storage mechanism for the sensitivity level of the data. Here is a decision matrix:
Data TypeExamplesRecommended StorageWhy
Auth tokensAccess tokens, refresh tokens, session IDsexpo-secure-store / react-native-keychainHardware-backed encryption, protected by device lock
User credentialsPasswords, PINs (if stored at all)react-native-keychain with biometric access controlKeychain is the only appropriate location; prefer not storing passwords at all
Encryption keysApp-level encryption keys, API signing keysexpo-secure-store (generated on device, never transmitted)Secure Store/Keychain is tamper-resistant; keys never leave the secure enclave
Cached PIIUser profiles, health data, financial recordsMMKV with encryption (key stored in Secure Store)Too large for Secure Store (2KB limit on iOS); encryption-at-rest via derived key
Non-sensitive preferencesTheme, language, onboarding completionAsyncStorage or MMKV (unencrypted)No security risk; fast read/write for frequently accessed settings
Temporary dataForm drafts, search history, undo stackIn-memory (React state or Zustand)Automatically cleared on app close; no persistence needed
expo-secure-store has a 2KB value limit on iOS (backed by iOS Keychain). If you need to store larger encrypted payloads (e.g., a cached user profile with 50+ fields), use MMKV with an encryption key that lives in Secure Store. Do not try to split large values across multiple Secure Store keys — the API is not designed for that pattern and you will hit race conditions.

Certificate Pinning Decision Framework

Certificate pinning adds meaningful security but introduces operational complexity. Not every app needs it.
App CategoryPinning RecommendationRationale
Banking / financialRequired — pin to leaf or intermediate CA public keyRegulatory compliance; high-value target for MITM attacks
Healthcare / HIPAARequired — pin to intermediate CA public keyProtected health information; compliance mandates
Enterprise / MDM-managedPin to intermediate CA — NOT leafCorporate environments often install custom root CAs; leaf pinning breaks MDM inspection
E-commerceRecommended — pin to intermediate CA public keyPayment data in transit; balance security with rotation flexibility
Social / contentOptional — standard TLS is usually sufficientLower-value target; pinning adds rotation burden without proportional security gain
Internal / prototypeSkip — not worth the operational overheadFocus development effort elsewhere; standard HTTPS is adequate
Leaf vs. intermediate vs. root pinning:
Pin TargetRotation FrequencySecurity LevelOperational Risk
Leaf certificateEvery 90 days to 1 yearHighest — only your exact cert is trustedHighest — every cert rotation requires an app update
Intermediate CAEvery 3-5 yearsHigh — trusts certs from one specific CAMedium — you can rotate leaf certs freely
Root CAEvery 10-20 yearsModerate — trusts all certs from that CA chainLow — rarely needs updating
My recommendation: Pin the intermediate CA’s public key. This gives you strong MITM protection while allowing leaf certificate rotation without app updates. Always include a backup pin for the next intermediate you plan to use.

Edge Cases in Mobile Security

Token Refresh Race Condition

When multiple API calls fail simultaneously with a 401 (expired token), each one independently triggers a token refresh. This can cause multiple refresh requests, and if the server invalidates the refresh token on first use (which it should for security), all but the first refresh call fail — cascading into a forced logout.
// Solution: queue refresh requests behind a single promise
let refreshPromise: Promise<string> | null = null;

async function getValidToken(): Promise<string> {
  const token = await SecureStore.getItemAsync('accessToken');
  
  if (isTokenExpired(token)) {
    // If a refresh is already in progress, wait for it
    if (refreshPromise) {
      return refreshPromise;
    }
    
    // Start a single refresh and share the promise
    refreshPromise = refreshAccessToken().finally(() => {
      refreshPromise = null; // Clear after completion
    });
    
    return refreshPromise;
  }
  
  return token!;
}

Clipboard Data Leakage

When users copy sensitive data (account numbers, one-time codes), it sits on the system clipboard accessible to any app. On Android, clipboard managers can even persist clipboard history across app switches.
// Clear clipboard after a timeout when user copies sensitive data
import * as Clipboard from 'expo-clipboard';

async function copyWithAutoExpiry(text: string, expiryMs: number = 30000) {
  await Clipboard.setStringAsync(text);
  
  // Clear after timeout
  setTimeout(async () => {
    const current = await Clipboard.getStringAsync();
    // Only clear if user hasn't copied something else
    if (current === text) {
      await Clipboard.setStringAsync('');
    }
  }, expiryMs);
}
Deep links (myapp://reset-password?token=xyz) can be crafted by malicious apps or websites to inject parameters into your navigation. If your app blindly trusts deep link parameters, an attacker can bypass auth flows or trigger unintended actions.
// Always validate deep link parameters before acting on them
function handleDeepLink(url: string) {
  const parsed = Linking.parse(url);
  
  // Validate: is this a known route?
  const allowedPaths = ['/reset-password', '/invite', '/project'];
  if (!allowedPaths.includes(parsed.path || '')) {
    console.warn('Blocked unknown deep link path:', parsed.path);
    return;
  }
  
  // Validate: do parameters look legitimate?
  if (parsed.path === '/reset-password') {
    const token = parsed.queryParams?.token;
    // Verify token format before navigating
    if (!token || typeof token !== 'string' || token.length < 20) {
      console.warn('Invalid reset token in deep link');
      return;
    }
    // Verify token server-side before showing reset form
    verifyResetToken(token).then((valid) => {
      if (valid) router.push(`/reset-password?token=${token}`);
    });
  }
}

Background App Snapshot Exposure

Both iOS and Android capture a screenshot of your app when it moves to the background (for the app switcher). If your app displays sensitive data (bank balances, health records, personal messages), this screenshot is visible to anyone who opens the task switcher.
import { AppState, View, StyleSheet } from 'react-native';
import { useEffect, useState } from 'react';

// Overlay a blur/blank screen when app backgrounds
export function useBackgroundProtection() {
  const [isBackground, setIsBackground] = useState(false);

  useEffect(() => {
    const subscription = AppState.addEventListener('change', (state) => {
      setIsBackground(state !== 'active');
    });
    return () => subscription.remove();
  }, []);

  return isBackground;
}

// Usage in sensitive screens
function BankAccountScreen() {
  const isBackground = useBackgroundProtection();
  
  return (
    <View style={{ flex: 1 }}>
      {isBackground && (
        <View style={StyleSheet.absoluteFill}>
          <View style={{ flex: 1, backgroundColor: '#fff' }} />
        </View>
      )}
      {/* Screen content */}
    </View>
  );
}

Security Checklist

Storage Security

  • Use SecureStore for tokens
  • Encrypt sensitive data
  • Clear data on logout
  • No hardcoded secrets

Network Security

  • Use HTTPS only
  • Implement certificate pinning
  • Validate SSL certificates
  • Add request signing

Code Security

  • Enable ProGuard/R8
  • Remove debug logs
  • Obfuscate JavaScript
  • No sensitive data in logs

Runtime Security

  • Detect jailbreak/root
  • Prevent debugging
  • Screen capture protection
  • Clipboard security

Security Audit Script

// scripts/security-audit.ts
import { execSync } from 'child_process';

interface AuditResult {
  category: string;
  check: string;
  status: 'pass' | 'fail' | 'warning';
  message: string;
}

const results: AuditResult[] = [];

// Check for vulnerable dependencies
function checkDependencies() {
  try {
    execSync('npm audit --json', { encoding: 'utf-8' });
    results.push({
      category: 'Dependencies',
      check: 'Vulnerability scan',
      status: 'pass',
      message: 'No vulnerabilities found',
    });
  } catch (error) {
    results.push({
      category: 'Dependencies',
      check: 'Vulnerability scan',
      status: 'fail',
      message: 'Vulnerabilities detected - run npm audit',
    });
  }
}

// Check for hardcoded secrets
function checkHardcodedSecrets() {
  const patterns = [
    { regex: /api[_-]?key\s*[:=]\s*['"][A-Za-z0-9_\-]{16,}['"]/gi, label: 'API key' },
    { regex: /secret\s*[:=]\s*['"][A-Za-z0-9_\-]{16,}['"]/gi, label: 'Secret' },
    { regex: /password\s*[:=]\s*['"][^'"]{8,}['"]/gi, label: 'Password' },
    { regex: /sk_live_[A-Za-z0-9]{24,}/gi, label: 'Stripe secret key' },
    { regex: /-----BEGIN (RSA |EC )?PRIVATE KEY-----/gi, label: 'Private key' },
    { regex: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./g, label: 'JWT token' },
  ];

  const { readdirSync, readFileSync } = require('fs');
  const { join } = require('path');

  function scanDir(dir: string) {
    const entries = readdirSync(dir, { withFileTypes: true });
    for (const entry of entries) {
      const fullPath = join(dir, entry.name);
      if (entry.isDirectory()) {
        if (['node_modules', '.git', 'build', 'dist'].includes(entry.name)) continue;
        scanDir(fullPath);
      } else if (/\.(ts|tsx|js|jsx|json)$/.test(entry.name)) {
        const content = readFileSync(fullPath, 'utf-8');
        for (const pattern of patterns) {
          const matches = content.match(pattern.regex);
          if (matches) {
            results.push({
              category: 'Hardcoded Secrets',
              check: `${pattern.label} in ${fullPath}`,
              status: 'fail',
              message: `Found ${matches.length} potential ${pattern.label}(s)`,
            });
          }
        }
      }
    }
  }

  scanDir('src');

  if (!results.some(r => r.category === 'Hardcoded Secrets')) {
    results.push({
      category: 'Hardcoded Secrets',
      check: 'Source code scan',
      status: 'pass',
      message: 'No hardcoded secrets detected',
    });
  }
}

// Check for insecure storage usage
function checkInsecureStorage() {
  const { execSync } = require('child_process');
  try {
    // Look for AsyncStorage being used for tokens or passwords
    const output = execSync(
      'grep -r "AsyncStorage.*token\\|AsyncStorage.*password\\|AsyncStorage.*secret" src/ --include="*.ts" --include="*.tsx" -l',
      { encoding: 'utf-8' }
    );
    if (output.trim()) {
      results.push({
        category: 'Storage',
        check: 'Insecure token storage',
        status: 'fail',
        message: `Tokens/passwords stored in AsyncStorage (unencrypted): ${output.trim()}`,
      });
    }
  } catch {
    results.push({
      category: 'Storage',
      check: 'Insecure token storage',
      status: 'pass',
      message: 'No tokens found in AsyncStorage',
    });
  }
}

// Check that console.log is stripped in production config
function checkConsoleStripping() {
  try {
    const metroConfig = require('fs').readFileSync('metro.config.js', 'utf-8');
    if (metroConfig.includes('drop_console')) {
      results.push({
        category: 'Code Security',
        check: 'Console stripping',
        status: 'pass',
        message: 'console.log is stripped in production builds',
      });
    } else {
      results.push({
        category: 'Code Security',
        check: 'Console stripping',
        status: 'warning',
        message: 'console.log may leak sensitive data in production -- add drop_console to metro.config.js',
      });
    }
  } catch {
    results.push({
      category: 'Code Security',
      check: 'Console stripping',
      status: 'warning',
      message: 'Could not read metro.config.js',
    });
  }
}

// Run all checks
checkDependencies();
checkHardcodedSecrets();
checkInsecureStorage();
checkConsoleStripping();

// Output results
console.log('\n🔒 Security Audit Results\n');
results.forEach(result => {
  const icon = result.status === 'pass' ? '✅' : result.status === 'fail' ? '❌' : '⚠️';
  console.log(`${icon} [${result.category}] ${result.check}: ${result.message}`);
});

Next Steps

Module 32: Offline-First Architecture

Learn to build apps that work seamlessly without internet connectivity