Steven's Knowledge

Security

React Native security — secure storage, SSL pinning, code protection, authentication, data protection

Security

Mobile apps face a fundamentally different threat model than web applications. The binary ships to the user's device, runs in an environment you do not control, and communicates over networks you cannot trust. This guide covers the practical security measures that matter for React Native apps in production.

Threat Model for Mobile Apps

What Is Different From Web

On the web, your code runs on your server. On mobile, your code runs on the attacker's hardware. This changes everything:

ConcernWebMobile
Code visibilityServer-side code is hiddenAPK/IPA can be decompiled and inspected
StorageHttpOnly cookies, server sessionsOn-device storage accessible with root/jailbreak
NetworkTLS terminates at your serverUser can install proxy CAs and intercept traffic
RuntimeSandboxed browserDebugger attachment, Frida injection, runtime hooking
DistributionAlways latest versionUsers run old versions with known vulnerabilities

Threat Matrix

Attack vectorRiskMitigation
Network interception (MITM)Token theft, data exfiltrationCertificate pinning, TLS 1.3
Reverse engineeringAPI key extraction, business logic theftHermes bytecode, ProGuard, server-side secrets
Local data theft (rooted device)Credential theft, PII exposureSecure storage (Keychain/Keystore), encryption
Session hijackingAccount takeoverShort-lived tokens, refresh rotation, device binding
Deep link hijackingAuth code interception (OAuth)PKCE, link validation, verified app links
Debug/instrumentationRuntime manipulation, bypass checksDebug detection, anti-tampering, RASP

Security is defense in depth. No single measure is sufficient. Certificate pinning without secure storage is pointless — an attacker who can read your Keychain does not need to intercept network traffic. Layer your defenses and assume each layer can be bypassed individually.

Secure Storage

Uses iOS Keychain Services and Android Keystore — hardware-backed credential storage on both platforms.

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

// Store credentials
async function storeToken(token: string): Promise<void> {
  await Keychain.setGenericPassword('auth', token, {
    accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE,
  });
}

// Retrieve credentials
async function getToken(): Promise<string | null> {
  const credentials = await Keychain.getGenericPassword();
  if (credentials) {
    return credentials.password;
  }
  return null;
}

// Clear on logout
async function clearToken(): Promise<void> {
  await Keychain.resetGenericPassword();
}

MMKV is NOT secure storage. react-native-mmkv is fast key-value storage, but it stores data in plaintext files. On a rooted/jailbroken device, these files are readable by any process. Even MMKV's encryption option uses a key you must store somewhere — if you store that key in MMKV, you have accomplished nothing. Use MMKV for preferences and cache; use Keychain/Keystore for credentials and tokens.

expo-secure-store (Expo Projects)

import * as SecureStore from 'expo-secure-store';

await SecureStore.setItemAsync('refreshToken', token, {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});

const stored = await SecureStore.getItemAsync('refreshToken');

Biometric-Gated Access

Require biometric authentication before releasing sensitive credentials.

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

const biometrics = new ReactNativeBiometrics();

async function storeWithBiometric(key: string, value: string): Promise<void> {
  await Keychain.setGenericPassword(key, value, {
    accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
    authenticationType: Keychain.AUTHENTICATION_TYPE.BIOMETRICS,
  });
}

async function getWithBiometric(): Promise<string | null> {
  const { available } = await biometrics.isSensorAvailable();
  if (!available) {
    // Fall back to PIN/passcode prompt
    return null;
  }

  try {
    const credentials = await Keychain.getGenericPassword({
      authenticationPrompt: {
        title: 'Authenticate to access your account',
      },
    });
    return credentials ? credentials.password : null;
  } catch {
    // User cancelled or biometric failed
    return null;
  }
}

When to Use Secure Storage vs Regular Storage

