DEV Community

Cover image for React Native App Security — Threat Model, Storage, Auth, Network & Production Hardening
Amit Kumar
Amit Kumar

Posted on

React Native App Security — Threat Model, Storage, Auth, Network & Production Hardening

Introduction

Your React Native app ships a JS bundle, talks to APIs, stores a session, and maybe unlocks with Face ID. Attackers don’t need your source repo — they need a rooted phone, a proxy, or a leaked token in AsyncStorage.

What is app security in React Native?

It is the set of practices that protect secrets, user data, sessions, and API trust on a device you do not fully control — across JS, native layers, network, and store distribution.

Why it matters

  • Mobile clients can be reverse-engineered; “hidden in the app” is not secret
  • Tokens in plain storage are one backup/export away from theft
  • Cleartext HTTP, weak deep links, and verbose logs are common real breaches
  • Play Store / App Store and enterprise clients increasingly expect basic hardening

What this article covers

  1. Threat model — what you are defending against
  2. What to store where (Keychain vs AsyncStorage)
  3. Auth, refresh tokens, biometrics
  4. Network security (TLS, pinning, cleartext)
  5. Deep links, intents, WebViews
  6. Reverse engineering & binary hardening
  7. Device integrity, privacy screens, logging
  8. Dependencies, CI secrets, ship checklist

Table of Contents

  1. Threat model
  2. Security decision map
  3. Secrets & secure storage
  4. Authentication & sessions
  5. Biometrics
  6. Network security
  7. Deep links, intents & WebViews
  8. Code protection & reverse engineering
  9. Device integrity & privacy
  10. Logging, analytics & PII
  11. Permissions & platform config
  12. Dependencies & supply chain
  13. CI / secrets hygiene
  14. Scenario walkthrough
  15. Ship checklist
  16. Interview-style Q&A

Body

1. Threat model — think like an attacker

Threat What they do Your defense
Casual user Screenshot, share device Privacy screen, short session, biometric gate
Stolen device Unlock later, read local data Keychain/Keystore, biometric + short TTL tokens
Network attacker MITM on café Wi‑Fi HTTPS only, certificate pinning (high-risk apps)
Reverse engineer Decompile APK / read bundle No secrets in JS, ProGuard/R8, obfuscation limits
Malicious app Read backups, abuse intents allowBackup=false, validate deep links
Compromised dependency Steal tokens at runtime Lockfile, audits, minimal native modules

Golden rule

Anything shipped inside the app binary can eventually be extracted. Real secrets live on the server. The app holds capabilities (tokens) that must be short-lived, revocable, and stored as safely as the OS allows.


2. Security decision map — what to use where

You need to… Do this Avoid
Store access / refresh token Keychain (iOS) / Keystore-backed storage (Android) AsyncStorage, plain files, Redux Persist without encryption
Store user prefs (theme, flags) AsyncStorage / MMKV (non-secret) Putting tokens next to prefs
API keys for your backend Backend only; app uses user session Hardcoding in JS / .env baked into bundle
Third-party keys (Maps, etc.) Restrict by bundle id / SHA-1; treat as semi-public Assuming the key is private
Unlock returning user Biometric + secure-stored refresh token “Biometric only” with token in AsyncStorage
Talk to API HTTPS + timeout + auth header Cleartext HTTP in production
Open myapp://… links Allowlist hosts/paths; auth gate Blind navigate(params.screen)
Hide screen in app switcher Privacy overlay / FLAG_SECURE where needed Leaving banking screens visible in recents
Debug logs in prod Strip / gate behind __DEV__ Logging tokens, passwords, PII

3. Secrets & secure storage

3.1 The storage ladder

Data Storage Notes
Access token Secure storage Short TTL (minutes)
Refresh token Secure storage Rotating; revoke on logout / reuse
PII cache Encrypted DB or don’t cache Minimize retention
Feature flags / theme AsyncStorage / MMKV Non-sensitive
Offline content App documents No secrets in filenames/logs

