Mobile App Security: A Practical Guide for React Native
Ship React Native apps that resist real attacks. A practical mobile app security guide covering auth, storage, networking, secrets, and release hardening.

You've got a React Native app that's already in TestFlight or close to launch, the Expo build is green, and the next message in Slack is probably either a weird login complaint, a security questionnaire, or a panic about a key that showed up somewhere it shouldn't. That's the moment many teams realize mobile app security isn't a policy document, it's release engineering with consequences. For Expo and React Native teams, the primary question isn't whether to secure the app, it's which parts of the stack are exposed, and which shortcuts will come back during the next incident review.
Table of Contents
- The Wake-Up Call Most Teams Get About Mobile App Security
- Authentication That Survives a Stolen Phone
- Choosing Where Sensitive Data Lives
- Secrets, API Keys, and What Never Belongs in a JS Bundle
- Dependency and Code Scanning Before Each Release
- Release Hardening, Obfuscation, and Runtime Integrity
- Monitoring in Production Without Leaking Your Users
The Wake-Up Call Most Teams Get About Mobile App Security
An infographic showing three common wake-up calls for teams regarding mobile app security and lessons learned.
The first real lesson usually doesn't come from a security conference. It shows up as a Slack message that says a customer account looks wrong, a late-night email from a researcher, or an App Store rejection that turns a launch into a fire drill. That's when the team discovers that mobile app security was never just about malware, it was about credentials, stored data, SDKs, release channels, and everything that ships inside the bundle.
For Expo and React Native teams, the hard part is that the app is only one layer. The JS bundle, native modules, third-party SDKs, EAS Update channels, and backend APIs all change the attack surface in different ways. If you treat the app as a single binary, you miss the places attackers usually go first, especially when they don't need to break the UI at all.
Practical rule: model the app as a living system, not a file you ship once.
A fast way to start is a 90-minute threat model on one page. List the assets, like auth tokens, user PII, API keys, and in-app purchase receipts, then write the threats beside them, like token theft, MITM, repackaged APKs, and SDK data exfiltration. Finish each row with the control that reduces the risk, not a generic “secure it” note.
OWASP's mobile app security essentials are a useful companion here because they reinforce the same practical mindset, secure-by-default design, least privilege, defense in depth, and secure backend communication. The important part is doing this before the next release, not after a researcher or user points out the gap.
A simple table works well for small teams because it forces prioritization. If you only have time to test one thing, start with the assets that create account takeover or backend abuse, then move outward to SDKs, storage, and runtime tampering. That matches how real attacks usually happen, one exposed layer at a time.
The biggest trap is assuming your app is “covered” because you ran a single scan or uploaded a build to TestFlight. Independent coverage has warned that as much as 95% of a mobile application's actual attack surface may remain untested when teams rely on one-off checks, which is why coverage strategy matters more than having a long checklist. When you're choosing where to spend limited engineering time, the attack surface inventory is the work that keeps the rest of the effort honest.
Authentication That Survives a Stolen Phone
A lost phone is where weak auth setups fall apart. On Expo and React Native, the pattern that holds up in production is still the plain one, Authorization Code with PKCE, short-lived access tokens, and refresh tokens that can be rotated and revoked. Long-lived tokens that stay valid after device loss, rooting, or shared access create too much exposure for mobile apps that store meaningful user data.
For sensitive screens, a biometric prompt through expo-local-authentication adds a useful second check, but it does not replace server-side session control. If logout only clears local storage, the backend may still accept the session, which leaves room for access through another device, another app path, or an intercepted refresh flow. The server has to invalidate the session as part of logout.
Practical rule: local logout alone is insufficient, the server must also invalidate the session.
A lightweight flow is enough for the majority of consumer apps:
- Exchange the auth code for tokens on the backend.
- Store the access token in
expo-secure-store. - Keep the refresh token rotated and revocable.
- Retry once on refresh failure, then force re-authentication.
- Protect high-risk screens with biometric verification if the risk justifies the friction.
That pattern fits Expo well because expo-secure-store maps to platform secure storage instead of leaving secrets in plain app storage. It is a sensible default for token handling in apps that ship to both app stores and web, where the same JavaScript bundle has to behave safely across different device models and threat levels. AsyncStorage is the wrong place for secrets, because it is built for convenience, not for keeping token material safe when someone inspects the device, the backup, or the app logic later.
A good companion reference on the mechanics of auth methods is AppLighter's user authentication methods guide, especially if you are comparing password login, OTP, and token-based flows in a React Native stack. For regulated environments, Titanium Computing's discussion of multi factor authentication for healthcare is also useful context, because it shows why a second factor matters when the data behind the login is sensitive.
The cleaner mental model is to treat auth as a session lifecycle, not a login form. The login screen is the entry point. The security work happens across token issuance, refresh, revocation, and the controls that make stolen devices less useful.
Choosing Where Sensitive Data Lives
A React Native app usually breaks security in storage, not in the database choice itself. The central question is where the sensitive data lived, how long it stayed there, and what happens when a device is rooted, backed up, or inspected after the fact.
| Option | Backend | Best for | Key gotcha |
|---|---|---|---|
expo-secure-store | iOS Keychain, Android encrypted storage | Intended for tokens and small secrets, session metadata | Large structured data does not belong here |
| MMKV with encryption | App-managed key | Fast cached data that needs encryption | You own key management |
AsyncStorage | Plain app storage | Intended for UI preferences and flags, non-sensitive cache | Secrets do not belong here |
| SQLite with SQLCipher | Encrypted local database | Structured sensitive records | Adds native build and maintenance overhead |
For Expo teams, expo-secure-store is usually the cleanest starting point because it fits token storage without forcing a native rewrite. MMKV makes sense when you need fast reads and encrypted local state, but the encryption key becomes a security object you still have to protect elsewhere. SQLite with SQLCipher is the better fit when the app needs structured encrypted data and the team can absorb the build and maintenance cost, especially in apps that already rely on local persistence.
On iOS, Keychain access groups matter if the app shares data with a Share Extension or another app in the same family. On Android, hardware-backed protection like StrongBox is useful when it exists, but it should stay an enhancement rather than the foundation of your storage model because device support varies. The safer pattern is to design for the lowest common denominator and treat hardware-backed keys as a bonus layer.
Network security needs the same level of discipline. Use TLS 1.2 or newer, disable cleartext HTTP, and keep ATS enabled on iOS. Certificate pinning can be worth the operational cost for high-risk apps, but only if the rotation plan is already in place before the backend changes certificates, CDNs, or issuers. Without that plan, the pin becomes a self-inflicted outage.
Expo adds a few sharp edges here because dev and production builds do not share the same assumptions. Metro connections, dev clients, and release binaries each behave differently, so permissive local settings must stay out of release config. Teams that blur that line usually discover the mistake only after shipping, when the production app starts acting like a development build.
The same storage question shows up outside the phone as well. A useful read on remote offboarding security risks is worth keeping in mind, because stolen devices and returned hardware force the same basic review of where the sensitive data lived. If it was spread across local storage without strong controls, the answer is usually too many places.
For backend-facing APIs, AppLighter's API security best practices guide fits this discussion too, because app secrets and API exposure often fail together. A mobile client should authenticate as a client, not serve as the root of trust for the backend.
Secrets, API Keys, and What Never Belongs in a JS Bundle
If it ends up in the JS bundle, assume it's public. That's the rule that saves teams from shipping Stripe keys, Firebase service account fragments, and AI endpoints with too much privilege. In Expo, the confusion usually comes from mixing runtime config with build-time secrets and assuming process.env will behave the way it does on the server.
EAS Secrets stay on the server side and never ship to the device. Runtime config and app.json extras are readable in the built artifact, so anything sensitive there is effectively exposed. If a value needs to stay secret, it cannot be embedded in the bundle, and it shouldn't be reachable from code that's packaged for the app store.
The practical setup is simple enough for the majority of teams.
- Keep development, staging, and production builds separate.
- Inject secrets through EAS, not through checked-in config.
- Rotate leaked keys immediately and treat the old key as compromised.
- Inspect the IPA or APK for strings that should never appear there.
Anything your app can read on the client can be extracted by someone else eventually.
For backend-facing APIs, AppLighter's API security best practices guide pairs well with this mindset, because app secrets and API exposure usually fail together. A mobile client should authenticate as a client, not as a root-of-trust for the whole backend.
The right storage choice also matters here. If you're thinking about tokens or local secrets, expo-secure-store is the safer default, while AsyncStorage should stay limited to preferences and harmless cache. MMKV with encryption is fast, but only if you're disciplined about the encryption key, and SQLite with SQLCipher makes sense when the data structure justifies the extra native work.
A fast way to sanity-check a release is to inspect the build artifact and look for hardcoded credentials, debug URLs, or SDK keys that should have stayed server-side. That check won't prove the bundle is perfect, but it catches the embarrassing failures before users do.
Dependency and Code Scanning Before Each Release
Supply-chain problems rarely arrive as one dramatic exploit. They creep in through a package update, a forgotten transitive dependency, or a native rebuild that picks up something you didn't mean to ship. That's why release-time scanning matters even when the app code itself hasn't changed much.
npm audit is still useful, but only as a first pass. It flags known CVEs in JavaScript dependencies, yet it doesn't understand the full mobile stack, especially native modules and what happens after expo prebuild. Dependabot and Snyk are the two practical tools teams can keep running continuously without turning the process into a side project.
The Expo-specific habit that helps most is running expo prebuild --clean before a release when native folders are part of the flow. It forces the native output to regenerate from current package versions instead of carrying old assumptions forward. That matters when you're relying on managed and prebuilt code at the same time.
An infographic titled Dependency and Code Scanning illustrating five essential security steps for software development releases.
A release gate should cover five checks:
- Run
npm auditearly. Catch obvious package issues before they reach build time. - Understand its limitations. It won't tell you enough about native or runtime exposure.
- Use a deeper scanner. Dependabot or Snyk gives you ongoing visibility.
- Update dependencies regularly. Waiting until the next incident creates too much drift.
- Automate scanning in CI. A bad dependency should block the release, not TestFlight.
For hardcoded secrets, scanning the built IPA or APK is the faster reality check. Tools like MobSF help, and a simple strings plus grep pass is often enough to catch obvious mistakes when you're moving quickly. The important part is making the build fail when a secret appears, because warnings that only exist in logs get ignored once the team is busy.
Code scanning is also where teams should remember that security isn't just npm packages. Mobile apps pull in native frameworks, permission handlers, analytics SDKs, and web bridges, all of which can expand the attack surface in ways that don't show up in a normal JavaScript audit.
Release Hardening, Obfuscation, and Runtime Integrity
Once the app is built, the question shifts from who can read the source to how much they can infer from the release. Hermes helps because its bytecode is harder to inspect than plain JavaScript, and Expo ships with it by default. Hermes doesn't make the app invisible. It raises the effort required for casual inspection.
A sensible hardening stack stays layered. Minify production bundles, strip dev-only assertions, and use Android build hardening through ProGuard or R8 when your project setup supports it. If the app handles credentials, money movement, or regulated data, commercial tools like DexGuard or iXGuard can make sense, but they are hard to justify for every consumer app.
Runtime integrity is the layer that catches curious users with tooling. Jailbreak and root detection, emulator detection, and signature verification all help when the backend should only trust genuine app builds. Repackaged APKs are a real problem because the server cannot assume the client binary is authentic unless it checks.
EAS Update needs the same discipline. Runtime version policy protects users from incompatible updates, and signing key management prevents a compromised channel from pushing malicious code. The update system can feel like a convenience feature, but it becomes an attack vector when signing keys are compromised.
Practical rule: obfuscation slows attackers, runtime integrity makes tampering expensive, and signature checks keep the backend from trusting the wrong client.
The release question still comes back to secrets. process.env is empty in the release bundle unless you explicitly wire values through the build system, so per-environment keys have to live outside the JS payload. That applies whether you are doing a dev client, staging build, or production release.
If you want a hands-on look at the testing side of this, AppLighter's app penetration testing guide is a useful adjacent reference. For Expo teams, hardening only matters when it is paired with checks that prove the shipped binary behaves like the one you intended.
Monitoring in Production Without Leaking Your Users
The first production security problem usually shows up in telemetry, not in a lab. A device starts failing login repeatedly, a backend sees strange 4xx patterns, or the crash reports point at a screen that should have been stable. The hard part is getting visibility without turning your observability layer into another data leak.
Sentry is the default choice for many React Native teams because it fits the workflow well, but it has to be configured carefully. PII scrubbing should be on by default, and auth tokens, biometric prompts, and raw request bodies should never land in logs just because a debug helper makes it easy. Source maps can be uploaded through build hooks, which keeps stack traces readable without exposing the actual bundle in the wild.
A strong production posture comes from discipline and restraint.
- Configure Sentry with PII scrubbing. Keep error reports useful without leaking user data.
- Log auth events server-side. Device compromise shouldn't erase your audit trail.
- Watch for unusual behavior. Failed logins from one device, repeated 4xxs, and strange navigation loops deserve attention.
- Define consent boundaries clearly. Users should know what telemetry exists and why.
The logs that matter are the ones tied to state changes, not everything a screen renders. If the app logs a token, a password field, or raw personal data, observability has turned into a second copy of the breach. Keep the event trail small, structured, and boring.
A short pre-ship checklist helps small teams split the work cleanly. Auth should verify PKCE, token rotation, and logout revocation. Storage should confirm that secrets live in secure storage, not plain app state. Networking should check TLS, pinning policy, and release-only behavior. Secrets should be absent from the bundle. Dependencies should pass a scan. Hardening should include runtime integrity checks where the risk justifies them.
Revisit the threat model at every major release, because the attack surface changes the moment you add a new SDK, an AI integration, or a new update channel.
AppLighter gives Expo teams a production-ready starter kit with authentication, navigation, state management, and edge-ready API wiring, which makes it easier to start from a secure baseline instead of retrofitting one later. If you are building a React Native app and want a cleaner path from prototype to release, visit AppLighter and see how it fits your stack.