# モバイルアプリのセキュリティ実装ガイド 2026｜OWASP MASVS・SecureStore・生体認証（React Native / Expo）

> React Native / Expo アプリを本番で安全に運用するためのセキュリティ実装ガイド。OWASP MASVS v2.1.0 の8つの管理領域を軸に、トークンの安全な保管（expo-secure-store）、EXPO_PUBLIC_ の落とし穴、生体認証の正しい設定、TLSと証明書ピンニングの現実解までを、公式ドキュメント準拠の実コードで解説します。

- 公開日: 2026-07-23
- 著者: 友田 陽大
- タグ: モバイルアプリ, React Native, Expo, セキュリティ, OWASP, MASVS, 認証, TypeScript, アーキテクチャ設計, アプリ開発
- URL: https://tomodahinata.com/blog/mobile-app-security-owasp-masvs-secure-storage-guide
- カテゴリ: モバイルアプリ開発（Expo / React Native）
- 総合ガイド: https://tomodahinata.com/blog/expo-production-guide-router-eas-cng-ota

## 要点

- モバイルアプリは配布した瞬間に攻撃者の端末で動く。バイナリもローカルストレージも相手の支配下にある、という前提から設計する。
- 基準はOWASP MASVS v2.1.0（8つの管理領域）。v2.0.0でL1/L2/Rの検証レベルは廃止され、MAS Testing Profiles（MASWE・Beta）へ移行済み——古い記事のL1/L2/R表記に注意。
- AsyncStorageは公式に『非暗号化』。トークン・シークレットの保管には使わず、expo-secure-store（iOS Keychain / Android Keystore）を使う。
- expo-secure-storeの『2048バイト上限』は現在の公式記述では撤回されている（Expoは上限を課さない）。上限を前提にせず、書き込みのエラー処理を実装するのが正しい要件。
- 証明書ピンニングにExpoの一次APIは存在しない。実現はprebuild(CNG)＋ネイティブ設定で、証明書更新が旧クライアントを切断する運用コストを必ず見積もる。

---

Webアプリのセキュリティとモバイルアプリのセキュリティは、前提が根本的に違います。Webではコードはサーバーに残り、攻撃者が触れるのはリクエストだけです。一方**モバイルアプリは、配布した瞬間に攻撃者の端末で動きます**。バイナリは逆コンパイルでき、ローカルストレージは端末が侵害されれば読まれ、通信は中間者に晒される。**「相手の支配下で動くクライアント」**という前提から設計しなければ、いくらサーバーを固めても意味がありません。

この記事は、React Native / Expo アプリの本番運用に必要なセキュリティを、**OWASP MASVS v2.1.0** を軸に整理し、**Expo公式ドキュメントで検証した実コード**で示します。あわせて、多くの記事が古い情報のまま伝えている**3つの落とし穴**（SecureStoreの「2048バイト上限」、MASVSの「L1/L2/R」、Expoの証明書ピンニング）も訂正します。

> 実装の土台（Expo Router・EAS・CNG・OTA更新）は [Expo本番運用ガイド](/blog/expo-production-guide-router-eas-cng-ota) を参照してください。本記事はその上に載る「セキュリティ」の層です。

## 基準を押さえる：OWASP MASVS v2.1.0

モバイルセキュリティの検証基準は **OWASP MASVS**。現行は **v2.1.0**（2024-01-18リリース、MASVS-PRIVACY を追加）で、**8つの管理領域**に整理されています。

| 管理領域 | 目的（公式の定義） |
|---|---|
| **MASVS-STORAGE** | 端末上の機微データの安全な保管（保存データ） |
| **MASVS-CRYPTO** | 機微データを保護する暗号機能 |
| **MASVS-AUTH** | アプリが使う認証・認可のメカニズム |
| **MASVS-NETWORK** | アプリとリモート間の安全な通信（通信データ） |
| **MASVS-PLATFORM** | プラットフォーム・他アプリとの安全な相互作用 |
| **MASVS-CODE** | データ処理のベストプラクティスとアプリの最新性維持 |
| **MASVS-RESILIENCE** | リバースエンジニアリング・改ざんへの耐性 |
| **MASVS-PRIVACY** | 利用者のプライバシーを守る管理策 |

> ⚠️ **落とし穴①：L1 / L2 / R は廃止されています。** MASVS **v2.0.0 で検証レベル（L1/L2/R）は削除**され、「MAS Testing Profiles」として **MASWE**（現在**Beta**）へ再編されました。2026年時点でL1/L2/Rを前提にしている解説は、古い版に基づいています。

役割分担も整理しておくと、**MASVS（何を検証するか）→ MASWE（弱点の列挙・Beta）→ MASTG（どう検証するか）** ＋ MAS Checklist（適合の証跡）という構造です。なお **OWASP Mobile Top 10（2024）は別プロジェクト**で、啓発・リスク認知向け（ページ自体が "work in progress" と明記、前版は2016年）。**エンジニアリングの規範はMASVS/MASTG**、Mobile Top 10 はリスクの語り口、と使い分けます。

## MASVS-STORAGE：秘密を「どこに」置くか

最も事故が多いのがここです。まず大原則——**React Native は機微データを保存する仕組みを標準では持ちません**（公式明記）。

**AsyncStorage は使ってはいけません。** React Native 公式は AsyncStorage を「**非暗号化（unencrypted）**」と定義し、用途表で **「トークン保管」「シークレット」を “使うな” 側に明記**しています（使ってよいのは非機微な設定やUI状態）。

正解は **expo-secure-store**。**iOS では Keychain（`kSecClassGenericPassword`）**、**Android では Android Keystore で暗号化された SharedPreferences** に保管されます。

```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 });
  }
}
```

> ⚠️ **落とし穴②：「2048バイト上限」は現在の公式記述では撤回されています。** 現行ドキュメントは「大きなペイロードはプラットフォームに拒否され得る／歴史的に一部のiOSリリースは約2048バイト超を拒否した／**Expoは上限を課さない**」という趣旨です。固定上限を前提にした設計ではなく、**書き込みエラーのハンドリング**を要件にしてください。

本番で刺さる注意点が3つあります。

- **iOSは再インストールしても残る** — Keychainの性質上、同じbundle IDで再インストールすると**アンインストール前の値が復活**します。ログアウト時は明示的に `deleteItemAsync` してください。
- **Android の自動バックアップを除外する** — Auto Backup 対象のままだと、復元後に**復号できない**エントリが残ります（アンインストール時にKeystoreの鍵が消えるため）。`expo-secure-store` の config plugin（`configureAndroidBackup`）に任せるか、カスタムバックアップ設定側で `SecureStore` を除外します。
- **生体変更で鍵が無効化される** — `requireAuthentication: true` で保存した値は、**指紋の追加や顔情報の変更でシステムが鍵を無効化**し、読めなくなります。復旧導線（再ログイン）を必ず用意します。

同期版の `setItem` / `getItem` も存在しますが、公式が「**JavaScriptスレッドをブロックする**」と警告しています。原則は非同期版を使ってください。

## MASVS-CODE：EXPO_PUBLIC_ はバンドルに平文で埋め込まれる

Webの `NEXT_PUBLIC_` と同じ罠が、モバイルではより深刻に効きます。Expo公式は明確です——**「`EXPO_PUBLIC_` 変数に秘密を入れるな。コンパイル済みアプリ内に平文で見える」**。仕組みはビルド時の**Metroによる文字列インライン化**で、**EAS Build でも EAS Update でも同様**です。

```ts
// ❌ EXPO_PUBLIC_* はビルド時にバンドルへ平文で埋め込まれる（公式警告）
const apiKey = process.env.EXPO_PUBLIC_API_KEY; // 逆コンパイルすれば誰でも読める

// ✅ 公開前提の値だけを置く（エンドポイントURLなど）
const apiUrl = process.env.EXPO_PUBLIC_API_URL;

// 秘密はサーバー側（EAS Secrets / 環境変数）に置き、
// 端末へは「短命なアクセストークン」だけを渡す設計にする。
```

「アプリに埋め込んだAPIキーは秘密ではない」——これはモバイル固有の制約ではなく、**クライアント配布物すべてに共通する原則**です（Webの[環境変数の漏洩防止](/blog/nextjs-env-secret-leak-prevention-public-vars-guide)や[Supabaseキーの露出](/blog/supabase-anon-key-service-role-key-exposure-guide)と同じ構図）。

## MASVS-AUTH：生体認証を「正しく」設定する

`expo-local-authentication` は、iOSの Face ID / Touch ID、Androidの生体認証を扱います。要点は**いきなり認証を呼ばない**こと——ハードウェアの有無、登録の有無を順に確認します。

