Every React Native security review I have sat through starts the same way. Someone opens the release APK in a zip tool, finds index.android.bundle, runs strings on it, and reads the API keys out loud. It takes about four minutes and it works on a surprising share of shipped apps. React Native security is not exotic. Most of it is remembering that the JavaScript bundle is a public document, the device is not your server, and the network between them belongs to whoever runs the Wi-Fi.
This is the checklist we run before a React Native app goes to the stores, pinned to react-native 0.87.1 and React 19.3 and the library versions on npm as of September 2026. It is organised by where the data lives: in the bundle, on the device, on the wire, in the session, and on the server you talk to. Skip the sections that do not apply. Do not skip the first one.
The React Native security model in one paragraph
Your app is three things: a JavaScript bundle that anyone can read, a native shell that runs with the permissions the user granted, and a set of HTTP calls to servers you may or may not control. Hermes compiles the bundle to bytecode, which makes it harder to read, not impossible. The OS sandbox protects your files from other apps, not from the phone’s owner. TLS protects the wire from a passive listener, not from a proxy the user installed on purpose. Every item below follows from one of those three facts.
1. Secrets: nothing in the bundle is secret
This is the whole list for a lot of apps. react-native-config 1.7.2 and react-native-dotenv 5.0.0 (released September 21) both inline values from a .env file at build time. That is convenient for switching API hosts between staging and production. It is not a secret store. The value ends up as a string literal in the bundle or in BuildConfig, and both are readable from the release artefact.
// .env
API_URL=https://api.example.com // fine, public by design
STRIPE_PUBLISHABLE_KEY=pk_live_... // fine, designed to be public
STRIPE_SECRET_KEY=sk_live_... // never. this ships to every user.
OPENAI_API_KEY=sk-... // never. you will get a bill.
The rule: if a key can spend money, read other users’ data, or sign anything, it does not go in the app. It goes behind an endpoint you own, and the app authenticates to that endpoint as a user. Third party SDK keys that the vendor designed as public (Stripe publishable, Google Maps browser keys, analytics write keys) are fine to ship but should be restricted in the vendor console to your bundle ID and package name, so a copied key is useless from anywhere else.
Check it the way an attacker would. Build a release, unzip it, and grep:
unzip -o app-release.apk -d out
strings out/assets/index.android.bundle | grep -iE 'sk_live|sk-|AKIA|BEGIN (RSA|EC) PRIVATE|password'
Hermes bytecode makes strings noisier, not empty. String constants survive compilation.
2. Storage: pick the store that matches the data
Three tiers, three libraries.
Tokens, refresh tokens, encryption keys. These go in the platform keystore: Keychain on iOS, Keystore backed EncryptedSharedPreferences on Android. Two current options. react-native-keychain 10.0.0 for bare projects, with ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY so the item does not migrate to a new phone via backup, and expo-secure-store 57.0.4 in Expo projects, which wraps the same primitives.
import * as Keychain from 'react-native-keychain';
await Keychain.setGenericPassword('session', refreshToken, {
service: 'com.example.app.session',
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
const creds = await Keychain.getGenericPassword({ service: 'com.example.app.session' });
Fast local state and caches. react-native-mmkv 4.3.2 is the right default and it supports an encryptionKey. The catch that trips people: the encryption key is itself a secret, so if you hardcode it you have encrypted nothing. Generate it once, store it in the keychain, and read it at startup.
import { createMMKV } from 'react-native-mmkv';
import * as Keychain from 'react-native-keychain';
import * as Crypto from 'expo-crypto';
async function openStore() {
let key = (await Keychain.getGenericPassword({ service: 'mmkv-key' }))?.password;
if (!key) {
key = Crypto.randomUUID().replace(/-/g, '').slice(0, 16);
await Keychain.setGenericPassword('mmkv', key, { service: 'mmkv-key' });
}
return createMMKV({ id: 'user', encryptionKey: key });
}
Everything else. @react-native-async-storage/async-storage 3.1.1 is plaintext on disk. It is fine for a theme preference. It is not fine for anything you would mind seeing in a screenshot, and as of Xcode 27 the Device Hub exposes the app’s data container with a click.
Also: do not log the token. console.log(response) during development has a way of surviving into release, and on Android, logcat is readable by anyone with a cable. Strip console calls in release with the Babel plugin or a Metro transformer, and never put PII in crash reporter breadcrumbs.
3. The wire: TLS is the floor, pinning is a choice
Both platforms refuse cleartext HTTP by default (iOS App Transport Security, Android usesCleartextTraffic=false from API 28). Do not turn that off for production. If a vendor still hands you an http:// URL in 2026, that is a vendor problem.
Certificate pinning stops a user installed proxy (Charles, mitmproxy, a corporate TLS inspector) from reading your traffic. It also bricks your app when the certificate rotates if you pinned the wrong thing. Pin the public key of an intermediate you control, keep a backup pin, and have a remote kill switch. On Android this needs no library at all:
<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-09-01">
<pin digest="SHA-256">primaryPinBase64==</pin>
<pin digest="SHA-256">backupPinBase64==</pin>
</pin-set>
</domain-config>
</network-security-config>
On iOS the equivalent is NSPinnedDomains in Info.plist, supported since iOS 14. The JavaScript level option, react-native-ssl-pinning, last published 1.6.0 in July 2025 and replaces fetch with its own client, which is a large dependency for something the OS does natively. Our view: pin at the OS level, and only if you have a threat model that includes a hostile proxy. Most consumer apps do not. Banking, healthcare and anything with a regulator usually do.
WebSockets ride on the same TLS, so wss:// inherits your pinning. The one thing they do not inherit is your auth header handling, which we cover in the WebSockets guide: send the token in the first message or a query parameter that the server scrubs from logs, never in a URL that ends up in an access log.
4. Sessions: short tokens, rotating refresh, PKCE
Long lived bearer tokens on a phone are a liability because phones get lost. The pattern that holds up: access tokens of 5 to 15 minutes, a refresh token in the keychain, refresh token rotation on the server so a stolen refresh token is single use and a reuse trips an alarm. If you use an identity provider, react-native-app-auth 8.4.1 does the OAuth 2.0 authorization code flow with PKCE through the system browser, which is the only flow the OAuth working group still recommends for native apps. Do not build a login WebView that captures the password field. Users cannot tell it from phishing, and neither can you.
Biometrics gate access to the refresh token, they do not replace it. expo-local-authentication 57.0.3 is current. react-native-biometrics 3.0.1 has not had a release since September 2022, so treat it as unmaintained. If you need a biometric bound key rather than a yes or no prompt, use the keychain access control flags (ACCESS_CONTROL.BIOMETRY_CURRENT_SET in react-native-keychain) so the token is only decryptable after the prompt succeeds.
5. Device integrity: know what you are actually buying
Root and jailbreak detection tells you the device might be compromised. It cannot tell you it is not. jail-monkey 3.0.0 (April 2026) gives you the basic checks, hooks detection and debugger flags. freerasp-react-native 5.2.1 (August 2026) goes further with runtime application self protection: tamper, repackaging, emulator, hook framework and screenshot detection, with a free tier that phones home. Use either to change behaviour (hide the balance, disable transfers, log the event), not to hard block, because false positives on custom ROMs and developer devices are real and every hard block generates a one star review from a legitimate user.
Cheaper wins in the same category: set FLAG_SECURE on Android activities that show sensitive screens so they are blank in the recents switcher and cannot be screenshotted, clear the clipboard after a paste of an OTP, and hide sensitive content in the iOS app switcher snapshot by rendering a cover view on AppState going inactive.
6. Deep links and WebViews: two doors people forget
Custom URL schemes (myapp://) can be claimed by any app on the device. Use Universal Links on iOS and App Links on Android, which require a file on your domain proving ownership, and treat every parameter that arrives through a link as untrusted input. A deep link that opens myapp://pay?to=...&amount=... and pre fills a form is a phishing vector. Confirm on screen, never auto submit.
WebViews are the other door. If you render third party content, keep javaScriptEnabled off unless you need it, never expose a bridge (onMessage and injectedJavaScript) to origins you do not control, and validate event.nativeEvent.url before acting on it. We have a full WebView post coming in this series, so I will stop there.
7. Updates and the supply chain
If you ship over the air JavaScript updates, sign them. expo-updates supports code signing so the runtime rejects a bundle not signed with your key, which turns “someone got into our CDN” from a total compromise into an outage. For everything else in node_modules: commit the lockfile, run npm audit or the equivalent in CI, and read the release notes of the framework you depend on. This week alone Next.js patched a remote code execution in next/og, and the React Native 0.87.1 release earlier this month bumped Metro past an image size dependency with a CVE. None of that helps if nobody is watching the feed.
Pin native dependency versions too. A caret range on a native module means your next clean install compiles code you have not reviewed, with whatever permissions it declares in its manifest. Diff the merged AndroidManifest.xml from a release build against last month’s and see what appeared.
8. Permissions and privacy manifests
Request permissions when the feature needs them, not at launch, and be able to explain each one in a sentence. iOS requires a privacy manifest (PrivacyInfo.xcprivacy) declaring required reason APIs, and the App Store now rejects binaries where a dependency uses one without declaring it. Both stores publish a data safety form based on what your app and its SDKs collect, and analytics SDKs collect more than you think. Audit the SDK list, not just your code.
9. The server is where the data actually lives
Everything above hardens the client. A determined attacker with a rooted phone gets past all of it eventually, which is why the server must never trust the app: validate every input again, enforce authorisation on every object (the app hiding a button is not access control), rate limit the endpoints the app calls, and keep secrets there.
Where that server runs starts to matter the moment a regulator is involved. Healthcare apps under HIPAA, anything under GDPR with EU residents, finance under whatever your jurisdiction calls it: the questions on the questionnaire are about data residency, encryption at rest, audit logs, retention and who at the vendor can read the data. This is the reason we built Ethora (our own product, disclosure) to run self hosted or on a dedicated server as well as in the cloud: when the chat backend sits in your own VPC you answer those questions yourself instead of forwarding them to a third party. The same logic applies to your auth server, your file storage and your analytics. Our HIPAA compliance guide goes through the questionnaire line by line.
The checklist
- No spendable or privileged secrets in
.env, the bundle, orBuildConfig. Verified by grepping a release build. - Public vendor keys restricted to bundle ID and package name in the vendor console.
- Tokens and keys in Keychain or Keystore (
react-native-keychain10.0.0 orexpo-secure-store57.0.4), device only accessibility. - MMKV encryption key generated at runtime and stored in the keychain, never hardcoded.
- No sensitive data in AsyncStorage, logs, or crash breadcrumbs. Console stripped in release.
- Cleartext traffic off. Pinning at the OS level if the threat model needs it, with a backup pin and an expiry.
- Short access tokens, rotating refresh tokens, PKCE via the system browser. No password WebViews.
- Biometrics protecting the refresh token, not standing in for it.
- Root and jailbreak detection used to degrade, not to block.
FLAG_SECUREand switcher cover on sensitive screens. Clipboard cleared after OTP paste.- Universal Links and App Links, not custom schemes. Link parameters treated as untrusted.
- WebView bridges only for origins you own.
- OTA updates code signed. Lockfile committed. Dependency advisories watched in CI.
- Permissions requested in context. Privacy manifest and data safety forms match the real SDK list.
- Server validates, authorises and rate limits every call regardless of what the client did.
FAQ
Does Hermes protect my code? It makes casual reading harder. Bytecode can be decompiled, and string constants are still visible. Treat obfuscation as a speed bump, not a lock.
Is Expo less secure than bare React Native? No. The same OS primitives are available through the Expo SDK packages, and Expo adds signed OTA updates and managed credentials. What changes is which package you install, not the security model.
Should I pin certificates? Only with a threat model that includes a hostile proxy and an operations plan for rotation. Pinning without a backup pin and a kill switch is an outage waiting for a certificate renewal.
What is the single highest value item on this list? Section 1. Pulling secrets out of the bundle and behind your own endpoint removes the attack that actually happens most, and it usually takes a day.
How often should I redo this? Every major dependency upgrade and every new SDK. Native dependencies bring permissions and network calls you did not write.