A production React Native application was recently subjected to a complete, authorized reverse engineering engagement. Its entire API authentication scheme was reconstructed from a running app: the request flow was captured at the JavaScript-to-native bridge, the credential that actually mattered was pulled from Android's cookie store, and the production API was reached from a standalone client.
The full methodology is documented in the companion post. Notably, none of it required decompiling the JavaScript bundle — the app's Hermes bytecode defeated every available decompiler, and it did not matter. This post addresses the other side of that research: what you can actually do to defend against it.
The honest answer, the same one we gave in the Flutter hardening post, is that no mobile app can be made unbreakable. Any code that runs on hardware your users control can, in principle, be analyzed. The goal of hardening is not impossibility — it is to raise the cost of analysis high enough that most attackers stop, and to ensure that when the client is compromised, the blast radius is small. For React Native specifically, that means being honest about one thing above all: your JavaScript is not the boundary you think it is.
What the Research Revealed About React Native's Default Security Posture
Before discussing defenses, it helps to be specific about what React Native provides by default and where it falls short.
What Hermes bytecode actually protects:
- The JavaScript bundle ships as compiled Hermes bytecode, not readable source, so there is no
index.android.bundleyou can open in a text editor and read - Newer Hermes bytecode versions outrun the public decompilers — in this engagement, bytecode version 96 defeated
hbctool(max version 90) and other tools outright - Casual "unzip the APK and read the app" analysis is genuinely blocked
What Hermes bytecode does not protect:
- Every
fetch()in the app funnels through a single Java method,com.facebook.react.modules.network.NetworkingModule.sendRequest. Hooking that one method exposes every request — method, URL, query parameters, headers — in plaintext, with no JavaScript decompilation required. - The hook is framework-level, so it does not care which HTTP client the app uses under the hood.
- Anything the JavaScript computes and then sends is visible at the bridge, after the JS runs and before it is encrypted. Obscuring the JS buys you nothing here.
What "the app used HTTPS" does not protect:
- The app relied on Android's internal networking (
com.android.okhttp) and native cookie handling. The decisive credential — a long-lived refresh cookie — never appeared in the JavaScript headers and was pulled directly fromandroid.webkit.CookieManagerat runtime. - Requests used
withCredentials: true, meaning cookies were attached natively. TLS interception would have shown the request without revealing where that credential lived.
Understanding these distinctions prevents the two most common React Native security mistakes: believing Hermes hides your logic, and believing HTTPS hides your credentials.
1. Treat the JS Bridge as Public — Because It Is
The single most important mental shift for React Native security is this: anything reachable from your JavaScript is reachable at the native bridge, and the bridge is trivially hookable.
Do not design security around the assumption that Hermes hides your request-building logic. It does not. This means:
- No secrets computed in JavaScript. Any HMAC key, signing secret, or API key that your JS uses to build a request is visible at
NetworkingModule.sendRequestthe moment the request is constructed. Client-side request signing in React Native provides essentially no protection. - No security logic that only runs client-side. Feature gating, entitlement checks, and "is this user allowed" decisions made in JS can be observed and replicated. They must be enforced on the server.
- Assume every endpoint your app can call is public knowledge. Within minutes of hooking the bridge, an analyst has a complete map of your API surface, including undocumented and internal endpoints.
This is the React Native analogue of the lesson from the Flutter engagement, where embedded private keys were extracted from the binary. The framework differs; the principle is identical — the client cannot keep a secret.
2. Fix Broken Object-Level Authorization on the API
The most serious finding was not a client weakness at all. Once a valid session was obtained, the member-directory endpoint accepted a caller-controlled offset and limit, applied no meaningful rate limiting, and returned records with no per-object authorization check beyond "you hold a valid session." A single authenticated user could page through the entire directory from a script.
This is Broken Object-Level Authorization (BOLA) — number one on the OWASP API Security Top 10 — combined with unrestricted resource consumption. No amount of client hardening fixes it, because the attacker is using a legitimate session.
Defenses that live on the server:
- Enforce authorization per object and per record, not per session. Returning a bulk list is a decision; make sure the caller is entitled to every record in it, not merely authenticated.
- Rate-limit and quota bulk/list endpoints aggressively. Directory-style endpoints that return personal data should have per-user caps far below "walk the entire dataset in an afternoon."
- Bound pagination. Cap
limit, reject absurdoffsetvalues, and consider cursor-based pagination that is harder to enumerate blindly. - Detect enumeration. Sequential, high-volume, evenly paced list requests are a signature. Alert and throttle on it.
- Minimize fields. A directory endpoint should return only the fields the UI actually renders, not every attribute in the record.
If your mobile app exposes personal data through a list endpoint, this control matters more than every other item on this list combined.
3. Implement Certificate Pinning — at the Native Layer
The researched app had no certificate pinning: no CertificatePinner in code and no custom network-security configuration. Pinning would not have stopped the bridge hook (that operates above TLS), but it defends against a different and common attacker — one who controls a network path but cannot run code on the device.
Two things matter for React Native on modern Android:
targetSdkVersion 35does not trust user-installed CA certificates by default. That is a real, free protection against casualmitmproxysetups — but it is not pinning, and it is undone the moment an attacker can modify the app or the device trust store.- Configure a Network Security Config with an SPKI pin set, and pin against the public-key hash so certificate renewals do not break the app:
<network-security-config>
<domain-config>
<domain includeSubdomains="true">your-api.example.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">PRIMARY_SPKI_HASH_BASE64</pin>
<pin digest="SHA-256">BACKUP_SPKI_HASH_BASE64</pin>
</pin-set>
</domain-config>
</network-security-config>
Because this is enforced at the Android manifest/native layer, it is harder to defeat from the JavaScript side than a JS-level fetch wrapper. For teams that want pinning inside the app logic, libraries such as react-native-ssl-pinning move validation into native code as well. Configuration alone is not enough, though — the app binary and its config are part of what an attacker can repackage, which is why the next item exists.
4. Detect Repackaging, Instrumentation, and Root
The engagement depended on injecting a Frida Gadget into the APK, re-signing it with a debug key, and installing the repackaged splits. Every one of those steps is detectable.
- Signature and integrity verification. A repackaged app is re-signed with a different key. Check your signing certificate's hash at runtime, and — more importantly — verify it server-side via attestation so the check cannot simply be patched out. The app whose base APK is untouched but whose native split is swapped will still fail an integrity attestation of its full package.
- Play Integrity API. This is the highest-leverage server-side control. The app requests an attestation token; your server validates it against Google's API before issuing or refreshing credentials. A gadget-injected, debug-signed build fails attestation, so the modified app never gets a working session.
- Frida / instrumentation detection. Check for known gadget artifacts — default listener ports, injected library names in the loaded-module list, suspicious memory regions. It is bypassable, but it raises cost.
- Root detection. Libraries such as RootBeer catch common indicators. Treat it as one layer, not a wall.
The caveat from the Flutter post applies unchanged: on-device detections can be hooked and neutralized. Their value is that the server-verified ones (Play Integrity, signature attestation) cannot be faked without compromising the attestation service itself. Put the trust boundary on your server.
5. Shorten and Bind the Refresh Token
The credential that turned a single login into days of API access was a refresh-token cookie with a multi-day lifetime, stored in Android's WebView cookie store and attached automatically because requests used withCredentials: true. Extracting it from CookieManager was the whole game.
Harden this credential:
- Shorten the refresh-token lifetime. A multi-day refresh window is a multi-day exfiltration window for anyone who captures it once. Match the lifetime to your actual risk tolerance.
- Bind tokens to the device. Issue refresh tokens tied to a Play Integrity attestation result or a device-bound key. A token lifted onto a standalone client on a different (or failing-attestation) device should be rejected.
- Rotate refresh tokens on use. One-time-use refresh tokens that rotate on every exchange make a captured token far less valuable — reuse of a rotated token is a detectable, revocable event.
- Treat
withCredentialscookie auth as sensitive. SetHttpOnly,Secure, and a tight domain scope, and remember that these attributes protect against JavaScript and network attackers, not against a runtimeCookieManagerdump. The real defense is short lifetime plus binding.
6. Keep Sensitive Material Out of the Client Entirely
The most effective hardening is also the simplest: do not put anything in the client that you cannot afford to have read.
A practical audit checklist for a React Native app:
- No signing keys, HMAC secrets, or API tokens in the JS bundle or bridged into native code
- No internal or infrastructure-revealing API base URLs in the production bundle — use remote config or environment-specific builds
- No authorization decision made in JavaScript that the server does not independently re-check
- No long-lived credentials persisted on-device beyond what the session genuinely requires
- Bulk/list endpoints return only rendered fields, with per-record authorization and rate limits enforced server-side
The attack surface exposed by the client is exactly proportional to what you put in it — and for React Native, "the client" includes everything reachable at the JS bridge.
7. Secure the API Layer, Not Just the App
Whatever protections you put in the app, a determined analyst may eventually bypass them. The server needs defenses that assume the client is already compromised:
- Anomaly detection. Monitor request rate, timing, geography, and device fingerprint. A scripted client walking a directory endpoint does not look like a human tapping through a UI.
- Token binding. Credentials issued after a passing integrity check should be unusable on a device that fails attestation.
- Least privilege. Any credential that leaks should be short-lived and scoped to the minimum. A session that can only read the one screen a user is on is a far smaller problem than one that unlocks an entire dataset.
- Server-side authorization as the source of truth. This is where the real security lives. The client is a convenience; the API is the boundary.
This is the throughline across both of our mobile engagements and it is worth building into how you architect systems generally, as we discuss in how to build scalable enterprise applications: trust flows from the server outward, never from the client in.
Prioritizing the Work
If you are assessing a production React Native app right now, here is where to start, ordered by impact:
- Audit your list endpoints for broken authorization. Can one authenticated user enumerate data that belongs to everyone? Fix this first — it is the highest-severity, client-independent flaw.
- Implement Play Integrity attestation and bind credentials to it. The highest-leverage server-side control against the entire repackaging-and-instrumentation class of attack.
- Shorten refresh-token lifetimes and rotate on use. Directly shrinks the window a captured credential is worth anything.
- Add certificate pinning via Network Security Config. Low cost, real protection against network-path attackers.
- Remove any secret or authorization logic from the JavaScript. Assume the bridge is public, because it is.
Security in mobile applications is not a single control — it is a set of layers, each of which raises the cost of attack. No single layer is sufficient. Together they can make the analysis described in the companion post require significantly more effort, and they ensure that when the client is compromised, the API does not fall with it. For teams thinking about this earlier in the lifecycle, the trade-offs in custom versus off-the-shelf software shape how much of this you control in the first place.
The companion post, Reverse Engineering a React Native (Hermes) App: A Mobile API Security Case Study, documents exactly what each of these defenses is designed to prevent. For the parallel engagement against a Flutter app — and the different techniques a different framework demands — see reverse engineering a Flutter mobile app.
For a professional security assessment of your mobile application — including framework analysis, dynamic instrumentation testing, API authorization review, and a prioritised hardening roadmap — explore IQCrafter's Mobile Security & Reverse Engineering service.