A React Native screen lives in a device, behind a JavaScript bundle that anyone with the IPA or APK can read. A Next.js document lives in a browser origin. Both UIs are untrusted. Both still talk to the same Hono / Better Auth API. This note is the device.
The category map is Web Security and the OWASP Top 10. The origin host is Security in Next.js. This is a defensive reading of React Native / Expo: failure modes and attacker goals, not exploits.
1. The Host
A React Native request leaves through native modules, then meets the same server the Next.js document uses:
React Native:
JS thread → Native modules ─┬→ OS keychain
└→ TLS
OS keychain → Hono API → Authn then authz| Next.js | React Native | |
|---|---|---|
| Isolation unit | Browser origin | App ID / keychain access group |
| Session | HttpOnly; Secure; SameSite cookie | SecureStore / Keychain / Keystore — never AsyncStorage |
| XSS surface | DOM, dangerouslySetInnerHTML, next/script | WebView plus any JS bridge you expose |
| Secrets | server-only modules; NEXT_PUBLIC_* is public | Nothing in the binary is secret |
| Deep entry | searchParams, open redirects, router.push | Custom URL schemes, universal links, push payloads |
- The shared lie is the same on both hosts: the UI is not authorization. Storing
isAdminin AsyncStorage does not decide whether a row may be read. - Understanding React Native in Depth covers the renderer; this note is the threat model around the extractable bundle, the keychain, and any WebView you embed.
2. The Bundle Is Not a Secret
React Native's threat model starts where Next.js's server graph ends. There is no 'use client' fence. Metro's output ships on the device.
- Anyone who can install the app can inspect the JavaScript. API keys, "hidden" feature flags, and hardcoded HMAC secrets in the bundle are public constants with extra steps.
- The attacker goal is credentials that were never supposed to leave the build laptop.
- Defense is the same sentence as
NEXT_PUBLIC_*, applied to the whole binary: the app holds public identifiers and user-bound tokens, not authority. The Hono API still authenticates every call. - Fine in the binary: a Mapbox public token with URL restrictions, a Firebase client config, an OAuth client id for a public native client. Not fine: a database URL, an HMAC that mints sessions, a "debug admin" compile-time flag, a Stripe secret key.
- Expo's
extrainapp.jsonis still in the bundle. EAS secrets baked into the client at build time areNEXT_PUBLIC_*again. Hermes bytecode slows casual reading; it is not encryption. - Identity on mobile is a short-lived access token in memory plus a refresh token in the OS secret store, issued by the same Better Auth (or OAuth) server the web app uses.
Failure: session material in AsyncStorage, Redux Persist, or unencrypted MMKV. Those are localStorage with a native accent. Jailbreak / root detection is not a boundary.
3. SecureStore, Keychain, and Keystore
iOS Keychain and Android Keystore are the isolation unit that cookies are on the web. expo-secure-store is how JS asks the OS to hold a blob the rest of the app should not casually dump to disk.
await SecureStore.setItemAsync("auth.refresh", token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
})WHEN_UNLOCKED_THIS_DEVICE_ONLYkeeps the refresh token off iCloud Keychain backups when the product does not need cross-device restore. That is a product trade-off, not a universal rule.- Access tokens stay in memory when the session can be refreshed; a process death then costs a refresh, not a stolen long-lived bearer in a world-readable file.
- Screenshot and recents protection (
FLAG_SECURE, iOS screen-capture notifications) is policy for banking-shaped screens. It is not cryptography.
Failure: treating SecureStore as magic. A compromised device, a malicious keyboard, or a backup you opted into can still expose it. The server still rotates, expires, and revokes.
4. TLS and Pinning
On the web, the browser and Let's Encrypt did most of this for you. On mobile, the OS still verifies the public PKI: App Transport Security on iOS, Network Security Config on Android.
- Cleartext HTTP is off unless you explicitly punch a hole. Production API hosts do not get cleartext exceptions.
- Certificate pinning is defense in depth for high-risk apps: the client additionally requires a known key or SPKI for
api.example.com. It raises the cost of a rogue CA or a corporate TLS-intercept box. - Pinning also means you need a rotation story before the pin expires, or you ship a brick. Pin only when the threat model includes hostile networks and the team can rotate pins with an app update or a backup pin.
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>Failure: treating transport security as authorization. ATS / NSC on, no user-installed CAs trusted for the API if the OS lets you say so, pinning only with rotation — and the API still requires a real user session.
5. Deep Links
A custom URL scheme (myapp://) is not an origin. Other apps on the device can often register the same scheme. A verified universal link (iOS) or App Link (Android) is associated with an HTTPS domain you control.
- The attacker goal is to open the app on a chosen path with chosen query params: an OAuth redirect, a password-reset token, a campaign URL that sets
organizationId. - Prefer associated domains over raw schemes for anything that carries a token or chooses an account.
- Treat every incoming URL as untrusted input. Parse it. Allowlist paths. Do not execute whatever query string arrived.
- OAuth on mobile goes through the system browser (or in-app browser tab) plus PKCE, not an embedded WebView that the app can script. The app never sees the user's password; it sees an authorization code the OS delivered to the claimed redirect.
- FCM (and other push) payloads are the same class. A campaign deep link that opens a membership offer is marketing. A push that the client interprets as "mark this invoice paid" is an unsigned RPC.
import * as Linking from "expo-linking"
const CAMPAIGN_PATHS = new Set(["/offers", "/card", "/inbox"])
export function pathFromDeepLink(url: string) {
const parsed = Linking.parse(url)
const path = parsed.path ? `/${parsed.path}` : "/"
if (!CAMPAIGN_PATHS.has(path)) {
return "/home"
}
return path
}Query params from that URL still need the same Zod treatment as Next.js searchParams. They do not become fetch bodies without a session.
6. WebView
A WebView is a browser origin inside the app. HTML it loads can run script in that origin. If you also expose a JavaScript bridge into native modules — camera, file system, session store — you have given that page a privileged RPC.
- The attacker goal is to run script in the WebView with the app's privileges: a loaded help article, a payment iframe you thought was sealed, a
file://page, a redirect to content you do not control. - Prefer an in-app browser tab (Safari View Controller / Chrome Custom Tabs,
expo-web-browser) for pages that are not your UI. They get the real browser's origin isolation and the system cookie jar, not your bridge. - If you must WebView your own content, lock navigation to an allowlisted https origin, disable file access you do not need, and do not inject a bridge that can read SecureStore or fire authenticated API calls.
const ALLOWED_HOST = "help.example.com"
function onShouldStartLoadWithRequest(request: { url: string }) {
try {
const url = new URL(request.url)
return url.protocol === "https:" && url.hostname === ALLOWED_HOST
} catch {
return false
}
}Failure: injectedJavaScript is dangerouslySetInnerHTML for native. User content does not belong there. A membership card rendered in React Native views does not need a WebView.
7. Native Modules and Push
Third-party native code is supply chain with a second compiler. An npm package that also ships an Android Gradle plugin or a CocoaPod can do anything the OS grants the app.
- The OWASP supply-chain category applies; the extra fact is that JS review does not see the native half.
- Versions are pinned,
autolinkingoutput is reviewed, and a new native module is a permissions review: what entitlements does this add? - A device push token identifies a device to APNs or FCM. It does not identify a user. Binding a token to an account happens on the server after a verified session.
- Register the token on login (
POST /devices); unregister on logout./devicesstill authenticates with the session from SecureStore — the token is a destination, not a credential. - The JS thread can be paused; native work continues. Logout is a native operation, not only a React state reset: cancel in-flight uploads and wipe SecureStore.
Failure: authorizing a mutation because a push arrived, or accepting a token the client posted for someone else.
8. Defaults
- The bundle is public. Refresh tokens live in SecureStore / Keychain, not AsyncStorage. Access tokens stay in memory when the product allows.
- ATS / Network Security Config on; pinning only with a rotation plan.
- Universal links over raw schemes. OAuth via system browser and PKCE. Deep links and push payloads are untrusted input.
- No privileged WebView bridge. Logout wipes the secret store and the push binding.
The keychain, the session, and the authorization check are the product. The renderer is not. The same sentence on the origin is Security in Next.js.