skip to content

Mobile App Hardening

Protecting a React Native app whose binary the attacker holds: token storage, OAuth sign-in, pinning, bundle exposure, permissions and attestation. Interviewers probe what a client can protect.

part ofReact Nativeoverview, primer and where to startread it →
on this pageshow

explore

questions

26

In a React Native app, why is an API key read from a .env file or an EXPO_PUBLIC_ variable not actually secret?

level: juniorimportance: must knowfreq 66%

answer

  1. resolved at build time, not on the phone
  2. value copied into the shipped build
  3. APK and IPA are downloadable archives
  4. Expo docs: visible in plain text
  5. real secrets stay server-side

basics

~20 s

The value is copied into the build at build time, and that build ships inside every APK and IPA, so anyone who downloads the app can extract it. Real secrets must stay on a server the app calls.

solid answer

~50 s

A `.env` file in a React Native project is read on the developer's machine or the build server, never on the phone. Expo CLI replaces each statically written `process.env.EXPO_PUBLIC_*` reference with the literal value inside the JavaScript bundle, and libraries such as `react-native-config` bake values into the native build; either way the value ends up inside the APK or IPA that every user downloads. Unzipping the package and searching the bundle finds it, and Hermes bytecode keeps string literals readable. So anything embedded in the app is configuration — an API base URL, a key the provider designed to be public — never a secret. Anything that grants spending or data access belongs on a backend: the app calls your server with the user's session, and the server attaches the key when it calls the third party.

code

typescript · 20 lines
typescript
// Anti-pattern: Expo CLI inlines this value into the shipped bundle
export async function translateDirect(text: string) {
  const key = process.env.EXPO_PUBLIC_TRANSLATE_KEY;
  return fetch(`https://translate.provider.example/v1?key=${key}`, {
    method: 'POST',
    body: JSON.stringify({ text }),
  });
}

// Instead: call your own backend; the provider key never leaves the server
export async function translateViaBackend(text: string, sessionToken: string) {
  return fetch('https://api.myapp.example/translate', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${sessionToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ text }),
  });
}

go deeper

for a junior

Recall the core rule: a value in the app is readable by anyone who installs it, so .env and EXPO_PUBLIC_ are for configuration like an API URL, never for secret keys.

for a middle

Explain the mechanism: values are resolved at build time and inlined into the bundle inside the APK or IPA, and name what may ship versus what must stay on a backend.

for a senior

Show the design: a backend that holds provider keys, authenticates users, rate-limits per account and returns only needed data, plus a review step that treats every embedded value as published.

for a principal

Frame it as classification policy across teams: which keys are public by design, who approves adding one to the client, and how you detect and rotate one that slipped into a release.

## What an environment variable means in a mobile build On a server, an **environment variable** is read by the running process at start-up, on a machine the attacker does not control. A React Native app has no such process on your side of the wire. The code runs on the user's phone, so any value the code needs has to travel inside the app package — the **APK** or **AAB** on Android, the **IPA** on iOS. A `.env` file in a React Native project is therefore read at **build time**, on a developer laptop or a CI worker, and its values are copied into the build output. The name "environment variable" survives, but the security property of a server environment variable does not. ## Where the value actually ends up | How the value gets in | When it is resolved | Where it lands | Readable by a user? | |---|---|---|---| | `process.env.EXPO_PUBLIC_X` in an Expo project | when Metro bundles | literal text in the JavaScript bundle | yes | | `react-native-config` or `react-native-dotenv` | at build time | the native build or the JavaScript bundle | yes | | an EAS environment variable with **secret** visibility that the app code reads | in the EAS build job | wherever the app code embeds it | yes | | a variable on your own backend | at server runtime | never leaves the server | no | The Expo documentation says it plainly: do not store sensitive info in `EXPO_PUBLIC_` variables, because they are visible in plain text in the compiled application. It also warns that EAS **secret** visibility only hides a value on the EAS website, in the CLI and in build logs — it gives no extra protection to a value the app itself embeds. The React Native security guide adds that `react-native-config` and `react-native-dotenv` are for environment-specific settings such as API endpoints, not secrets. ## Why the device is the attacker's machine - An APK and an IPA are **zip archives**; the JavaScript bundle is a file inside them. - With **Hermes**, the bundle is bytecode, but string literals sit in a readable string table, and decompilers exist. - A value the app sends over the network can also be read by the device's owner with an intercepting proxy, whatever the bundle looks like. - The user does not need a jailbroken or rooted phone to download a package and unzip it. The conclusion is not "hide it better". It is **classification**: every value in the app is public, so decide which values are allowed to be public. ## What may ship and what may not Values that are fine in the bundle: - your own API's **base URL** and environment name; - **publishable** keys a provider designs to be public, restricted to your app and scoped to low-risk calls; - feature flags and other non-sensitive configuration. Values that must never ship: - secret keys for payments, messaging, email or AI providers — anything billed per call; - database credentials, admin tokens, cloud access keys; - a signing key or an encryption key meant to protect data from the user. ## Where the secret goes instead 1. Keep the third-party key in your **backend's** own secret storage. 2. The app authenticates the user and calls **your** endpoint with the user's session token. 3. The backend checks the user, applies per-account rate limits, validates the input and calls the third party with the key. 4. The backend returns only the data the screen needs. A leaked user token then exposes one user's quota for a limited time; a leaked provider key exposes your whole bill until you rotate it. ## Traps interviewers listen for - **"The .env file is gitignored."** That keeps the value out of the repository, not out of the app. - **"`process.env.MY_KEY` is undefined, so I renamed it to `EXPO_PUBLIC_MY_KEY`."** Expo inlines only statically referenced `EXPO_PUBLIC_` names, so the rename is exactly the step that publishes the secret. - **OTA updates.** An update ships a new JavaScript bundle, so an embedded value also reaches every device through updates. The one-line interview answer: **a React Native app cannot keep a secret from its own user; keep secrets on a server and ship only values you are willing to publish.**

  • In an Expo project, process.env.PAYMENT_KEY is undefined in the app. Why, and why is renaming it to EXPO_PUBLIC_PAYMENT_KEY the wrong fix?
    Expo CLI only replaces statically written `process.env.EXPO_PUBLIC_*` references; other names are never inlined into the client bundle, so they read as undefined. That is the guard working. Renaming the variable makes Expo copy the value into the bundle in plain text, which publishes the key. The fix is to move the call that needs the key to your backend.
  • Is a key the provider calls publishable fine to embed in a React Native app?
    Usually, if the provider designed it to be public: it can only perform low-risk client operations, and the provider lets you restrict it to your app and cap its usage. It is still extractable, so apply those restrictions and watch usage. What never ships is the matching secret key that can charge, refund or read other users' data.
  • Does storing the value as an EAS environment variable with secret visibility change the picture?
    No. Secret visibility keeps the value unreadable on the EAS website and in EAS CLI and masks it in job logs, which suits build-time credentials such as a private registry token. The Expo docs state that it adds no protection to a value the app embeds; once the app code reads it, it is in the package.

Embedding a key in the bundle is like printing the safe combination inside the instruction booklet that ships in every box: hiding it on a later page does not help, because every buyer owns the booklet.

saying these in an interview costs you the question

  • A gitignored .env file keeps the key out of the shipped app.
  • EAS secret visibility hides the value inside the compiled app.
  • Hermes bytecode makes embedded string literals unreadable.
  • Only rooted or jailbroken phones can read the app's bundle.
  • The EXPO_PUBLIC_ prefix only exposes a variable to the app, not to users.
open as a page

In a React Native app, where should a refresh token be stored, and why is AsyncStorage the wrong place for it?

level: juniorimportance: must knowfreq 78%

basics

~20 s

A refresh token belongs in platform secure storage: the iOS Keychain, or a value encrypted with an Android Keystore key, reached through expo-secure-store or react-native-keychain. AsyncStorage is an unencrypted store that anyone reading the app's files can open.

open as a page

In an Expo app, which expo-web-browser function runs an OAuth sign-in in the system browser, and what does it use on iOS and Android?

level: juniorimportance: must knowfreq 58%

basics

~20 s

WebBrowser.openAuthSessionAsync(authUrl, redirectUrl) from expo-web-browser. On iOS it runs ASWebAuthenticationSession; on Android it opens a Custom Tab and waits for the redirect deep link. It resolves with { type: 'success', url } or a cancel or dismiss result.

open as a page

