Skip to content

Security: mukundjogi/reactnative-interview

Security

docs/security.md

Security in React Native

Table of Contents


Mobile Security Threat Model

React Native apps face unique security challenges because JavaScript bundles are readable and the bridge exposes native capabilities.

Threat Risk Mitigation
Reverse engineering Exposed API keys, business logic Obfuscation, server-side secrets
Man-in-the-middle Intercepted API traffic SSL pinning, certificate validation
Insecure storage Token theft from AsyncStorage Keychain / EncryptedSharedPreferences
Jailbreak/root Runtime manipulation, key extraction Detection + risk-based response
Deep link hijacking Unauthorized navigation Verified links, intent filters

Principle: Client-side security is defense in depth, not absolute protection. Never trust the client.


Secure Storage with react-native-keychain

AsyncStorage stores data in plain text — never use it for tokens, passwords, or PII.

react-native-keychain stores credentials in:

  • iOS: Keychain Services (hardware-backed when available)
  • Android: EncryptedSharedPreferences / Keystore

Installation and Basic Usage

npm install react-native-keychain
import * as Keychain from 'react-native-keychain';

const SERVICE_NAME = 'com.myapp.auth';

export async function saveTokens(
  accessToken: string,
  refreshToken: string,
): Promise<void> {
  await Keychain.setGenericPassword(
    'tokens',
    JSON.stringify({ accessToken, refreshToken }),
    {
      service: SERVICE_NAME,
      accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
      accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
    },
  );
}

export async function getTokens(): Promise<{
  accessToken: string;
  refreshToken: string;
} | null> {
  const credentials = await Keychain.getGenericPassword({
    service: SERVICE_NAME,
  });

  if (!credentials) return null;

  return JSON.parse(credentials.password) as {
    accessToken: string;
    refreshToken: string;
  };
}

export async function clearTokens(): Promise<void> {
  await Keychain.resetGenericPassword({ service: SERVICE_NAME });
}

Biometric-Protected Storage

import * as Keychain from 'react-native-keychain';

export async function saveWithBiometrics(secret: string): Promise<boolean> {
  return Keychain.setGenericPassword('user', secret, {
    accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
    accessible: Keychain.ACCESSIBLE.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
    authenticationPrompt: {
      title: 'Authenticate to access your account',
      cancel: 'Cancel',
    },
  });
}

export async function getBiometricProtectedSecret(): Promise<string | null> {
  try {
    const creds = await Keychain.getGenericPassword({
      authenticationPrompt: {
        title: 'Unlock your vault',
      },
    });
    return creds ? creds.password : null;
  } catch {
    return null;
  }
}

Storage Decision Matrix

Data Type Storage Solution
Access/refresh tokens Keychain
User preferences AsyncStorage / MMKV
Cached API responses MMKV / SQLite (encrypt sensitive)
Encryption keys Keychain / Android Keystore
Large offline datasets SQLite with SQLCipher

SSL Pinning

SSL pinning validates that the server's certificate matches a known trusted certificate or public key, preventing MITM attacks even with a compromised CA.

Using react-native-ssl-pinning

import { fetch as sslFetch } from 'react-native-ssl-pinning';

export async function secureApiCall<T>(endpoint: string): Promise<T> {
  const response = await sslFetch(`https://api.example.com${endpoint}`, {
    method: 'GET',
    sslPinning: {
      certs: ['api-example-com'], // cert files in android/app/src/main/assets/ and iOS bundle
    },
    headers: {
      Accept: 'application/json',
      Authorization: `Bearer ${await getAccessToken()}`,
    },
  });

  if (response.status !== 200) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json() as Promise<T>;
}

Certificate Pinning Setup

ios/
  api-example-com.cer       # Add to Xcode project bundle

android/app/src/main/assets/
  api-example-com.cer       # Referenced by ssl-pinning lib

Pinning Considerations

Pros Cons
Blocks MITM with rogue certificates Certificate rotation requires app update
Protects against compromised CAs Misconfiguration breaks all API calls
Required for some compliance (finance) Public key pinning is more rotation-friendly

Best practice: Pin the public key hash (SPKI) rather than full certificate when possible. Plan rotation with backup pins and OTA-capable pinning libraries.

Alternative: TrustKit (Native Module)

For advanced pinning with reporting and backup pins, integrate TrustKit on iOS and Android via native modules or community wrappers.


