Mobile Security

React Native Security: Secure Storage, Deep Links, and API Security

Practical React Native security guide covering why AsyncStorage is insecure for tokens, how to use react-native-keychain, preventing deep link hijacking, and implementing certificate pinning.

November 1, 20258 min readShipSafer Team

React Native applications run JavaScript but ship as native apps with access to sensitive user data. The JavaScript layer introduces unique risks that pure native apps don't face: secrets embedded in JS bundles are extractable, AsyncStorage persists data unencrypted to the device filesystem, and deep link handlers written by web developers often lack the URI validation that native developers apply reflexively.

Why AsyncStorage Is Insecure for Tokens

AsyncStorage is React Native's key-value store. Its API is simple and familiar — which is why it's the first place developers store auth tokens.

// This is what everyone writes first — and it's insecure
import AsyncStorage from '@react-native-async-storage/async-storage';

// After login
await AsyncStorage.setItem('auth_token', token);

// On app launch
const token = await AsyncStorage.getItem('auth_token');

The problem: AsyncStorage writes data to plaintext files on the device filesystem.

On Android, data is stored in /data/data/com.yourapp/databases/RCTAsyncLocalStorage_V1 (SQLite). On a rooted device, any process can read this file. On unrooted devices, Android backups (adb backup) extract AsyncStorage data. The android:allowBackup="true" flag (default) means any computer that has ever had adb access to the phone can extract auth tokens.

On iOS, AsyncStorage data is stored in the app's Documents directory, which is included in unencrypted iCloud backups and iTunes backups unless the app sets NSFileProtectionComplete.

Never store auth tokens, API keys, session identifiers, or any sensitive secrets in AsyncStorage.

react-native-keychain: The Correct Alternative

react-native-keychain stores secrets in the iOS Keychain (hardware-backed on devices with Secure Enclave) and Android Keystore on Android. The underlying storage is encrypted and inaccessible to other apps, most backup systems, and debuggers.

npm install react-native-keychain
cd ios && pod install

Storing and Retrieving Tokens

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

// Store authentication token
export async function storeAuthToken(token: string): Promise<boolean> {
  try {
    await Keychain.setGenericPassword(
      'auth_token',  // username field (used as a label)
      token,         // password field (the actual secret)
      {
        accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
        // Don't include in iCloud Keychain sync
        // Stays on this device only
      }
    );
    return true;
  } catch (error) {
    console.error('Failed to store token in keychain');
    return false;
  }
}

export async function getAuthToken(): Promise<string | null> {
  try {
    const credentials = await Keychain.getGenericPassword();
    if (credentials) {
      return credentials.password;
    }
    return null;
  } catch (error) {
    return null;
  }
}

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

Biometric-Protected Secrets

For highly sensitive secrets (private keys, master encryption keys), require biometric authentication before access:

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

export async function storeSensitiveSecret(
  key: string,
  secret: string
): Promise<boolean> {
  try {
    // Check biometric availability
    const biometryType = await Keychain.getSupportedBiometryType();
    if (!biometryType) {
      // Fall back to device PIN/password
    }

    await Keychain.setGenericPassword('secret_key', secret, {
      accessControl: biometryType
        ? Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE
        : Keychain.ACCESS_CONTROL.DEVICE_PASSCODE,
      accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
      service: key,  // namespace for multiple secrets
    });
    return true;
  } catch {
    return false;
  }
}

export async function getSensitiveSecret(
  key: string,
  promptMessage: string
): Promise<string | null> {
  try {
    const credentials = await Keychain.getGenericPassword({
      service: key,
      authenticationPrompt: {
        title: 'Authentication Required',
        subtitle: promptMessage,
        cancel: 'Cancel',
      },
    });
    return credentials ? credentials.password : null;
  } catch {
    // User cancelled biometrics or device not enrolled
    return null;
  }
}

Deep Link Hijacking Prevention

Deep links let other apps and websites navigate into your app. Without validation, a malicious app can craft a deep link that navigates your app to a phishing URL, initiates an OAuth flow, or triggers a payment action.

The Attack Pattern

If your app handles myapp://oauth/callback?code=ABC, a malicious app that also registers the myapp:// scheme intercepts the OAuth callback on Android (scheme hijacking). On iOS, universal links mitigate this because HTTPS-based links require domain ownership verification.

Use Universal Links (iOS) and App Links (Android)

Instead of custom schemes, use HTTPS-based links that your server verifies:

iOS: Host an apple-app-site-association file at https://yourdomain.com/.well-known/apple-app-site-association:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.yourapp"],
        "components": [
          { "/": "/open/*" },
          { "/": "/oauth/callback" }
        ]
      }
    ]
  }
}

Android: Host assetlinks.json at https://yourdomain.com/.well-known/assetlinks.json:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.yourapp",
    "sha256_cert_fingerprints": [
      "AA:BB:CC:DD:..."
    ]
  }
}]

Validating Deep Link Parameters

Even with universal links, validate every parameter before using it:

import { Linking } from 'react-native';
import { useEffect } from 'react';
import { useNavigation } from '@react-navigation/native';

const ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com'];
const PRODUCT_ID_REGEX = /^[a-zA-Z0-9\-]{1,64}$/;

