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-store。iOS では Keychain(kSecClassGenericPassword)、Android では 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-properties の usesCleartextTraffic(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 を除外したか。
- 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の領域ごとに漏れなく設計する——これが、審査にも監査にも耐えるモバイルアプリの作り方です。