API Key Protection

Never embed secret API keys in JavaScript bundles — they can be extracted via decompilation.

What Can Be Client-Side

Safe (with limits) Never Client-Side
Firebase client config (with rules) Stripe secret key
Google Maps SDK key (restricted) AWS secret access key
Analytics write keys (rate-limited) Database credentials
OAuth client ID (public) OAuth client secret

Secure Architecture

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│  RN App     │────▶│  Your Backend │────▶│  Third-Party│
│  (public)   │     │  (secrets)    │     │  API        │
└─────────────┘     └──────────────┘     └─────────────┘
// BAD — secret in client
const STRIPE_SECRET = 'sk_live_xxxx'; // Extractable from bundle!

// GOOD — proxy through your backend
export async function createPaymentIntent(amount: number): Promise<string> {
  const response = await fetch('https://api.myapp.com/payments/intent', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${await getAccessToken()}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ amount }),
  });
  const { clientSecret } = await response.json();
  return clientSecret; // Safe to use client-side
}

Restricting Public Keys

For keys that must be client-side (Maps, Firebase):

<!-- Android: restrict by package name + SHA-1 in Google Cloud Console -->
<!-- iOS: restrict by bundle ID -->

Use environment-specific keys via build flavors — never production keys in debug builds committed to git.


Code Obfuscation

JavaScript bundles are readable by default. Obfuscation adds friction but is not foolproof.

Metro / JavaScript Obfuscation

// metro.config.js
const jsoMetroPlugin = require('obfuscator-io-metro-plugin')(
  {
    compact: true,
    controlFlowFlattening: true,
    deadCodeInjection: true,
    stringArray: true,
    stringArrayEncoding: ['base64'],
  },
  { runInDev: false, logObfuscatedFiles: false },
);

module.exports = {
  ...jsoMetroPlugin,
  // other metro config
};

Android ProGuard / R8