Data typeStorageReason
Auth tokens (access, refresh)Keychain / KeystoreCredential — must be hardware-protected
API keys used client-sideDo not store on deviceProxy through your backend
User preferences (theme, locale)MMKV / AsyncStorageNot sensitive
Cached API responsesMMKV / AsyncStorageNot sensitive; rebuild from server
Encryption keysKeychain / KeystoreKey material — hardware protection required
PII (SSN, card numbers)Do not store on deviceMinimize what you hold; tokenize server-side

Network Security

Certificate Pinning

Certificate pinning ensures your app only communicates with servers presenting a known certificate or public key. This defeats MITM attacks even when the user (or a corporate proxy) has installed a custom CA.

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

async function secureFetch(url: string, accessToken: string) {
  const response = await fetch(url, {
    method: 'GET',
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
    sslPinning: {
      certs: ['server-cert'], // .cer file in app bundle
    },
    timeoutInterval: 10000,
  });

  return response.json();
}

Pin the public key, not the certificate. Certificates rotate; public keys can persist across rotations. If you pin certificates, you must ship an app update before the old cert expires. Public key pinning (sha256/base64hash) survives certificate renewal as long as the same key pair is reused.

iOS App Transport Security

Configure ATS in Info.plist. The default policy enforces TLS 1.2+, which is correct. Only add exceptions for domains you truly cannot upgrade.

<!-- ios/YourApp/Info.plist -->
<key>NSAppTransportSecurity</key>
<dict>
  <!-- Do NOT set NSAllowsArbitraryLoads to true in production -->
  <key>NSExceptionDomains</key>
  <dict>
    <key>legacy-api.example.com</key>
    <dict>
      <key>NSExceptionMinimumTLSVersion</key>
      <string>TLSv1.2</string>
      <key>NSExceptionRequiresForwardSecrecy</key>
      <true/>
    </dict>
  </dict>
</dict>

Android Network Security Config

<!-- 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.yourapp.com</domain>
    <pin-set expiration="2027-01-01">
      <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
      <!-- Always include a backup pin -->
      <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
    </pin-set>
  </domain-config>
</network-security-config>

Reference it in AndroidManifest.xml:

<application
  android:networkSecurityConfig="@xml/network_security_config"
  ...>

Preventing API Key Exposure

Client app  ──→  Your backend proxy  ──→  Third-party API
                  (holds the real key)

Never embed third-party API keys in your JS bundle. Hermes bytecode is trivially extractable (see Code Protection below). Instead, proxy requests through your own backend, which holds the real credentials.

// Bad: API key in the JS bundle — extractable in minutes
const response = await fetch('https://maps.googleapis.com/maps/api/...', {
  headers: { 'X-API-Key': 'AIzaSy...' }, // shipped to every device
});

// Good: proxy through your backend
const response = await fetch('https://api.yourapp.com/maps/geocode', {
  headers: { Authorization: `Bearer ${accessToken}` },
});
// Your backend adds the Google API key server-side

Hermes bytecode is not a security measure. While Hermes compiles JS to bytecode, tools like hermes-dec and hbctool can decompile it back to readable JavaScript in seconds. Treat every string literal in your JS bundle as public. This includes API keys, feature flag names, hardcoded URLs, and error messages.

Code Protection

What Hermes Bytecode Actually Protects

Hermes compiles JavaScript to bytecode (.hbc files). This provides:

  • Faster startup (no parse step)
  • Smaller bundle size
  • Marginal increase in reverse-engineering effort

It does not provide:

  • Meaningful obfuscation (decompilers exist)
  • String encryption (all string literals are in a string table, trivially extractable)
  • Control flow protection

ProGuard / R8 for Android

Configure in android/app/build.gradle:

android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
                          'proguard-rules.pro'
        }
    }
}

ProGuard/R8 obfuscates your Java/Kotlin native module code. It does nothing for your JavaScript layer.

String Obfuscation

For strings that must exist in the bundle (endpoint paths, feature flags), obfuscate them so they do not appear in plaintext scans:

// core/security/obfuscate.ts

// Simple XOR obfuscation — not cryptographic, but defeats grep
function deobfuscate(encoded: number[], key: number): string {
  return String.fromCharCode(...encoded.map((c) => c ^ key));
}