Popular RN libraries:

3.2 Scenario: Persist session the wrong way vs right way

// BAD — readable on rooted devices / backups / forensic tools
import AsyncStorage from '@react-native-async-storage/async-storage';
await AsyncStorage.setItem('token', accessToken);

// GOOD — OS-backed secure storage
import * as Keychain from 'react-native-keychain';

await Keychain.setGenericPassword('session', accessToken, {
  accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  // Optional: require biometric to read
  accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
});

const creds = await Keychain.getGenericPassword();
const token = creds ? creds.password : null;
Enter fullscreen mode Exit fullscreen mode

On logout — always wipe

await Keychain.resetGenericPassword();
// also clear in-memory auth state / query cache
Enter fullscreen mode Exit fullscreen mode

3.3 Never ship server secrets in the bundle

// BAD — visible in the JS bundle
const STRIPE_SECRET = 'sk_live_...';
const ADMIN_API_KEY = 'super-secret';

// GOOD — app calls your backend; backend holds secrets
await api.createPaymentIntent({orderId});
Enter fullscreen mode Exit fullscreen mode

.env files used with bundlers still often end up in the client bundle if imported by app code. Treat client env as public configuration, not secret storage.


4. Authentication & sessions

4.1 Recommended token shape

Token Lifetime Where Used for
Access token 5–30 min Memory + optional secure storage Authorization header
Refresh token Days–weeks Secure storage only Get new access token
ID token (OIDC) Short Memory User profile claims (validate server-side)

4.2 Scenario: Auth context that doesn’t leak tokens into logs

async function login(email, password) {
  const {accessToken, refreshToken, user} = await api.login({email, password});

  await Keychain.setGenericPassword('refresh', refreshToken, {
    accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });

  // Keep access token in memory when possible
  setSession({accessToken, user});
}

