# Mobile App Security Implementation Guide 2026 | OWASP MASVS, SecureStore, Biometrics (React Native / Expo)

> A security implementation guide for running React Native / Expo apps safely in production. Organized around the eight control groups of OWASP MASVS v2.1.0, covering secure token storage (expo-secure-store), the EXPO_PUBLIC_ pitfall, correct biometric configuration, and the reality of TLS and certificate pinning — with code verified against the official docs.

- Published: 2026-07-23
- Author: 友田 陽大
- Tags: モバイルアプリ, React Native, Expo, セキュリティ, OWASP, MASVS, 認証, TypeScript, アーキテクチャ設計, アプリ開発
- URL: https://tomodahinata.com/en/blog/mobile-app-security-owasp-masvs-secure-storage-guide
- Category: モバイルアプリ開発（Expo / React Native）
- Pillar guide: https://tomodahinata.com/en/blog/expo-production-guide-router-eas-cng-ota

## Key points

- A mobile app runs on the attacker's device the moment you ship it. Design from the premise that both the binary and local storage are under their control.
- The standard is OWASP MASVS v2.1.0 (eight control groups). The L1/L2/R verification levels were removed in v2.0.0 and reworked as MAS Testing Profiles (MASWE, Beta) — beware stale L1/L2/R framing.
- AsyncStorage is officially 'unencrypted.' Don't use it for tokens or secrets; use expo-secure-store (iOS Keychain / Android Keystore).
- The '2048-byte limit' for expo-secure-store has been retracted in the current docs (Expo enforces no limit). The correct requirement is handling write errors, not assuming a cap.
- Expo ships no first-party API for certificate pinning. Doing it requires prebuild (CNG) + native config, and you must budget for the operational cost: rotating the cert cuts off older clients.

---

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](/blog/expo-production-guide-router-eas-cng-ota). 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.**

```ts
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 `deleteItemAsync` on 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-store` config plugin handle it (`configureAndroidBackup`), or exclude `SecureStore` in your custom backup config.
- **Biometric changes invalidate keys.** Values written with `requireAuthentication: true` become 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.**

```ts
// ❌ 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](/blog/nextjs-env-secret-leak-prevention-public-vars-guide) and [Supabase key exposure](/blog/supabase-anon-key-service-role-key-exposure-guide) 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.

```ts
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 `biometricsSecurityLevel` defaults to `'weak'`**, permitting the weaker Class 2 biometrics. If you have a security requirement, **set `'strong'` explicitly.**
- **On iOS, forgetting `NSFaceIDUsageDescription` silently 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](/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase) 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.

```xml
<!-- 証明書ピンニングは 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'`? Is `NSFaceIDUsageDescription` configured 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.
