Chuyển đến nội dung chính
友田 陽大
モバイルアプリ開発(Expo / React Native)
モバイルアプリ
React Native
Expo
セキュリティ
OWASP
MASVS
認証
TypeScript
アーキテクチャ設計
アプリ開発

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

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

Published
Reading time
10 min read
Author
友田 陽大
Chia sẻ

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本番運用ガイド を参照してください。本記事はその上に載る「セキュリティ」の層です。

基準を押さえる: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-storeiOS では Keychain(kSecClassGenericPasswordAndroid では Android Keystore で暗号化された SharedPreferences に保管されます。

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 でも同様です。

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

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

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

「アプリに埋め込んだAPIキーは秘密ではない」——これはモバイル固有の制約ではなく、クライアント配布物すべてに共通する原則です(Webの環境変数の漏洩防止Supabaseキーの露出と同じ構図)。

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

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

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 が必要)。「動いたつもり」で本番に出さないこと。

なお、生体認証は認可の代替ではありません。サーバー側の認証・認可が本体で、生体認証は端末ローカルの「本人確認」に過ぎない、という位置づけを崩さないでください。

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

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

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

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

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

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

導入チェックリスト(MASVS領域別)

  • STORAGE — トークン等は AsyncStorage ではなく expo-secure-store に保管し、書き込みエラーを処理しているか。ログアウト時に削除しているか(iOSは再インストールで残る)。
  • STORAGE — Android の自動バックアップから SecureStore を除外したか。
  • CODEEXPO_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の領域ごとに漏れなく設計する——これが、審査にも監査にも耐えるモバイルアプリの作り方です。

Câu hỏi thường gặp

AsyncStorage にアクセストークンを保存してはいけないのはなぜですか?
React Native の公式ドキュメントが AsyncStorage を『非暗号化(unencrypted)』と明記し、用途として『トークン保管』『シークレット』を使ってはいけない側に分類しているためです。端末が侵害された場合やバックアップ経由で平文のまま読まれ得ます。トークンなど小さな秘密は expo-secure-store(iOS は Keychain、Android は Keystore で暗号化された SharedPreferences)に保管します。
expo-secure-store に 2048 バイトの上限があると聞きましたが本当ですか?
現在の公式ドキュメントでは撤回されています。記載は『大きなペイロードはプラットフォーム側に拒否されることがある。歴史的に一部のiOSリリースはおよそ2048バイトを超える値を拒否した。Expo は上限を課さない』という趣旨で、固定の上限ではありません。したがって正しい実装要件は『上限を前提にする』ことではなく、『書き込み失敗(ネイティブ側の拒否)を必ずハンドリングする』ことです。
OWASP MASVS の L1 / L2 / R というレベルはどう使えばいいですか?
使いません。MASVS v2.0.0 で検証レベル(L1/L2/R)は廃止され、『MAS Testing Profiles』として MASWE 側へ再編されました(MASWE は現在Beta)。2026年時点でL1/L2/Rを前提にしている解説は古い版に基づいています。現在は8つの管理領域(MASVS-STORAGE / CRYPTO / AUTH / NETWORK / PLATFORM / CODE / RESILIENCE / PRIVACY)で要件を整理します。
Expo で証明書ピンニング(certificate pinning)はできますか?
Expo に一次APIはなく、公式ドキュメントにもピンニングの記載はありません。実現するには prebuild(CNG)+config plugin でネイティブ設定(Android の Network Security Config など)を書く必要があります。加えて運用コストが大きく、証明書を更新すると古い証明書を埋め込んだ既存アプリが接続できなくなります(React Native 公式も警告)。バックアップpinと有効期限の設計が必須です。
生体認証(Face ID / 指紋)で見落としやすい設定は?
3点あります。①Android の biometricsSecurityLevel は既定が 'weak'(より弱い Class 2 を許可)なので、'strong' を明示する。②iOS で NSFaceIDUsageDescription を app.json に設定していないと、Face ID ではなく端末パスコード認証へ静かに降格します。③Face ID は Expo Go では検証できず、development build が必要です。
APIキーをアプリに埋め込んでもよいですか?
いけません。EXPO_PUBLIC_ で始まる環境変数は、ビルド時にバンドルへ平文で埋め込まれます(公式が『コンパイル済みアプリ内に平文で見える』と警告)。埋めてよいのは公開前提の値(APIのURLなど)だけです。秘密はサーバー側(EAS Secrets / 環境変数)に置き、端末へは短命なトークンだけを渡す設計にします。

Tài liệu tham khảo

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

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.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

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

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

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

Cũng đáng đọc