Skip to main content
友田 陽大
モバイルアプリ開発(Expo / React Native)
モバイルアプリ
React Native
Expo
セキュリティ
OWASP
MASVS
認証
TypeScript
アーキテクチャ設計
アプリ開発

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
Reading time
8 min read
Author
友田 陽大
Share

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 groupPurpose (official definition)
MASVS-STORAGESecure storage of sensitive data on a device (data-at-rest)
MASVS-CRYPTOCryptographic functionality used to protect sensitive data
MASVS-AUTHAuthentication and authorization mechanisms used by the mobile app
MASVS-NETWORKSecure network communication between the app and remote endpoints (data-in-transit)
MASVS-PLATFORMSecure interaction with the underlying platform and other installed apps
MASVS-CODESecurity best practices for data processing and keeping the app up-to-date
MASVS-RESILIENCEResilience to reverse engineering and tampering attempts
MASVS-PRIVACYPrivacy 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 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.

// ❌ 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 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 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'? 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.

Frequently asked questions

Why shouldn't I store an access token in AsyncStorage?
Because React Native's official docs explicitly describe AsyncStorage as 'unencrypted' and list 'token storage' and 'secrets' under what NOT to use it for. If the device is compromised — or via backups — the values can be read in plain text. Small secrets like tokens belong in expo-secure-store (Keychain on iOS, Keystore-encrypted SharedPreferences on Android).
I heard expo-secure-store has a 2048-byte limit. Is that true?
It's been retracted in the current docs. The wording is that large payloads can be rejected by the underlying platform, that historically some iOS releases refused values above roughly 2048 bytes, and that Expo does not enforce a limit. So it isn't a fixed cap. The correct implementation requirement is to handle write failures (native rejection), not to design around an assumed limit.
How should I use the OWASP MASVS L1 / L2 / R levels?
You shouldn't. The verification levels (L1/L2/R) were removed in MASVS v2.0.0 and reworked as 'MAS Testing Profiles' under MASWE (currently Beta). Any 2026 explanation built on L1/L2/R is based on an old edition. Today you organize requirements by the eight control groups (MASVS-STORAGE / CRYPTO / AUTH / NETWORK / PLATFORM / CODE / RESILIENCE / PRIVACY).
Can I do certificate pinning in Expo?
Expo has no first-party API, and its official documentation contains no mention of pinning. To do it you need prebuild (CNG) + a config plugin writing native configuration (such as Android's Network Security Config). It also carries a real operational cost: when you rotate the certificate, existing apps embedding the old one can no longer connect (React Native's docs warn about this). Backup pins and an expiration strategy are mandatory.
What's easy to miss when configuring biometrics (Face ID / fingerprint)?
Three things. (1) On Android, biometricsSecurityLevel defaults to 'weak' (allowing the weaker Class 2), so set 'strong' explicitly. (2) On iOS, if NSFaceIDUsageDescription isn't set in app.json, the module silently falls back to device passcode authentication instead of Face ID. (3) Face ID can't be tested in Expo Go — you need a development build.
Is it OK to embed an API key in the app?
No. Environment variables prefixed with EXPO_PUBLIC_ are inlined into the bundle in plain text at build time (the official docs warn they'll be 'visible in plain-text in your compiled application'). Only publicly-safe values (like an API URL) belong there. Keep secrets server-side (EAS Secrets / environment variables) and hand the device only short-lived tokens.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Security engineering, from design to implementation and operations

Design reviews via threat modeling, correct implementation of crypto and authn/authz, log design and detection (detection engineering), and building an incident-response capability. With experience building — solo × generative AI — a METI Minister's Award B2B SaaS and a payments platform with zero double charges in production, I help get your product to a state where it ships fast and can be defended. Vertical risks that only design can address, I also take on as an audit.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading