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
- Threat model — what you are defending against
- What to store where (Keychain vs AsyncStorage)
- Auth, refresh tokens, biometrics
- Network security (TLS, pinning, cleartext)
- Deep links, intents, WebViews
- Reverse engineering & binary hardening
- Device integrity, privacy screens, logging
- Dependencies, CI secrets, ship checklist
Table of Contents
- Threat model
- Security decision map
- Secrets & secure storage
- Authentication & sessions
- Biometrics
- Network security
- Deep links, intents & WebViews
- Code protection & reverse engineering
- Device integrity & privacy
- Logging, analytics & PII
- Permissions & platform config
- Dependencies & supply chain
- CI / secrets hygiene
- Scenario walkthrough
- Ship checklist
- 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:
-
react-native-keychain -
expo-secure-store(Expo) - MMKV with encryption only if you manage the key correctly (key in Keychain, not hardcoded)
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;
On logout — always wipe
await Keychain.resetGenericPassword();
// also clear in-memory auth state / query cache
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});
.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}));
}
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
userIdwithout 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;
}
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:
- User logs in with password / OTP once
- Refresh token stored in Keychain with biometric access control
- 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);
}
Platform config
- iOS:
NSFaceIDUsageDescriptioninInfo.plist - Android:
USE_BIOMETRICpermission; prefer BiometricPrompt APIs via a maintained library
Don’t
- Store the password and “secure it” with a boolean
biometricsEnabledin AsyncStorage - Skip server refresh and trust a local
isAuthenticated: trueflag
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"
...>
Use a network security config if you need cleartext only for debug builds / emulators.
iOS — avoid blanket:
<key>NSAllowsArbitraryLoads</key>
<true/>
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);
}
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);
}
Rules
- Allowlist paths
- Never take a raw screen name from the URL
- Require auth for private screens after cold start
- Use
httpsapp 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
/>
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'
}
}
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
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
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});
}
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"
...>
<!-- Info.plist: Face ID purpose (required if you use Face ID) -->
<key>NSFaceIDUsageDescription</key>
<string>Unlock your account securely</string>
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
- Commit
yarn.lock/package-lock.json - Run
npm audit/yarn npm auditin CI (triage realistically) - Prefer maintained libraries with recent releases
- Minimize native modules — each is attack + maintenance surface
- Review postinstall scripts on new deps
- Use Dependabot / Renovate for security patches
yarn npm audit
npx react-native info # know your native surface
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:
-
*.keystorewith production keys -
.envwith 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
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
ResetPasswordwithout 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
- [ ]
allowBackupdecided intentionally (falsefor 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
- No secrets in the JS bundle — only short-lived, revocable capabilities
- Keychain/Keystore for tokens; AsyncStorage for non-sensitive prefs
- HTTPS + careful cleartext/ATS config in release
- Biometrics protect local unlock, not server identity alone
- Allowlist deep links and WebView URLs
- Redact logs and enable release minify/hardening
Next steps
- Grep your app for
AsyncStorage+token/password/Bearer - Turn off cleartext + review
allowBackupfor release - Add logout that truly clears secure storage
- Walk one deep link and one WebView through an allowlist review
- 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)