Cross-platform frameworks let one team ship to iOS and Android from a single codebase, but they also move security decisions out of the native platform guidance that most mobile checklists assume. A React Native app ships its logic as a JavaScript or Hermes bytecode bundle inside the package; a Flutter app ships compiled Dart in a native library; both reach the operating system through bridges and plugins written by third parties; and both are often updated over the air. This guide covers what is different about securing Flutter and React Native apps: where tokens and keys should live, what obfuscation does and does not buy, how bridges, WebViews and deep links widen the attack surface, how over-the-air updates must be signed, and how to detect and measure it. It is written for mobile leads, application security engineers and penetration testers; the general mobile picture is in the Mobile App Security Guide.
Key takeaways
- Everything in the bundle is public. Both the React Native and Flutter documentation say not to store secrets in the app; Hermes bytecode and Dart AOT code are readable with freely available tools, so any API secret shipped in the app must be treated as disclosed.
- Use the platform keystores, never plain key-value storage. React Native Async Storage and Flutter's shared_preferences are unencrypted; tokens belong in the iOS Keychain and Android Keystore through a maintained wrapper with explicit accessibility settings.
- Obfuscation is a speed bump. Flutter's own documentation states that
--obfuscaterenames symbols, does not encrypt resources and does not protect against reverse engineering. - Bridges and plugins are your new attack surface. Platform channels, native modules and WebView message handlers accept untrusted input and run with the app's full permissions; validate everything that crosses them.
- Over-the-air updates are code delivery. Sign them end to end and protect the signing key as you would a store release key, or an update channel becomes a remote code execution path.
How identity and access work in Flutter and React Native
Neither framework has its own identity layer. Sign-in, tokens and authorisation decisions are implemented by the app team and the backend, which is exactly why they go wrong. The safe pattern is the same as for native apps: OAuth 2.0 authorisation code flow with PKCE in the system browser, short-lived access tokens, a refresh token held in hardware-backed storage, and every authorisation decision made by the server.
- OAuth in the system browser: React Native's security page recommends PKCE and points to react-native-app-auth, which wraps the AppAuth-iOS and AppAuth-Android libraries; Flutter teams typically use a plugin that drives
ASWebAuthenticationSessionand Custom Tabs. Never sign users in through an embedded WebView you control. See the OAuth for Native and Mobile Apps Guide. - Redirects and deep links: the React Native documentation warns that any app can register the same custom URL scheme and that iOS gives the user no choice when two apps claim one, so tokens must never travel in deep links. Prefer Universal Links and Android App Links for redirects. See the Mobile Deep Link and App Link Security Guide.
- Local authentication: biometrics should unlock a key in the keystore (a cryptographic binding), not flip a boolean in Dart or JavaScript that a hooking tool can patch. Expo SecureStore's
requireAuthenticationoption, for example, ties the value to device authentication, and a new enrolled fingerprint or face invalidates it. - Authorisation stays on the server: hiding a button in the UI is not access control. Treat every API call as if it came from a modified client.
Where to store what
| Storage option | Framework | Encrypted at rest | Use for |
|---|---|---|---|
Async Storage (@react-native-async-storage/async-storage) | React Native | No | Non-sensitive preferences and cached state only |
shared_preferences / NSUserDefaults / SharedPreferences | Both | No | Non-sensitive settings |
| iOS Keychain (via react-native-keychain, expo-secure-store, flutter_secure_storage) | Both | Yes, hardware-backed keys | Refresh tokens, session secrets, private keys |
| Android Keystore with encrypted preferences (same wrappers) | Both | Yes, key in Keystore | Refresh tokens, session secrets |
| In-memory only | Both | Not persisted | Access tokens, PKCE verifier, decrypted data |
| SQLite, Realm, Hive and similar databases | Both | Only if you add encryption (for example SQLCipher) with a key from the keystore | Offline data that must be protected |
Set accessibility deliberately. Expo SecureStore defaults to WHEN_UNLOCKED; WHEN_UNLOCKED_THIS_DEVICE_ONLY stops a value migrating to a new device through a backup, which is usually right for refresh tokens. On Android, exclude secure-store preferences from Auto Backup, because restored entries cannot be decrypted once the Keystore key is gone (Expo does this automatically unless you supply a custom backup configuration). Wrapper libraries are community code: the React Native documentation says to use them at your own risk, so pin versions and review them as you would any security dependency.
Admin roles and privileged access
In a mobile app the privileged access that matters is less about in-app admin screens and more about who can change what runs on users' phones.
- Hidden admin and debug features: developer menus, feature-flag overrides, staging environment switchers and "god mode" accounts compiled into release builds are found quickly by anyone who unpacks the bundle. Strip them at build time (Dart
kReleaseModeor--dart-defineconstants; JavaScript__DEV__checks that the bundler removes), and enforce admin capability on the server. - Store and signing accounts: App Store Connect, Google Play Console and the app signing keys can push a new binary to every user. Require phishing-resistant MFA, least-privilege roles and Play App Signing or equivalent so the upload key can be reset if lost. See the Code Signing Security Guide.
- Over-the-air update channels: services such as EAS Update deliver new JavaScript without a store review. Expo's end-to-end code signing (RSA with SHA-256,
rsa-v1_5-sha256) embeds a certificate in the native build, signs updates locally with a private key that should live in a KMS or password manager, and makes the client reject any update whose signature is invalid. Expo documents it as available on its Production and Enterprise plans; whichever OTA service you use, confirm it supports client-side signature verification. Flutter has no first-party OTA mechanism; third-party code-push services for Flutter need the same scrutiny. - CI/CD identities: the pipeline that builds, signs and uploads releases holds the most powerful credentials in the mobile estate. See the CI/CD Pipeline Identity Security Guide.
Integrations, API tokens and service accounts
Cross-platform apps tend to accumulate third-party SDK keys: maps, analytics, crash reporting, payments, push, Firebase and AI APIs. The research record is clear. Cybernews found at least one hard-coded secret in 71 percent of 156,080 iOS apps it analysed; see the iOS Apps Leaking Hard-Coded Secrets 2025 page. Symantec found hard-coded AWS credentials in 1,859 apps, including one identity SDK whose embedded keys were reused across five banking apps; see Mobile Apps Hard-Coded AWS Keys 2022.
| Key type | Safe in the app? | Required handling |
|---|---|---|
| Publishable or client keys designed to be public (Stripe publishable key, Firebase web config, maps keys) | Yes, if restricted | Restrict by app package, bundle ID and signing certificate, by API and by quota; pair with app attestation where offered |
| Secret API keys (payment secret keys, LLM API keys, cloud access keys) | No | Move behind your backend or a serverless proxy that holds the key and authenticates the user, as the React Native documentation recommends |
react-native-config / .env values and Dart --dart-define values | Only non-secrets | Compiled into the bundle; React Native's documentation states these tools are for environment-specific values, not secrets |
| Per-user tokens | Yes, in the keystore | Short-lived access token in memory, rotated refresh token in Keychain or Keystore |
- Backend for the mobile app: a thin backend that holds partner secrets and calls third-party APIs on the user's behalf is the only way to keep a secret secret. Rate limit it per user so it does not become a free proxy.
- App attestation: Apple App Attest and the Google Play Integrity API let the backend check that a request comes from your unmodified app on a genuine device. Use attestation to raise cost and reduce abuse, never as authentication of the user. Firebase App Check wraps both for Firebase services; see the Firebase Credentials Security Guide.
- Do not leak to telemetry: the React Native documentation warns specifically against sending tokens and personal data to crash and monitoring services and against persisting a whole Redux state tree that contains sensitive fields. Scrub breadcrumbs and network logs in Sentry, Crashlytics and similar SDKs.
Bridges, plugins and WebViews
- Flutter platform channels:
MethodChannelandBasicMessageChannelpass messages between Dart and native code using codecs such asStandardMessageCodec; Flutter's documentation describesBasicMessageChannelas not type safe. Generate channels with Pigeon for typed interfaces, and validate arguments on the native side as you would an API. - React Native native modules: every method exposed to JavaScript is callable by any JavaScript that runs in the app, including code from a compromised dependency or an injected OTA update. Expose narrow, purpose-built methods (sign this payload) rather than generic ones (read any file).
- WebViews: react-native-webview defaults
originWhitelistto any http or https origin andjavaScriptEnabledto true. Restrict origins, keepallowFileAccess,allowFileAccessFromFileURLsandallowUniversalAccessFromFileURLsfalse, keepmixedContentModeatnever, do not setsetSupportMultipleWindowsto false (documented as exposing CVE-2020-6506), and treatonMessagedata as untrusted strings.onShouldStartLoadWithRequestis not called for the first load on Android, so it cannot be the only navigation check. See the Mobile WebView Security Guide. - Plugin supply chain: pub.dev and npm packages bring native code with them. Pin versions with
pubspec.lockandpackage-lock.json, review native permissions a plugin adds toAndroidManifest.xmlandInfo.plist, and watch for install scripts in npm dependencies. See the OWASP Mobile Top 10 M2 Inadequate Supply Chain Security Guide.
Logging and detection
Mobile apps run on devices you do not control, so most detection happens at the backend and in the build pipeline.
| Signal | Where to collect it | What it indicates |
|---|---|---|
| Attestation failures and verdict trends | Backend logs of App Attest and Play Integrity checks | Repackaged or tampered clients, emulator farms |
| Use of restricted client keys from unexpected packages or IPs | Cloud and SDK provider consoles | Key extracted from the app and reused |
| Secrets found in release artefacts | CI scan of the APK, AAB and IPA (bundle, libapp.so, assets) | A secret about to ship |
| Refresh token reuse after rotation | Authorisation server | Token copied from a device |
| OTA updates published outside the release process | Update service audit log | Compromised publishing credential |
| Crash reports containing tokens or personal data | Crash and monitoring SDK data scans | Logging leak (MASVS-STORAGE-2) |
Metrics:
- Secrets detected per release build (target zero, gate the pipeline on it).
- Percentage of sensitive values stored through a keystore wrapper rather than plain storage, from code review or static analysis.
- Share of API traffic carrying a valid attestation verdict.
- Age of the Flutter SDK or React Native version in production against the current stable release.
- Percentage of OTA updates verified by client-side signature checks (target 100).
Hardening baseline
- Keep the framework current: Flutter publishes security fixes for the current stable branch only and advises keeping the SDK and dependencies updated rather than running private forks; React Native fixes likewise land in supported releases. Budget for regular upgrades (MASVS-CODE-3).
- Release build settings: Flutter
flutter build apk --obfuscate --split-debug-info=<dir>(supported for Android, iOS and desktop targets, not web, where release builds are minified instead), keeping the symbol files private forflutter symbolize; React Native release builds with Hermes bytecode, R8 or ProGuard for the Android native layer, and no source maps shipped in the package. - Network: HTTPS only, enforced by Android Network Security Configuration (
cleartextTrafficPermitted="false") and iOS App Transport Security without broad exceptions. Pin only if you can rotate: the React Native documentation notes that pinned certificates expire and old app versions stop working. Pinning to a public key with a backup pin reduces that risk (MASVS-NETWORK-2). - Storage: keystore-backed wrappers with explicit accessibility, encrypted local databases, backup exclusions, no sensitive data in logs or in persisted state stores.
- Runtime protection where the risk justifies it: root and jailbreak detection, hooking and debugger detection, and integrity checks raise the cost of attack (MASVS-RESILIENCE). They are bypassable; use them for high-value apps such as banking and keep the real controls server-side.
Rolling it out
- Scan what you ship: unpack the current production APK, AAB and IPA, extract the bundle or Dart library strings and run secret scanning. Rotate anything found before fixing the code.
- Move secrets server-side: introduce the backend proxy for secret keys and restrict every remaining public key by package and signing certificate.
- Migrate storage: move tokens from Async Storage or shared_preferences to a keystore wrapper. Ship a migration that reads the old value once, writes it securely and deletes it; roll back by keeping the read path for one release.
- Fix sign-in: move to system-browser OAuth with PKCE and claimed HTTPS redirects; pilot with a percentage rollout and monitor sign-in success.
- Sign OTA updates, then rotate the update signing key into a KMS and remove publishing rights from individual developers.
- Add attestation in monitor mode, log verdicts for several weeks to measure false positives, then enforce on high-risk endpoints first.
- Gate CI: secret scanning, dependency audit, and a MASVS-based test pass before each store release. See the Mobile App Security Testing Guide.
Decision guidance: choose Flutter or React Native on product grounds; neither is meaningfully more secure. The security differences are operational: React Native teams must govern a large npm dependency tree and, often, OTA updates; Flutter teams must govern native plugin code and track a fast-moving SDK. Small teams should prefer managed, well-maintained wrapper libraries and the platform defaults; regulated apps (banking, health) should add attestation, runtime protection and an independent MASVS L2 assessment.
Framework mapping
| Control area | OWASP MASVS | NIST SP 800-53 Rev. 5 | MITRE ATT&CK (Mobile) |
|---|---|---|---|
| Keystore-backed token storage | MASVS-STORAGE-1, MASVS-CRYPTO-2 | SC-28, IA-5 | T1409 Stored Application Data |
| System-browser OAuth, claimed redirects | MASVS-AUTH-1, MASVS-PLATFORM-1 | IA-2 | T1635.001 URI Hijacking |
| WebView and bridge hardening | MASVS-PLATFORM-2, MASVS-CODE-4 | SI-10 | n/a |
| Signed OTA updates and store releases | MASVS-CODE-2 | CM-14, SI-7 | T1407 Download New Code at Runtime |
| Dependency and plugin governance | MASVS-CODE-3 | SR-3, SR-4 | T1474 Supply Chain Compromise |
| Transport security and pinning | MASVS-NETWORK-1, MASVS-NETWORK-2 | SC-8 | n/a |
Common misconfigurations
- Secret keys in
.env: a payment or LLM API secret placed inreact-native-configor a--dart-definevalue ends up as a plain string in the release bundle. - Tokens in Async Storage or shared_preferences: unencrypted on disk and included in device backups; the most common finding in cross-platform app assessments.
- Treating obfuscation as protection: relying on
--obfuscateor Hermes bytecode to hide keys or business logic; neither framework claims that. - Login in an embedded WebView: the app can read the user's credentials, single sign-on cookies are not shared, and many identity providers block it.
- Open WebView configuration: default
originWhitelist, file access enabled for a local HTML feature, and anonMessagehandler that calls a powerful native module. - Unsigned OTA updates with a shared publishing token: one leaked CI token can push code to every installed app without a store review.
Practitioner checklist
- Scan every release artefact for secrets and fail the build on findings.
- Move secret API keys behind a backend; restrict publishable keys by package, bundle ID and certificate.
- Store refresh tokens in Keychain or Keystore through a pinned, reviewed wrapper with
THIS_DEVICE_ONLYaccessibility where suitable. - Keep access tokens and PKCE verifiers in memory only.
- Use system-browser OAuth with PKCE and Universal Links or App Links for redirects.
- Harden WebViews: restricted origins, no file access, untrusted
onMessagehandling. - Validate every platform channel and native module input; generate channels with Pigeon.
- Sign OTA updates end to end and hold the key in a KMS.
- Build Flutter releases with
--obfuscate --split-debug-infoand keep symbols private, without treating it as a control. - Track framework and plugin versions against current stable and upgrade on a schedule.
Standards and references
- OWASP: Mobile Application Security Verification Standard
- React Native: Security
- Flutter: Obfuscate Dart code
- Flutter: Security
- Flutter: Writing custom platform-specific code (platform channels)
- Expo: SecureStore
- Expo: End-to-end code signing with EAS Update
- react-native-webview: API Reference
Related NHI Mgmt Group resources: Mobile App Security Guide · OWASP Mobile Top 10 M9 Insecure Data Storage Guide · OWASP Mobile Top 10 M1 Improper Credential Usage Guide · API Key Management Guide