// Pre-compute at build time, store as number arrays
// "api/v2/payments" → XOR with key 42
const PAYMENTS_ENDPOINT = deobfuscate(
  [75, 90, 73, 89, 88, 84, 89, 90, 75, 91, 69, 78, 68, 82, 84],
  42,
);

String obfuscation raises the bar, not the ceiling. A motivated attacker will set a breakpoint and read the deobfuscated value at runtime. The goal is to defeat automated scanning tools and casual inspection — it will not stop a dedicated reverse engineer.

What an Attacker Can Extract From Your APK/IPA

AssetDifficultyMitigation
String literals in JS bundleTrivialObfuscation, server-side secrets
API endpoint URLsTrivialAccept this; secure the endpoints
Hardcoded API keysTrivialNever embed; proxy through backend
Business logic flowModerateObfuscation, server-side validation
Native library symbolsModerateProGuard/R8, strip symbols
Encryption keys in codeTrivialUse Keychain/Keystore, derive at runtime

Root/Jailbreak Detection

Basic Detection with jail-monkey

import JailMonkey from 'jail-monkey';

interface DeviceSecurityStatus {
  isRooted: boolean;
  canMockLocation: boolean;
  isDebugMode: boolean;
  isOnExternalStorage: boolean;
}

function checkDeviceSecurity(): DeviceSecurityStatus {
  return {
    isRooted: JailMonkey.isJailBroken(),
    canMockLocation: JailMonkey.canMockLocation(),
    isDebugMode: JailMonkey.isDebuggedMode(),
    isOnExternalStorage: JailMonkey.isOnExternalStorage(),
  };
}

freeRASP for Comprehensive Protection

freeRASP by Talsec provides runtime application self-protection with broader detection coverage.

import { useFreeRasp, type TalsecConfig } from 'freerasp-react-native';

const talsecConfig: TalsecConfig = {
  androidConfig: {
    packageName: 'com.yourapp',
    certificateHashes: ['your_signing_cert_hash'],
  },
  iosConfig: {
    appBundleId: 'com.yourapp',
    appTeamId: 'YOUR_TEAM_ID',
  },
  watcherMail: 'security@yourapp.com',
};

const threatCallbacks = {
  onRootDetected: () => handleThreat('root'),
  onDebuggerDetected: () => handleThreat('debugger'),
  onTamperDetected: () => handleThreat('tamper'),
  onHookDetected: () => handleThreat('hook'),
  onDeviceBindingDetected: () => handleThreat('deviceBinding'),
  onUnofficialStoreDetected: () => handleThreat('unofficialStore'),
};

function handleThreat(type: string): void {
  // Tiered response — see below
  analytics.track('security_threat', { type });

  switch (type) {
    case 'root':
    case 'hook':
      // Degrade gracefully: disable sensitive features
      securityStore.setState({ restrictedMode: true });
      break;
    case 'tamper':
    case 'debugger':
      // Hard block: force logout
      authStore.getState().logout();
      break;
  }
}

// In your App component
function App() {
  useFreeRasp(talsecConfig, threatCallbacks);
  return <MainNavigator />;
}

Limitations and Tiered Response

Root/jailbreak detection is always bypassable. Tools like Magisk (Android) and Shadow/Liberty Lite (iOS) specifically exist to hide root status from detection libraries. Treat detection as a speed bump, not a wall. Never use it as your sole security control.

Design a tiered response instead of a binary block:

Threat levelDetectionResponse
LowRooted device, emulatorLog event, show warning, allow continued use
MediumMock location, external storageDisable location-sensitive features
HighDebugger attached, hooks detectedRestrict sensitive operations, force re-auth
CriticalBinary tampered, unofficial storeForce logout, wipe local credentials

Runtime Protection

Preventing Screenshots (Android)

// android/app/src/main/java/com/yourapp/ScreenProtectionModule.kt
package com.yourapp

import android.view.WindowManager
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod

class ScreenProtectionModule(
    private val reactContext: ReactApplicationContext
) : ReactContextBaseJavaModule(reactContext) {

    override fun getName() = "ScreenProtection"

    @ReactMethod
    fun enable() {
        currentActivity?.runOnUiThread {
            currentActivity?.window?.addFlags(
                WindowManager.LayoutParams.FLAG_SECURE
            )
        }
    }

    @ReactMethod
    fun disable() {
        currentActivity?.runOnUiThread {
            currentActivity?.window?.clearFlags(
                WindowManager.LayoutParams.FLAG_SECURE
            )
        }
    }
}

Usage from JS:

import { NativeModules } from 'react-native';

const { ScreenProtection } = NativeModules;

// Enable on sensitive screens
function PaymentScreen() {
  useEffect(() => {
    ScreenProtection.enable();
    return () => ScreenProtection.disable();
  }, []);

  return <PaymentForm />;
}

App Backgrounding Snapshot Protection (iOS)

iOS takes a snapshot of your app when it enters the background (for the app switcher). This can expose sensitive content.

// Hook to overlay a blur/splash when app backgrounds
import { useEffect } from 'react';
import { AppState, type AppStateStatus } from 'react-native';

export function useAppBackgroundProtection() {
  useEffect(() => {
    const handleChange = (state: AppStateStatus) => {
      if (state === 'inactive' || state === 'background') {
        // Show a privacy overlay (splash screen, blur)
        privacyOverlayStore.setState({ visible: true });
      } else if (state === 'active') {
        privacyOverlayStore.setState({ visible: false });
      }
    };

    const subscription = AppState.addEventListener('change', handleChange);
    return () => subscription.remove();
  }, []);
}

Debug Detection

// core/security/debugDetection.ts

export function isDebugEnvironment(): boolean {
  if (__DEV__) return true;

  // Android: check for debugger attachment
  // This requires a native module for reliable detection
  return false;
}

export function enforceReleaseMode(): void {
  if (__DEV__) {
    console.warn('Running in debug mode — security checks relaxed');
    return;
  }

  // In release, verify integrity
  const checks = [
    !__DEV__,
    typeof atob === 'function', // Hermes environment check
  ];

  if (checks.some((c) => !c)) {
    // Environment has been tampered with
    authStore.getState().logout();
  }
}

Authentication Security

Secure Token Storage Pattern

The standard pattern: keep the short-lived access token in memory, store the long-lived refresh token in Keychain/Keystore.

// core/auth/tokenManager.ts
import * as Keychain from 'react-native-keychain';

class TokenManager {
  // Access token lives in memory only — never persisted to disk
  private accessToken: string | null = null;

  async setTokens(access: string, refresh: string): Promise<void> {
    this.accessToken = access;
    await Keychain.setGenericPassword('refresh', refresh, {
      service: 'com.yourapp.refresh-token',
      accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    });
  }

  getAccessToken(): string | null {
    return this.accessToken;
  }

  async getRefreshToken(): Promise<string | null> {
    const creds = await Keychain.getGenericPassword({
      service: 'com.yourapp.refresh-token',
    });
    return creds ? creds.password : null;
  }

  async clearAll(): Promise<void> {
    this.accessToken = null;
    await Keychain.resetGenericPassword({
      service: 'com.yourapp.refresh-token',
    });
  }
}

export const tokenManager = new TokenManager();

Refresh Token Rotation

// core/auth/refreshTokenRotation.ts
import { tokenManager } from './tokenManager';

let refreshPromise: Promise<boolean> | null = null;

export async function refreshAccessToken(): Promise<boolean> {
  // Deduplicate concurrent refresh attempts
  if (refreshPromise) return refreshPromise;

  refreshPromise = (async () => {
    try {
      const refreshToken = await tokenManager.getRefreshToken();
      if (!refreshToken) return false;

      const response = await fetch('https://api.yourapp.com/auth/refresh', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ refreshToken }),
      });

      if (!response.ok) {
        await tokenManager.clearAll();
        return false;
      }

      const { accessToken, refreshToken: newRefreshToken } =
        await response.json();

      // Server issues a new refresh token — old one is invalidated
      await tokenManager.setTokens(accessToken, newRefreshToken);
      return true;
    } catch {
      return false;
    } finally {
      refreshPromise = null;
    }
  })();

  return refreshPromise;
}