```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;
}
```

見落としやすい落とし穴が3つあります。

- **Androidの `biometricsSecurityLevel` は既定が `'weak'`** — より弱い Class 2 生体を許容します。セキュリティ要件があるなら **`'strong'` を明示**してください。
- **iOSで `NSFaceIDUsageDescription` を設定し忘れると、Face IDではなく端末パスコード認証へ静かに降格**します。意図せず保護レベルが下がる典型例です。
- **Face ID は Expo Go では検証できません**（development build が必要）。「動いたつもり」で本番に出さないこと。

なお、生体認証は**認可の代替ではありません**。サーバー側の[認証・認可](/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase)が本体で、生体認証は端末ローカルの「本人確認」に過ぎない、という位置づけを崩さないでください。

## MASVS-NETWORK：TLSと、証明書ピンニングの現実解

通信の基本は当然TLSですが、**まずやるべきは平文通信を塞ぐこと**です。Androidは `expo-build-properties` の `usesCleartextTraffic`（Android 9以降は既定 `false`）、iOSは ATS（App Transport Security）で担保します。

その先の**証明書ピンニング**については、正直に書きます。

> ⚠️ **落とし穴③：Expo に証明書ピンニングの一次APIは存在しません。** Expo公式ドキュメントにピンニングの記載はありません。React Native 公式は**技法として解説**していますが、**APIは提供していません**。

実現するなら、**prebuild（CNG）＋config plugin でネイティブ設定を書く**ことになります。Androidなら 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>
```

そして**運用コストを必ず見積もってください**。React Native公式が警告するとおり、**証明書は1〜2年で期限を迎え、更新するとその証明書を埋め込んだ既存アプリは動かなくなります**。ユーザーが更新しなければ、アプリは死んだまま。だから **`expiration` とバックアップpinの設計が必須**であり、「とりあえずピンニング」は本番では危険です。**脅威モデル上、本当に必要かを天秤にかける**——これが正しい判断の仕方です。

## 導入チェックリスト（MASVS領域別）

- [ ] **STORAGE** — トークン等は AsyncStorage ではなく expo-secure-store に保管し、書き込みエラーを処理しているか。ログアウト時に削除しているか（iOSは再インストールで残る）。
- [ ] **STORAGE** — Android の自動バックアップから SecureStore を除外したか。
- [ ] **CODE** — `EXPO_PUBLIC_` に秘密を入れていないか。秘密はサーバー側（EAS Secrets）か。
- [ ] **AUTH** — 生体認証は hasHardware → isEnrolled → authenticate の順か。Androidは `'strong'` を明示したか。iOSの `NSFaceIDUsageDescription` を設定したか。
- [ ] **NETWORK** — 平文通信を塞いだか（cleartext無効・ATS）。ピンニングは脅威モデルとバックアップpin・期限設計込みで判断したか。
- [ ] **PLATFORM/RESILIENCE** — 逆コンパイル前提で、クライアントに信頼を置く実装（重要な判定をアプリ側だけで行う等）になっていないか。
- [ ] **PRIVACY** — 収集する個人データを最小化し、用途を開示しているか（MASVS-PRIVACY）。

## まとめ

モバイルアプリのセキュリティは、**「クライアントは攻撃者の支配下にある」**という前提から始まります。

- **基準はOWASP MASVS v2.1.0 の8領域。** L1/L2/Rは廃止済み——古い枠組みで語らない。
- **秘密の保管は expo-secure-store。** AsyncStorageは公式に非暗号化。「2048バイト上限」は撤回されており、要件は**エラー処理**。
- **`EXPO_PUBLIC_` はバンドルに平文で埋まる。** 埋めてよいのは公開前提の値だけ。
- **生体認証は既定値が弱い。** Androidは `'strong'` を明示、iOSはFace ID設定漏れでサイレント降格。
- **証明書ピンニングにExpoの一次APIは無い。** やるなら運用コスト（証明書更新で旧クライアントが死ぬ）まで含めて設計する。

サーバー側をどれだけ固めても、端末に平文のトークンが残っていれば意味がありません。**アプリの外側（サーバー・認可）と内側（保管・通信・改ざん耐性）を、MASVSの領域ごとに漏れなく設計する**——これが、審査にも監査にも耐えるモバイルアプリの作り方です。