function useDeepLinkHandler() {
  const navigation = useNavigation();

  useEffect(() => {
    const handleUrl = ({ url }: { url: string }) => {
      try {
        const parsed = new URL(url);

        // Validate host — reject anything not from our domain
        if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
          console.warn('Deep link from unexpected host:', parsed.hostname);
          return;
        }

        const path = parsed.pathname;

        if (path.startsWith('/open/product/')) {
          const productId = path.replace('/open/product/', '');

          // Validate product ID format before using it
          if (!PRODUCT_ID_REGEX.test(productId)) {
            return;
          }

          navigation.navigate('Product', { productId });

        } else if (path === '/oauth/callback') {
          const code = parsed.searchParams.get('code');
          const state = parsed.searchParams.get('state');

          // Validate CSRF state token
          if (!state || !isValidStateToken(state)) {
            return;
          }

          if (code && /^[a-zA-Z0-9\-_.~]{1,512}$/.test(code)) {
            handleOAuthCallback(code);
          }
        }
      } catch (error) {
        // Invalid URL — discard silently
      }
    };

    const subscription = Linking.addEventListener('url', handleUrl);

    // Handle app launch from deep link
    Linking.getInitialURL().then((url) => {
      if (url) handleUrl({ url });
    });

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

Certificate Pinning

Certificate pinning ensures your app only communicates with your server and rejects connections to servers presenting any other certificate — even valid ones signed by a trusted CA. This prevents MitM attacks even when an attacker installs a root CA on the device (a common corporate proxy technique used by attackers).

Using react-native-ssl-pinning

npm install react-native-ssl-pinning

Get the certificate SHA-256 fingerprint:

openssl s_client -connect api.yourapp.com:443 2>/dev/null \
  | openssl x509 -fingerprint -sha256 -noout \
  | awk -F'=' '{print $2}' \
  | tr -d ':' \
  | tr '[:upper:]' '[:lower:]'

Implement a pinned fetch wrapper:

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

interface PinnedRequestOptions {
  method?: string;
  headers?: Record<string, string>;
  body?: string;
}

// Your cert fingerprint (without colons, lowercase)
const API_CERT_FINGERPRINT = 'aabbccddeeff...';

export async function secureFetch(
  url: string,
  options: PinnedRequestOptions = {}
): Promise<unknown> {
  const response = await pinnedFetch(url, {
    method: options.method ?? 'GET',
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
    body: options.body,
    sslPinning: {
      certs: [API_CERT_FINGERPRINT],
    },
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return response.json();
}

Important: Always pin two certificates — the active one and a backup. If you deploy a new certificate without a backup pin in place, your existing app version (which cannot be force-updated) will stop working for all users who haven't updated.

Certificate rotation strategy:

  1. Add backup pin for the new certificate at least one app release cycle before rotation
  2. Wait for the old app version (with only the old pin) to drop below acceptable usage threshold
  3. Rotate the certificate
  4. Release a new app version that removes the old pin

Preventing JS Bundle Extraction

React Native bundles your JavaScript into a main.jsbundle or index.android.bundle that is included in the APK/IPA. This bundle is readable by anyone who extracts the package.

Mitigation strategies:

1. Never put secrets in the JS bundle:

// WRONG: API key in code
const API_KEY = 'sk_live_abc123';

// CORRECT: fetch from your backend or use Keychain
const apiKey = await getAuthToken();  // from Keychain, set after auth

2. Use Hermes and bytecode compilation (React Native default since 0.70):

The Hermes engine compiles JS to bytecode before packaging, which is significantly harder to read than raw JavaScript, though not impossible to reverse engineer.

3. Enable ProGuard/R8 on Android to obfuscate the Java/Kotlin bridge layer.

4. Use the Play Integrity API to verify your app hasn't been tampered with:

import { PlayIntegrity } from 'react-native-play-integrity';

async function verifyAppIntegrity(nonce: string): Promise<boolean> {
  try {
    const token = await PlayIntegrity.requestIntegrityToken(nonce);
    // Send token to your backend for verification
    const result = await verifyTokenOnBackend(token);
    return result.deviceIntegrity.includes('MEETS_DEVICE_INTEGRITY');
  } catch {
    return false;
  }
}

The nonce should be a server-generated one-time value to prevent replay attacks.

API Security in React Native

Use HTTPS exclusively. Set android:usesCleartextTraffic="false" in AndroidManifest.xml and configure ATS (App Transport Security) on iOS.

Authenticate every API request with short-lived tokens. Never use long-lived API keys in client apps:

import axios from 'axios';
import { getAuthToken, refreshAuthToken } from './auth';

const api = axios.create({
  baseURL: 'https://api.yourapp.com/v1',
  timeout: 10000,
});

api.interceptors.request.use(async (config) => {
  const token = await getAuthToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // Try to refresh the token
      const newToken = await refreshAuthToken();
      if (newToken) {
        error.config.headers.Authorization = `Bearer ${newToken}`;
        return api.request(error.config);
      }
      // Refresh failed — force re-login
      await clearAuthToken();
      navigateToLogin();
    }
    return Promise.reject(error);
  }
);

Rate limit on the server — mobile clients are easier to compromise than servers, so never rely on client-side rate limiting for security decisions.

react native
mobile security
async storage
keychain
deep links
certificate pinning
api security

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.