Biometric Authentication

import ReactNativeBiometrics from 'react-native-biometrics';

const biometrics = new ReactNativeBiometrics({
  allowDeviceCredentials: true, // fall back to device PIN
});

export async function authenticateUser(): Promise<boolean> {
  const { available, biometryType } = await biometrics.isSensorAvailable();

  if (!available) {
    // Device has no biometric hardware — fall back to password
    return false;
  }

  const { success } = await biometrics.simplePrompt({
    promptMessage: `Sign in with ${biometryType}`,
    cancelButtonText: 'Use password',
  });

  return success;
}

Session Timeout

// core/auth/sessionTimeout.ts
import { AppState, type AppStateStatus } from 'react-native';

const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

class SessionTimeoutManager {
  private backgroundedAt: number | null = null;

  start(): void {
    AppState.addEventListener('change', this.handleAppStateChange);
  }

  private handleAppStateChange = (state: AppStateStatus): void => {
    if (state === 'background') {
      this.backgroundedAt = Date.now();
    } else if (state === 'active' && this.backgroundedAt) {
      const elapsed = Date.now() - this.backgroundedAt;
      this.backgroundedAt = null;

      if (elapsed > SESSION_TIMEOUT_MS) {
        // Session expired — require re-authentication
        authStore.getState().requireReauth();
      }
    }
  };
}

export const sessionTimeout = new SessionTimeoutManager();

OAuth 2.0 + PKCE

Use react-native-app-auth for standards-compliant OAuth flows. PKCE (Proof Key for Code Exchange) is mandatory for mobile — it prevents authorization code interception.

import { authorize, type AuthConfiguration } from 'react-native-app-auth';

const config: AuthConfiguration = {
  issuer: 'https://auth.yourapp.com',
  clientId: 'mobile-app',
  redirectUrl: 'com.yourapp://oauth/callback',
  scopes: ['openid', 'profile', 'offline_access'],
  usePKCE: true, // generates code_verifier + code_challenge automatically
  additionalParameters: {
    prompt: 'consent',
  },
};

export async function login(): Promise<void> {
  try {
    const result = await authorize(config);

    await tokenManager.setTokens(
      result.accessToken,
      result.refreshToken ?? '',
    );
  } catch (error) {
    // User cancelled or auth failed
    throw new AuthenticationError('Login failed', { cause: error });
  }
}

Deep links can be hijacked by malicious apps registering the same scheme. Use verified app links (Android) and universal links (iOS) to prevent this.

// Validate incoming deep links before acting on them
import { Linking } from 'react-native';

const ALLOWED_HOSTS = ['app.yourapp.com', 'yourapp.com'];

async function handleDeepLink(url: string): Promise<void> {
  const parsed = new URL(url);

  // Reject links from unexpected origins
  if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
    console.warn(`Rejected deep link from unknown host: ${parsed.hostname}`);
    return;
  }

  // Validate path structure before navigating
  const oauthCallback = parsed.pathname.match(/^\/oauth\/callback/);
  if (oauthCallback) {
    const code = parsed.searchParams.get('code');
    const state = parsed.searchParams.get('state');

    if (!code || !state) {
      console.warn('Malformed OAuth callback — missing code or state');
      return;
    }

    // Validate state parameter matches what we generated
    const expectedState = await SecureStore.getItemAsync('oauth_state');
    if (state !== expectedState) {
      console.warn('OAuth state mismatch — possible CSRF');
      return;
    }

    await exchangeCodeForTokens(code);
  }
}

Data Protection

Console.log Ships in Production

console.log is not stripped in production React Native builds by default. Unlike web bundlers that tree-shake console calls, Metro keeps them. If you log tokens, user data, or API responses during development, that data is written to the device log in production — accessible via adb logcat on Android and Console.app on iOS.

Strip Console in Production

