Web security and mobile security start from fundamentally different premises. On the web, your code stays on the server and all an attacker touches is the request. A mobile app, by contrast, runs on the attacker's device the moment you ship it. The binary can be decompiled, local storage can be read if the device is compromised, and traffic is exposed to a man in the middle. Unless you design from the premise of "a client running under someone else's control," hardening the server buys you little.
This article organizes the security you need to run React Native / Expo apps in production around OWASP MASVS v2.1.0, with code verified against Expo's official documentation. Along the way it corrects three pitfalls that a lot of writing still gets wrong (the SecureStore "2048-byte limit," MASVS "L1/L2/R," and certificate pinning in Expo).
For the delivery foundation (Expo Router, EAS, CNG, OTA updates), see the Expo production guide. This article is the security layer that sits on top of it.
Know the standard: OWASP MASVS v2.1.0
The verification standard for mobile security is OWASP MASVS. The current edition is v2.1.0 (released 2024-01-18, adding MASVS-PRIVACY), organized into eight control groups.
| Control group | Purpose (official definition) |
|---|---|
| MASVS-STORAGE | Secure storage of sensitive data on a device (data-at-rest) |
| MASVS-CRYPTO | Cryptographic functionality used to protect sensitive data |
| MASVS-AUTH | Authentication and authorization mechanisms used by the mobile app |
| MASVS-NETWORK | Secure network communication between the app and remote endpoints (data-in-transit) |
| MASVS-PLATFORM | Secure interaction with the underlying platform and other installed apps |
| MASVS-CODE | Security best practices for data processing and keeping the app up-to-date |
| MASVS-RESILIENCE | Resilience to reverse engineering and tampering attempts |
| MASVS-PRIVACY | Privacy controls to protect user privacy |
⚠️ Pitfall #1: L1 / L2 / R are gone. MASVS v2.0.0 removed the verification levels (L1/L2/R), reworking them as "MAS Testing Profiles" under MASWE (currently Beta). Any 2026 explanation premised on L1/L2/R is based on an old edition.
It also helps to know the division of labor: MASVS (what to verify) → MASWE (enumerated weaknesses, Beta) → MASTG (how to verify), plus the MAS Checklist for compliance evidence. Note that OWASP Mobile Top 10 (2024) is a separate project aimed at awareness and risk communication (the page itself says "work in progress"; the prior edition was 2016). Use MASVS/MASTG as the engineering norm and Mobile Top 10 as the way you talk about risk.
MASVS-STORAGE: where do secrets live?
This is where most incidents happen. First, the governing fact: React Native ships no built-in way to store sensitive data (stated officially).
Don't use AsyncStorage. React Native's docs define it as "unencrypted" and explicitly list "token storage" and "secrets" on the "don't use it for" side (it's fine for non-sensitive preferences and UI state).
The answer is expo-secure-store: on iOS it uses Keychain services (kSecClassGenericPassword), and on Android, SharedPreferences encrypted with the Android Keystore system.
import * as SecureStore from "expo-secure-store";
// トークンは SecureStore へ(iOS: Keychain / Android: Keystore で暗号化)。
// 公式は「Expo 側でサイズ上限を課さない」— 大きな値はネイティブ側で拒否され得るため、
// 「上限の前提」ではなく「書き込み失敗の処理」を実装するのが正しい要件。
export async function saveAccessToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync("access_token", token, {
// 端末外へ持ち出さない・ロック中は読めない、を明示する
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
} catch (cause) {
// ネイティブ拒否(サイズ・Keychain エラー等)は握り潰さず、再認証へ倒す
throw new Error("failed to persist access token", { cause });
}
}
⚠️ Pitfall #2: the "2048-byte limit" has been retracted in the current docs. The present wording is that large payloads can be rejected by the platform, that historically some iOS releases refused values above roughly 2048 bytes, and that Expo enforces no limit. So don't design around a fixed cap — make handling write errors the requirement.
Three things bite in production:
- On iOS, values survive reinstalls. Because of how the Keychain works, reinstalling with the same bundle ID can resurrect values from before the uninstall. Explicitly
deleteItemAsyncon logout. - Exclude it from Android Auto Backup. If it stays in the backup set, restored entries can't be decrypted (the Keystore key is destroyed on uninstall). Let the
expo-secure-storeconfig plugin handle it (configureAndroidBackup), or excludeSecureStorein your custom backup config. - Biometric changes invalidate keys. Values written with
requireAuthentication: truebecome unreadable when the system invalidates the key after a new fingerprint is added or the face profile changes. Always provide a recovery path (re-login).
Synchronous setItem / getItem also exist, but the docs warn they block the JavaScript thread. Prefer the async versions.
MASVS-CODE: EXPO_PUBLIC_ is inlined into the bundle in plain text
The same trap as the web's NEXT_PUBLIC_, but it bites harder on mobile. Expo is explicit: "Do not store sensitive info, such as private keys, in EXPO_PUBLIC_ variables. These variables will be visible in plain-text in your compiled application." The mechanism is build-time string inlining by Metro, and it applies to both EAS Build and EAS Update.
// ❌ EXPO_PUBLIC_* はビルド時にバンドルへ平文で埋め込まれる(公式警告)
const apiKey = process.env.EXPO_PUBLIC_API_KEY; // 逆コンパイルすれば誰でも読める
// ✅ 公開前提の値だけを置く(エンドポイントURLなど)
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
// 秘密はサーバー側(EAS Secrets / 環境変数)に置き、
// 端末へは「短命なアクセストークン」だけを渡す設計にする。
"An API key embedded in the app is not a secret" isn't a mobile-specific constraint — it's a principle common to every distributed client (the same shape as preventing env-var leaks and Supabase key exposure on the web).
MASVS-AUTH: configure biometrics correctly
expo-local-authentication handles Face ID / Touch ID on iOS and biometrics on Android. The key is to not call authenticate blindly — check hardware, then enrollment, in order.
import * as LocalAuthentication from "expo-local-authentication";
// 生体認証:ハード有無 → 登録有無 → 認証、の順に確認する
export async function authenticateWithBiometrics(): Promise<boolean> {
if (!(await LocalAuthentication.hasHardwareAsync())) return false;
if (!(await LocalAuthentication.isEnrolledAsync())) return false;
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "本人確認",
disableDeviceFallback: true, // iOS: 生体のみ(パスコード代替に落とさない)
biometricsSecurityLevel: "strong", // Android: 既定は 'weak' → 明示的に強い方へ
});
return result.success;
}
Three easy-to-miss pitfalls:
- Android's
biometricsSecurityLeveldefaults to'weak', permitting the weaker Class 2 biometrics. If you have a security requirement, set'strong'explicitly. - On iOS, forgetting
NSFaceIDUsageDescriptionsilently downgrades authentication to the device passcode instead of Face ID — a textbook case of protection quietly weakening. - Face ID can't be verified in Expo Go (you need a development build). Don't ship on "it seemed to work."
Also, biometrics are not a substitute for authorization. Server-side authentication and authorization is the real thing; biometrics are only a local "is this the device owner" check. Don't let that boundary blur.
MASVS-NETWORK: TLS, and the reality of certificate pinning
TLS is table stakes, but the first thing to do is block cleartext traffic. On Android use expo-build-properties' usesCleartextTraffic (default false on Android 9+); on iOS, ATS (App Transport Security) covers it.
Beyond that, on certificate pinning, let me be blunt.
⚠️ Pitfall #3: Expo has no first-party certificate-pinning API. Expo's official documentation contains no mention of pinning. React Native's docs explain the technique but ship no API.
Doing it means writing native configuration via prebuild (CNG) + a config plugin. On Android that's the Network Security Config.
<!-- 証明書ピンニングは Expo の一次APIではない(公式ドキュメントに記載なし)。
prebuild(CNG) + config plugin で Android の Network Security Config を書く。 -->
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=</pin>
<!-- バックアップpinは必須:更新時に旧クライアントが接続不能になるのを防ぐ -->
<pin digest="SHA-256">fwza0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1oE=</pin>
</pin-set>
And budget for the operational cost. As React Native's docs warn, certificates expire every 1–2 years, and once you rotate, existing apps embedding the old certificate stop working. If users don't update, the app stays dead. That's why expiration and backup pins are mandatory, and why "pin it just in case" is dangerous in production. Weigh whether your threat model actually requires it — that's the right way to decide.
Adoption checklist (by MASVS group)
- STORAGE — are tokens in expo-secure-store rather than AsyncStorage, with write errors handled? Are they deleted on logout (iOS survives reinstall)?
- STORAGE — is SecureStore excluded from Android Auto Backup?
- CODE — are there no secrets in
EXPO_PUBLIC_? Are secrets server-side (EAS Secrets)? - AUTH — is it hasHardware → isEnrolled → authenticate? Is Android set to
'strong'? IsNSFaceIDUsageDescriptionconfigured on iOS? - NETWORK — is cleartext blocked (cleartext off / ATS)? Was pinning decided with the threat model plus backup pins and expiration?
- PLATFORM/RESILIENCE — assuming decompilation, does anything place trust in the client (e.g. deciding critical checks in the app alone)?
- PRIVACY — is collected personal data minimized and its use disclosed (MASVS-PRIVACY)?
Summary
Mobile app security starts from the premise that the client is under the attacker's control.
- The standard is the eight groups of OWASP MASVS v2.1.0. L1/L2/R are retired — don't frame it with the old model.
- Store secrets in expo-secure-store. AsyncStorage is officially unencrypted. The "2048-byte limit" has been retracted; the requirement is error handling.
EXPO_PUBLIC_is inlined in plain text. Only publicly-safe values belong there.- Biometric defaults are weak. Set
'strong'on Android; a missing Face ID key on iOS silently downgrades. - Expo has no first-party pinning API. If you do it, design for the operational cost — rotating the cert kills older clients.
However well you harden the server, it means nothing if a plaintext token sits on the device. Design the outside of the app (server, authorization) and the inside (storage, transport, tamper resistance) with no gaps across the MASVS groups — that's how you build an app that survives both app review and a security audit.