async function refreshSession() {
  const creds = await Keychain.getGenericPassword();
  if (!creds) throw new Error('No session');

  const {accessToken, refreshToken} = await api.refresh(creds.password);
  await Keychain.setGenericPassword('refresh', refreshToken, {
    accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
  setSession(prev => ({...prev, accessToken}));
}
Enter fullscreen mode Exit fullscreen mode

Server rules that matter more than client code

  • Rotate refresh tokens; detect reuse → revoke family
  • Bind sessions to device where product allows
  • Rate-limit login / OTP
  • Never trust client-sent userId without auth

4.3 Scenario: Attach token in API client (without printing it)

async function apiFetch(path, options = {}) {
  const token = getAccessTokenFromMemory();
  const res = await fetch(`${API_URL}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? {Authorization: `Bearer ${token}`} : {}),
      ...options.headers,
    },
  });

  if (res.status === 401) {
    await refreshSession();
    return apiFetch(path, options); // careful: one retry max
  }

  return res;
}
Enter fullscreen mode Exit fullscreen mode

Add a single-flight refresh (mutex) so parallel 401s don’t rotate the refresh token twice.


5. Biometrics

Biometrics protect access to a key/token on device — they are not a replacement for server auth.

Scenario: “Unlock app” with biometrics

Flow:

  1. User logs in with password / OTP once
  2. Refresh token stored in Keychain with biometric access control
  3. Next launch → biometric prompt → read token → refresh session
import ReactNativeBiometrics from 'react-native-biometrics';
// or @sbaiahmed1/react-native-biometrics — same idea: prompt then use secure storage

async function unlockWithBiometrics() {
  const rnBio = new ReactNativeBiometrics();
  const {success} = await rnBio.simplePrompt({
    promptMessage: 'Unlock to continue',
  });
  if (!success) return null;

  const creds = await Keychain.getGenericPassword();
  if (!creds) return null;
  return refreshSessionWith(creds.password);
}
Enter fullscreen mode Exit fullscreen mode

Platform config

  • iOS: NSFaceIDUsageDescription in Info.plist
  • Android: USE_BIOMETRIC permission; prefer BiometricPrompt APIs via a maintained library

Don’t

  • Store the password and “secure it” with a boolean biometricsEnabled in AsyncStorage
  • Skip server refresh and trust a local isAuthenticated: true flag

6. Network security

6.1 HTTPS only

  • Production API: HTTPS only
  • Android: disable cleartext unless a debug-only exception exists
  • iOS: App Transport Security (ATS) — don’t weaken globally for convenience

Android (concept)

<!-- Release: cleartext off -->
<application
  android:usesCleartextTraffic="false"
  android:allowBackup="false"
  ...>
Enter fullscreen mode Exit fullscreen mode

Use a network security config if you need cleartext only for debug builds / emulators.

iOS — avoid blanket:

<key>NSAllowsArbitraryLoads</key>
<true/>
Enter fullscreen mode Exit fullscreen mode

6.2 Certificate pinning (when it is worth it)

Pinning reduces MITM risk if a user installs a custom CA (or a CA is compromised). Cost: cert rotation must be planned.

App type Pinning?
Banking / health / high-fraud Strongly consider
Typical consumer MVP HTTPS + good auth often enough
Apps with frequent cert changes Pin carefully (backup pins)

Libraries / approaches: react-native-ssl-pinning, OkHttp CertificatePinner (Android), TrustKit (iOS), or a maintained networking stack that supports pins.

6.3 Scenario: Safer fetch defaults

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);

try {
  const res = await fetch(url, {
    signal: controller.signal,
    headers: {Authorization: `Bearer ${token}`},
  });
  // validate res.ok + JSON schema for critical payloads
} finally {
  clearTimeout(timeout);
}
Enter fullscreen mode Exit fullscreen mode

Also:

  • Pin API host in config (don’t accept arbitrary base URLs from user input)
  • Validate deep-link-driven URLs before WebView / Linking.openURL

7. Deep links, intents & WebViews

7.1 Deep links are public entry points

// BAD — attacker opens myapp://open?screen=Admin&userId=1
navigation.navigate(params.screen, params);

// GOOD — allowlist routes; ignore unexpected params
const routes = {
  product: id => navigation.navigate('Details', {id}),
  reset: token => navigation.navigate('ResetPassword', {token}),
};

function handleUrl(url) {
  const {path, queryParams} = parse(url);
  const open = routes[path];
  if (!open) return;
  open(queryParams.id || queryParams.token);
}
Enter fullscreen mode Exit fullscreen mode

Rules

  • Allowlist paths
  • Never take a raw screen name from the URL
  • Require auth for private screens after cold start
  • Use https app links / universal links with verified assetlinks / AASA where possible

7.2 Android exported components

Only export activities/services that must receive outside intents. Validate intent extras. Prefer android:exported="false" by default for non-entry components.

7.3 WebViews

Risk Mitigation
JS bridge abuse Expose minimal API; never raw eval of remote strings
Phishing Restrict navigation to allowlisted domains
Token theft Don’t inject tokens into arbitrary pages
File access Disable unused file / universal access settings
<WebView
  source={{uri: httpsUrlYouTrust}}
  originWhitelist={['https://*']}
  onShouldStartLoadWithRequest={req => isAllowed(req.url)}
  // avoid mixed content; careful with injectedJavaScript
/>
Enter fullscreen mode Exit fullscreen mode

8. Code protection & reverse engineering

Assume the JS bundle is readable. Your goal is raise cost, not perfect secrecy.

Layer Action
JS No secrets; minimize sensitive strings; Hermes bytecode helps a bit, not enough alone
Android R8/ProGuard minify for release; shrink resources
iOS Strip symbols in release; don’t ship debug entitlements
Integrity Server checks + optional Play Integrity / App Attest for high-risk actions
Root detection Optional signal — never sole auth factor
// android/app/build.gradle (release)
buildTypes {
  release {
    minifyEnabled true
    shrinkResources true
    proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
  }
}
Enter fullscreen mode Exit fullscreen mode

Keep ProGuard rules for RN / Hermes / your native libs so release builds don’t break.


9. Device integrity & privacy

9.1 Root / jailbreak detection

Useful as a risk signal (block payments, force re-login, warn). Easy to bypass on determined devices — pair with server-side fraud checks.

9.2 Scenario: Hide sensitive screen in app switcher

Android — FLAG_SECURE (blocks screenshots + hides recents content):

// via a native module / library that sets WindowManager.LayoutParams.FLAG_SECURE
enableSecureView();  // on banking / OTP screens
disableSecureView(); // when leaving
Enter fullscreen mode Exit fullscreen mode

iOS — blank the screen on background / capture notifications; avoid leaving OTP visible in app switcher.

9.3 Clipboard & screenshots

  • Don’t auto-copy OTPs to clipboard without UX need
  • Clear clipboard after paste on sensitive flows when appropriate
  • Warn before allowing screenshots on regulated screens (where OS allows)

9.4 Local authentication lock

import {AppState} from 'react-native';

// Pseudo-flow
// when AppState goes background → mark "needsUnlock"
// when active again after N minutes → show biometric / PIN gate
Enter fullscreen mode Exit fullscreen mode

10. Logging, analytics & PII

Scenario: Accidental token leak

// BAD
console.log('login response', response);
console.log('Authorization', headers);

// GOOD
if (__DEV__) {
  console.log('login ok', {userId: user.id});
}
Enter fullscreen mode Exit fullscreen mode

Checklist

  • Gate verbose logs with __DEV__
  • Redact Authorization, cookies, tokens, passwords in any network logger
  • In crash reporting (Sentry, etc.), scrub headers and user input fields
  • Don’t send full form payloads to analytics

11. Permissions & platform config

Ask only what you need

Permission Rule
Camera / mic / location Justify in store listing + OS purpose strings
Biometric Purpose string on iOS; prompt only when needed
Notifications Request after value is clear, not on first frame
Exact alarms / overlay High scrutiny — avoid unless required

Hardening defaults (examples)

<!-- AndroidManifest -->
<application
  android:allowBackup="false"
  android:usesCleartextTraffic="false"
  ...>
Enter fullscreen mode Exit fullscreen mode
<!-- Info.plist: Face ID purpose (required if you use Face ID) -->
<key>NSFaceIDUsageDescription</key>
<string>Unlock your account securely</string>
Enter fullscreen mode Exit fullscreen mode

allowBackup="false" reduces accidental token exfiltration via Android backup — align with product needs (some apps want backup; then encrypt sensitive data).


12. Dependencies & supply chain

React Native apps pull a lot of native code. One shady package can read Keychain via your own privileges.

Practices

  1. Commit yarn.lock / package-lock.json
  2. Run npm audit / yarn npm audit in CI (triage realistically)
  3. Prefer maintained libraries with recent releases
  4. Minimize native modules — each is attack + maintenance surface
  5. Review postinstall scripts on new deps
  6. Use Dependabot / Renovate for security patches
yarn npm audit
npx react-native info   # know your native surface
Enter fullscreen mode Exit fullscreen mode

13. CI / secrets hygiene

Secret Where it belongs
Keystore passwords / signing CI secrets / ESC encrypted store
App Store Connect / Play JSON CI secrets
API admin keys Server / CI only
google-services.json / GoogleService-Info.plist Often required in app — restrict APIs in cloud console

Never commit:

  • *.keystore with production keys
  • .env with production secrets
  • Personal access tokens

Use different dev / staging / prod API environments and signing identities.


14. Scenario walkthrough — login to API call

Product flow: email login → secure session → biometric next launch → fetch profile.

1. User submits email/password over HTTPS
2. Server returns access + refresh tokens
3. App stores refresh token in Keychain (biometric-protected optional)
4. Access token kept in memory; Authorization on API calls
5. On 401 → single-flight refresh → retry once
6. On logout → Keychain reset + memory clear + query cache clear
7. Cold start → biometric → read refresh → new access token
8. Logs never print tokens; cleartext disabled; allowBackup false
Enter fullscreen mode Exit fullscreen mode

Where bugs usually appear

  • Refresh token left in AsyncStorage “just for now”
  • Access token logged by a debug axios interceptor in prod
  • Deep link opens ResetPassword without validating token server-side
  • Staging HTTP cleartext flag left on for release builds

15. Ship checklist

Storage & auth

  • [ ] Tokens in Keychain/Keystore-backed storage — not AsyncStorage
  • [ ] Logout clears secure storage + in-memory session
  • [ ] Refresh rotation / revoke strategy exists on server
  • [ ] Biometric unlock does not replace server auth

Network

  • [ ] HTTPS only in release
  • [ ] Cleartext disabled for production Android / ATS not wide-open on iOS
  • [ ] Timeouts on API calls
  • [ ] Pinning considered for high-risk apps (+ cert rotation plan)

Platform

  • [ ] allowBackup decided intentionally (false for sensitive apps)
  • [ ] Deep links allowlisted; no dynamic navigate(params.screen)
  • [ ] WebView domain allowlist
  • [ ] Face ID / biometric usage strings present

Privacy & abuse

  • [ ] Sensitive screens consider FLAG_SECURE / privacy overlay
  • [ ] __DEV__-gated logs; crash reporter redaction
  • [ ] Minimal permissions; purpose strings accurate

Release engineering

  • [ ] R8/ProGuard (Android) enabled and tested
  • [ ] No production secrets in JS bundle
  • [ ] Lockfile committed; audit run
  • [ ] Signing keys only in CI / secure store

16. Interview-style Q&A

Q: Is AsyncStorage encrypted?

A: No. It is plain app storage. Fine for preferences; wrong for tokens.

Q: Does Hermes / minify make the app secure?

A: It raises the bar slightly. It does not protect API keys or tokens embedded in the client.

Q: Should every app use SSL pinning?

A: No. Use HTTPS everywhere; add pinning for higher threat models and be ready for pin updates.

Q: Is biometric login enough?

A: Biometrics gate local access to a stored secret. The server must still validate refresh/access tokens.

Q: Where do you put Firebase / Maps keys?

A: In the app if the SDK requires it — but restrict them by application id / SHA in the cloud console. Never put server admin keys in the app.

Q: What’s the first security fix you’d make on a messy RN codebase?

A: Move tokens out of AsyncStorage into Keychain, disable cleartext in release, wipe secrets from console.log, and allowlist deep links.


Conclusion

React Native security is mostly discipline: treat the client as hostile, put real secrets on the server, store session material in OS secure storage, force HTTPS, and stop leaking data through logs, backups, and sloppy deep links.

Takeaways

  1. No secrets in the JS bundle — only short-lived, revocable capabilities
  2. Keychain/Keystore for tokens; AsyncStorage for non-sensitive prefs
  3. HTTPS + careful cleartext/ATS config in release
  4. Biometrics protect local unlock, not server identity alone
  5. Allowlist deep links and WebView URLs
  6. Redact logs and enable release minify/hardening

Next steps

  1. Grep your app for AsyncStorage + token / password / Bearer
  2. Turn off cleartext + review allowBackup for release
  3. Add logout that truly clears secure storage
  4. Walk one deep link and one WebView through an allowlist review
  5. Add a CI audit step and secret scanning on PRs

Call to action

What’s the biggest security gap you’ve seen in a React Native app — tokens in AsyncStorage, cleartext HTTP, or logs leaking auth headers?

Comment below with your war story (redact secrets!). If this guide helped, share it with your team before the next release.

Want a follow-up deep dive? Tell me: SSL pinning in RN, OWASP MASVS mapped to RN, or secure biometric + PIN architecture.

Top comments (0)