In a React Native Android app, how do you request the camera permission with PermissionsAndroid, and what can the request resolve to?

level: juniorimportance: must knowfreq 58%

basics

~10 s

Call PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA) after declaring CAMERA in the manifest; it resolves to 'granted', 'denied' or 'never_ask_again', while check() only reports whether the permission is already held.

open as a page

With expo-auth-session, how do useAuthRequest, promptAsync and exchangeCodeAsync fit together in an authorization-code sign-in with PKCE?

level: middleimportance: must knowfreq 55%

basics

~10 s

useAuthRequest builds the request (state plus a PKCE verifier and S256 challenge) and returns [request, response, promptAsync]. promptAsync opens the system browser; on success you pass response.params.code and request.codeVerifier to exchangeCodeAsync to get tokens.

open as a page

Why does a React Native app's fetch to a plain http:// URL fail in a release build, and how should you allow it safely?

level: middleimportance: must knowfreq 62%

basics

~10 s

Both platforms block cleartext HTTP by default: iOS through App Transport Security, Android from API level 28. The fix is HTTPS; failing that, a narrow per-domain exception, never NSAllowsArbitraryLoads or app-wide cleartext.

open as a page

A researcher extracts a maps API key from your React Native taxi app's bundle; how do you respond, and where should such keys live?

level: seniorimportance: must knowfreq 45%

basics

~20 s

Treat the key as public: split it into a device-side map-rendering key that is restricted and capped, and move routing, geocoding and fare calls behind an authenticated backend proxy, then rotate on a schedule that old installs can survive.

open as a page

What does a Play Integrity or App Attest result prove about a request from a React Native app, and why must your server verify it?

level: seniorimportance: must knowfreq 40%

basics

~20 s

They give your server platform-signed evidence that a request comes from your genuine, unmodified app on a genuine device, bound to a fresh challenge. Only a server can check that evidence; a verdict read on the device can be forged.

open as a page

How do you keep a React Native wallet's card-details screen out of screenshots and screen recordings on Android and iOS?

level: juniorimportance: should knowfreq 36%

basics

~20 s

On Android, set FLAG_SECURE on the Activity window while the screen is visible, for example with expo-screen-capture's usePreventScreenCapture. iOS has no equivalent public flag, so rely on the library's iOS support, detect screenshots and recording, and hide sensitive content.

open as a page

Can an attacker read a React Native release app's JavaScript when it ships as Hermes bytecode inside the APK or IPA?

level: middleimportance: should knowfreq 42%

basics

~10 s

Yes. The bundle is a plain file in the package — assets/index.android.bundle on Android, main.jsbundle on iOS — and Hermes bytecode keeps strings readable and can be disassembled or decompiled back into readable logic.

open as a page

In a React Native release build, why don't R8 shrinking and Metro minification hide secrets or business logic in the JavaScript?

level: middleimportance: should knowfreq 30%

basics

~20 s

R8 only shrinks and renames the Android app's Java and Kotlin bytecode, never the JavaScript asset, and Metro's minifier shortens local names but keeps every string literal and property name, so keys and logic stay readable.

open as a page

In expo-local-authentication, what does authenticateAsync resolve with, and how should a React Native app handle a cancelled or failed prompt?

level: middleimportance: should knowfreq 40%

basics

~20 s

authenticateAsync resolves with { success: true } or { success: false, error, warning? }; a cancel or failed scan is a resolved result, not a rejection. The app branches on error: retry after a cancel, fall back to password sign-in after lockout.

open as a page

For a React Native app, how does a token kept in the iOS Keychain differ from one protected by the Android Keystore?

level: middleimportance: should knowfreq 45%

basics

~20 s

The iOS Keychain stores the token itself as an OS-encrypted item; the Android Keystore stores only a non-exportable key, so libraries encrypt the token with it and keep the ciphertext in app storage. Their lifecycles differ too.

open as a page

In a React Native app, why can't a jailbreak or root check running on the device be trusted, and how do attackers bypass it?

level: middleimportance: should knowfreq 42%

basics

~20 s

A root or jailbreak check is a heuristic running on a device the attacker controls, so root-hiding tools, runtime hooking or a patched bundle make it return false. Treat its result as a risk signal, never as a security decision.

open as a page