// android/app/build.gradle
buildTypes {
    release {
        minifyEnabled true
        shrinkResources true
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
}
# proguard-rules.pro — keep React Native and native module classes
-keep class com.facebook.react.** { *; }
-keep class com.facebook.hermes.** { *; }
-keep class com.myapp.security.** { *; }

iOS Considerations

  • Enable bitcode (where applicable) and strip symbols in release
  • Avoid storing secrets in Info.plist
  • Use ios-deploy symbol stripping for release archives

Obfuscation Reality Check

Obfuscation slows down attackers; it does not stop determined reverse engineers. Combine with:

  • Server-side validation of all sensitive operations
  • Short-lived tokens
  • Certificate pinning
  • Runtime integrity checks

Authentication Best Practices

import * as Keychain from 'react-native-keychain';

interface AuthState {
  isAuthenticated: boolean;
  accessToken: string | null;
}

const TOKEN_REFRESH_BUFFER_MS = 60_000; // Refresh 1 min before expiry

export async function getValidAccessToken(): Promise<string | null> {
  const tokens = await getTokens();
  if (!tokens) return null;

  const payload = parseJwtPayload(tokens.accessToken);
  const expiresAt = payload.exp * 1000;
  const now = Date.now();

  if (expiresAt - now > TOKEN_REFRESH_BUFFER_MS) {
    return tokens.accessToken;
  }

  // Refresh token flow
  const newTokens = await refreshTokens(tokens.refreshToken);
  if (!newTokens) {
    await clearTokens();
    return null;
  }

  await saveTokens(newTokens.accessToken, newTokens.refreshToken);
  return newTokens.accessToken;
}

function parseJwtPayload(token: string): { exp: number } {
  const base64 = token.split('.')[1];
  return JSON.parse(atob(base64)) as { exp: number };
}

Auth Security Rules

  1. Short-lived access tokens (15-60 min) with refresh token rotation
  2. Store tokens in Keychain, never AsyncStorage
  3. Clear tokens on logout and when refresh fails
  4. Certificate pinning on auth endpoints
  5. Biometric re-auth for sensitive actions (payments, settings)
  6. Prevent screenshots on sensitive screens (Android: FLAG_SECURE)
import { Platform } from 'react-native';

// Android FLAG_SECURE via native module or react-native-screenshot-prevent
export function enableScreenshotPrevention(): void {
  if (Platform.OS === 'android') {
    // NativeModule.setSecureFlag(true);
  }
}

Jailbreak and Root Detection

Jailbroken iOS and rooted Android devices allow:

  • Keychain extraction tools
  • SSL pinning bypass (Frida, Objection)
  • Runtime method swizzling
  • Access to other apps' sandbox data

Basic Detection with jail-monkey

npm install jail-monkey
import JailMonkey from 'jail-monkey';
import { Alert, BackHandler, Platform } from 'react-native';

export function performSecurityChecks(): boolean {
  const isCompromised =
    JailMonkey.isJailBroken() ||
    JailMonkey.canMockLocation() ||
    JailMonkey.isOnExternalStorage() ||
    JailMonkey.AdbEnabled();

  if (__DEV__) return true; // Skip in development

  if (isCompromised) {
    handleCompromisedDevice();
    return false;
  }

  return true;
}

function handleCompromisedDevice(): void {
  Alert.alert(
    'Security Notice',
    'This app cannot run on modified devices for your protection.',
    [{ text: 'Exit', onPress: () => BackHandler.exitApp() }],
    { cancelable: false },
  );
}

Risk-Based Response

Risk Level Response
Low sensitivity app Warn user, continue
Banking/fintech Block app entirely
Enterprise Block + report to MDM
Moderate Disable biometrics, require re-auth, limit features

Advanced Detection

  • Frida detection — Check for Frida server ports and loaded libraries
  • Debugger detectionptrace checks (native)
  • Integrity verification — Check app signature hasn't been modified
  • SafetyNet / Play Integrity API (Android) — Google's device attestation
  • DeviceCheck / App Attest (iOS) — Apple's device integrity APIs

Detection is a cat-and-mouse game — combine multiple signals and accept that determined attackers can bypass client checks. Server-side attestation is the strongest approach for high-security apps.


Security Checklist

## Production Security Checklist

### Storage
- [ ] Tokens in Keychain, not AsyncStorage
- [ ] No secrets in JS bundle or git
- [ ] Sensitive cache encrypted (MMKV encryption key in Keychain)

### Network
- [ ] HTTPS only (ATS on iOS, cleartext disabled on Android)
- [ ] SSL pinning on auth and payment endpoints
- [ ] Certificate rotation plan documented

### Authentication
- [ ] Short-lived JWT with refresh rotation
- [ ] Biometric gate for sensitive actions
- [ ] Secure logout clears all stored credentials

### Code
- [ ] ProGuard/R8 enabled for Android release
- [ ] JS obfuscation for release builds
- [ ] No sensitive data in logs (even in __DEV__ patterns leaking to prod)

### Device
- [ ] Jailbreak/root detection with appropriate response
- [ ] Screenshot prevention on sensitive screens
- [ ] Deep links validated before navigation

### Compliance
- [ ] Privacy policy and data handling documented
- [ ] GDPR/CCPA consent flows if applicable
- [ ] Penetration test before major releases

Interview Questions & Answers

Q1: Why should you never store authentication tokens in AsyncStorage?

Answer:

AsyncStorage on both platforms stores data in plain text:

  • iOS: Unencrypted files in app's sandbox (accessible on jailbroken devices)
  • Android: XML files in /data/data/<package>/ (accessible on rooted devices with backup enabled)

Any XSS-like vulnerability, backup extraction, or device compromise exposes tokens directly.

Use react-native-keychain instead, which leverages:

  • iOS Keychain (encrypted, hardware-backed on Secure Enclave devices)
  • Android Keystore + EncryptedSharedPreferences

Additional protections:

  • ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY — No iCloud backup of secrets
  • ACCESS_CONTROL.BIOMETRY_CURRENT_SET — Require Face ID / fingerprint
  • Short-lived access tokens to limit exposure window

Q2: Explain SSL pinning and when you would implement it.

Answer:

SSL pinning ensures the app only trusts specific server certificates or public keys, rather than any certificate signed by a trusted CA. This prevents MITM attacks using rogue CA certificates (common on corporate proxies or compromised CAs).

Implementation:

  1. Bundle server certificate (.cer) or pin SPKI hash in the app
  2. Configure networking library to validate server cert against pinned cert
  3. Reject connections that don't match

When to use:

  • Banking, healthcare, fintech apps
  • Apps handling PII or payment data
  • Compliance requirements (PCI-DSS considerations)
  • High-value API endpoints (auth, transactions)

Challenges:

  • Certificate expiry requires app update or dynamic pinning with backup pins
  • Misconfiguration causes total API failure
  • Public key pinning with multiple backup pins is recommended

Libraries: react-native-ssl-pinning, TrustKit, or custom native module with OkHttp CertificatePinner (Android) and NSURLSession delegate (iOS).


Q3: How do you protect API keys in a React Native app?

Answer:

Rule: Secrets never belong in the client.

  1. Proxy through backend — App calls your API; server holds third-party secrets (Stripe, AWS, etc.)
  2. Public keys only — OAuth client IDs, Firebase config, Maps SDK keys (with restrictions)
  3. Restrict keys — Google Cloud / AWS console: limit by bundle ID, package name, SHA-1, IP
  4. Environment flavors — Different keys for dev/staging/prod; prod keys never in debug builds
  5. Assume extraction — Any string in the JS bundle can be found via strings on the bundle file

For Firebase: security comes from Firestore/Storage rules, not hiding the config object.

For analytics: use write-only keys with rate limiting.

Never commit .env files with production secrets to git — use CI secrets injection.


Q4: What code obfuscation techniques apply to React Native?

Answer:

JavaScript layer:

  • Metro plugins (obfuscator-io-metro-plugin) — control flow flattening, string encryption, dead code injection
  • Hermes bytecode — not obfuscation but harder to read than plain JS
  • Remove source maps from production builds

Android native layer:

  • ProGuard/R8 — shrinks, optimizes, and obfuscates Java/Kotlin
  • Custom ProGuard rules to keep RN bridge classes

iOS native layer:

  • Strip debug symbols in release archive
  • Avoid storing logic in easily readable plist files

Limitations: Determined attackers decompile Hermes bytecode and bypass ProGuard. Obfuscation is one layer — never rely on it alone for security. All authorization must be server-validated.


Q5: Compare secure storage options in React Native.

Answer:

Solution Security Level Best For
AsyncStorage None (plain text) UI preferences, non-sensitive flags
MMKV Low (unencrypted default) Fast key-value, non-sensitive cache
MMKV encrypted Medium Cached data with encryption key in Keychain
react-native-keychain High Tokens, passwords, encryption keys
expo-secure-store High Expo projects, small secrets
SQLite + SQLCipher High Encrypted structured data at rest

Recommended pattern:

  • Keychain stores master encryption key
  • MMKV/SQLite stores encrypted bulk data
  • Never store refresh tokens without Keychain protection
  • Clear all secure storage on logout

Q6: How does jailbreak/root detection work and how should apps respond?

Answer:

Detection libraries (jail-monkey, custom native checks) look for:

iOS jailbreak indicators:

  • Presence of Cydia, Sileo, suspicious file paths (/Applications/Cydia.app)
  • Write access outside sandbox
  • Suspicious dylibs loaded

Android root indicators:

  • su binary presence
  • Magisk/SuperSU apps
  • Test-keys build tags
  • ro.debuggable system property

Response strategies (risk-based):

  • Warn and continue — Low-risk consumer apps
  • Disable sensitive features — No biometrics, no payments
  • Block app — Banking, enterprise
  • Server attestation — Play Integrity API / Apple App Attest for server-side decision

Never rely solely on client-side detection — it can be bypassed with Frida. For high-security apps, use hardware attestation and server-side validation.


Q7: What are the top security mistakes in React Native apps?

Answer:

  1. Tokens in AsyncStorage — Most common and critical mistake
  2. Secrets in JS bundle — API keys committed to git or embedded in code
  3. No certificate pinning on auth endpoints — Vulnerable to MITM
  4. Logging sensitive data — Tokens and PII in console.log reaching crash reports
  5. Disabled ATS/cleartext — Allowing HTTP traffic
  6. No certificate validation — Custom fetch ignoring SSL errors (never rejectUnauthorized: false)
  7. Deep link injection — Navigating to sensitive screens without auth check via crafted URLs
  8. Insecure WebViewjavaScriptEnabled with untrusted content, no URL filtering
  9. No root/jailbreak response — Treating compromised devices same as secure ones
  10. Client-side authorization — Hiding UI elements instead of server-enforcing permissions

Fix approach: Threat model each feature, apply defense in depth, and assume the client is hostile in security-critical flows.


Navigation

Previous: Publishing

Next: Advanced Topics

There aren't any published security advisories