Use babel-plugin-transform-remove-console:

// babel.config.js
module.exports = {
  presets: ['module:@react-native/babel-preset'],
  env: {
    production: {
      plugins: ['transform-remove-console'],
    },
  },
};

Or selectively strip only certain levels:

// babel.config.js
module.exports = {
  presets: ['module:@react-native/babel-preset'],
  env: {
    production: {
      plugins: [
        ['transform-remove-console', { exclude: ['error', 'warn'] }],
      ],
    },
  },
};

Securing WebView

WebView is a frequent attack surface. Treat it with the same caution you would an iframe on the web.

import { WebView } from 'react-native-webview';

function SecureWebView({ url }: { url: string }) {
  return (
    <WebView
      source={{ uri: url }}
      // Restrict which origins can load
      originWhitelist={['https://trusted.yourapp.com']}
      // Disable JavaScript if content is static
      javaScriptEnabled={false}
      // Prevent navigation to unexpected URLs
      onShouldStartLoadWithRequest={(request) => {
        return request.url.startsWith('https://trusted.yourapp.com');
      }}
      // Do NOT use injectedJavaScript for sensitive operations
      // It runs in the WebView context and can be intercepted
    />
  );
}

Never pass tokens via injectedJavaScript. Any JavaScript injected into a WebView runs in the web context and can be read by the loaded page. If your WebView needs to authenticate, use cookie-based auth set by your server, or use postMessage with origin checks and short-lived, scoped tokens.

GDPR and Data Minimization

  • Do not store PII on device unless strictly necessary. Tokenize server-side.
  • Implement a "delete my data" flow that clears both server and device storage.
  • Audit every AsyncStorage / MMKV key your app writes — each one is a data retention liability.
  • If you use analytics, ensure they are GDPR-compliant and consent-gated before initializing.
// core/storage/gdprWipe.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Keychain from 'react-native-keychain';
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();

export async function wipeAllUserData(): Promise<void> {
  // 1. Clear secure credentials
  await Keychain.resetGenericPassword();
  await Keychain.resetGenericPassword({ service: 'com.yourapp.refresh-token' });

  // 2. Clear fast storage
  storage.clearAll();

  // 3. Clear async storage
  await AsyncStorage.clear();

  // 4. Request server-side deletion
  await fetch('https://api.yourapp.com/user/delete', {
    method: 'DELETE',
    headers: { Authorization: `Bearer ${lastAccessToken}` },
  });
}

Security Checklist

Pre-Release Security Checklist

CategoryCheckPriority
StorageAuth tokens stored in Keychain/Keystore, not AsyncStorage/MMKVCritical
StorageNo sensitive data in plaintext on diskCritical
StorageMMKV/AsyncStorage audited for accidental PIIHigh
NetworkCertificate pinning enabled for all API endpointsHigh
NetworkNo cleartext HTTP traffic allowed (ATS / network security config)Critical
NetworkAPI keys proxied through backend, not in JS bundleCritical
Codeconsole.log stripped in production buildsHigh
CodeNo hardcoded secrets in source (scan with trufflehog or gitleaks)Critical
CodeProGuard/R8 enabled for Android release buildsMedium
CodeHermes enabled (bytecode, not plaintext JS)Medium
AuthAccess tokens are short-lived (< 15 min)High
AuthRefresh token rotation implementedHigh
AuthSession timeout on app backgroundMedium
AuthOAuth flows use PKCECritical
AuthDeep links validated before processingHigh
RuntimeRoot/jailbreak detection with tiered responseMedium
RuntimeScreenshot prevention on sensitive screensMedium
RuntimeApp backgrounding snapshot protectionMedium
RuntimeDebug detection in release buildsLow
WebViewOrigin whitelist configuredHigh
WebViewNo tokens passed via injectedJavaScriptCritical
DataGDPR data deletion flow implementedHigh (EU)
DataAnalytics consent-gated before initializationHigh (EU)
Supply chainDependencies audited (npm audit, Snyk)High
Supply chainLock file committed, no floating versionsMedium

On this page