In an Expo app, what does expo-auth-session's makeRedirectUri return in a development build versus Expo Go, and why does that matter for OAuth?

level: middleimportance: should knowfreq 40%

basics

~20 s

In a development or production build makeRedirectUri returns your scheme URL, such as expenses://auth; in Expo Go it returns an exp:// URL tied to the dev server's address. Identity providers need a fixed, registered redirect, so OAuth is developed in a development build.

open as a page

Why does a React Native iOS app crash when it first opens the camera if Info.plist lacks NSCameraUsageDescription, and how do you fix it?

level: middleimportance: should knowfreq 46%

basics

~20 s

iOS requires a purpose string for each protected resource before it shows a prompt; without NSCameraUsageDescription it terminates the app on camera access. Add a specific description to Info.plist, or ios.infoPlist in Expo, and ship a new binary.

open as a page

In React Native, what does PermissionsAndroid's never_ask_again result mean, and how should a document-scanning app recover from it?

level: middleimportance: should knowfreq 44%

basics

~10 s

never_ask_again means Android will no longer show the prompt, so request() resolves instantly; the scanner should explain why the camera is needed, offer Linking.openSettings(), and re-check the permission when the user returns.

open as a page

In a React Native brokerage app, why is checking authenticateAsync before reading a stored refresh token weaker than protecting the item with a biometric access-control flag?

level: seniorimportance: should knowfreq 32%

basics

~20 s

authenticateAsync only returns a success value to JavaScript; the token stays readable without it. A biometric access-control flag makes the Keychain or Keystore itself refuse to release or decrypt the token until the OS has verified the user.

open as a page

In a React Native app, what should logout wipe from the device, and why can an iOS reinstall still find the previous user's refresh token?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Logout should revoke the refresh token server-side, then delete every secure-storage entry and clear in-memory and persisted user data. iOS Keychain items can survive an uninstall, so a first-launch check should wipe leftovers from a previous install.

open as a page

Your React Native mobile-wallet app must handle rooted and jailbroken devices; would you block them outright, and how would you enforce the policy?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Usually not outright: a client-side block stops honest users while attackers bypass it. Enforce on the server instead, requiring verified attestation for money movement and applying limits, step-up authentication and monitoring when signals are weak.

open as a page

In a React Native expense app using expo-auth-session, why does tapping Sign in right after logout sign the same corporate user back in without a password prompt?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Logout cleared the app's tokens, but the identity provider's session cookie still lives in the system browser, so the next authorization request completes silently. Fix it with prompt: Prompt.Login or SelectAccount, an ephemeral iOS session, or ending the provider session.

open as a page

How should a React Native document-scanning app time and sequence its camera and photo-library permission requests so users rarely deny them for good?

level: seniorimportance: should knowfreq 34%

basics

~20 s

Ask for each permission only when the user starts the feature that needs it, explain first in your own UI, request one permission per action, and avoid asking at all where the system photo picker can do the job.

open as a page

A React Native payments app pins its API key and the server certificate is being replaced; how do you rotate without locking out users who never update?

level: seniorimportance: should knowfreq 34%

basics

~20 s

Plan rotation around the oldest app still in use: renew with the same key where possible, and when the key must change, switch to a backup key whose pin already shipped. An expiration date on Android pins and a forced-update path limit lock-out.

open as a page

In a React Native payments app, how do you implement public-key pinning for the API on iOS and Android, and why ship a backup pin?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Pin the SHA-256 hash of the API's public key in native config: a pin-set in Android's network security config and NSPinnedDomains in iOS's Info.plist, or a pinning module. Always include a backup key's pin so rotation needs no emergency release.

open as a page

Should a React Native payments app pin certificates at all, and how would you decide?

level: principalimportance: should knowfreq 24%

basics

~20 s

Pin only when the threat justifies the operational cost: pinning stops a mis-issued or rogue-CA certificate for your own API, but risks locking users out on rotation. For a payments API you control, pinning keys with backups is often justified.

open as a page

In a React Native Android app, how do android:usesCleartextTraffic and network_security_config.xml relate, and what else can the config file express?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

usesCleartextTraffic is one app-wide flag; network_security_config.xml is a per-domain policy file referenced from the manifest. When the file is present it takes precedence, and it can also set trust anchors, debug-only overrides and certificate pins.

open as a page