取り組みの背景 — なぜ「セキュリティ」が個人開発者にも必須なのか
アプリをリリースすると、必ず直面するのがセキュリティの問題です。個人情報を扱うアプリはもちろん、ユーザーの認証情報・決済情報・ヘルスデータを扱う場合、不十分なセキュリティ実装は App Store の審査落ちや、最悪の場合はデータ流出事故につながります。
Rork で開発した React Native / Expo ベースのアプリも、Web の常識とは異なるモバイル固有のセキュリティ脅威に晒されています。特に以下の 5 点は、2026 年現在の App Store / Google Play 審査でも指摘されやすい項目です。
- ローカルデータの平文保存(AsyncStorage にトークンや個人情報を保存している)
- 通信の中間者攻撃耐性がない(証明書ピンニング未対応)
- 認証フローの脆弱性(トークンの有効期限管理が甘い)
- ルート・脱獄端末への無防備な動作
- ATT・プライバシー情報の不備(iOS 14 以降の要件)
ここではこれらすべてに対する実践的な対応策を Rork プロジェクトに即した形でコード付きで解説します。対象読者はアプリを本番リリース済み、またはリリースを控えた中〜上級の個人開発者です。各セクションは独立して読めるよう構成していますので、必要な部分から実装を始めてください。
セキュリティ設計の基本原則
実装に入る前に、モバイルアプリのセキュリティ設計で守るべき 3 つの原則を理解しておきましょう。
1. 最小権限の原則(Principle of Least Privilege) アプリが必要とする権限は必要最小限に留める。位置情報をバックグラウンドで常時取得する必要がなければ「使用中のみ」を選択します。不要な権限はApp Storeレビューでの指摘対象になるだけでなく、攻撃面を広げます。
2. 深層防護(Defense in Depth) 1 つのセキュリティ機構が突破されても、次の防衛ラインが機能するよう多層構造にします。例えば「通信は暗号化+証明書ピンニング」「ローカルデータはセキュアストレージ+暗号化」のように複数の防護層を設ける。
3. セキュリティ・バイ・デフォルト(Secure by Default) 新機能追加時にセキュリティを後付けで考えるのではなく、設計段階からセキュアな実装を選択する習慣を持ちます。Rork でコンポーネントを生成する際も、フォームなら入力バリデーション、API連携なら認証ヘッダーの扱いをプロンプトに明記します。
生体認証の完全実装(expo-local-authentication)
基本セットアップ
# Rork / Expo プロジェクトに追加
npx expo install expo-local-authenticationapp.json または app.config.ts にパーミッション設定を追加します。
{
"expo": {
"ios": {
"infoPlist": {
"NSFaceIDUsageDescription": "本人確認のためにFace IDを使用します"
}
},
"android": {
"permissions": ["USE_BIOMETRIC", "USE_FINGERPRINT"]
}
}
}生体認証フック
全画面で使い回せるカスタムフックとして実装するのがベストプラクティスです。
// hooks/useBiometricAuth.ts
import * as LocalAuthentication from 'expo-local-authentication';
import { useState, useCallback } from 'react';
export type BiometricType = 'fingerprint' | 'facial' | 'iris' | 'none';
interface BiometricAuthResult {
success: boolean;
error?: string;
}
export function useBiometricAuth() {
const [isAuthenticating, setIsAuthenticating] = useState(false);
// デバイスが生体認証をサポートしているか確認
const checkSupport = useCallback(async (): Promise<{
supported: boolean;
type: BiometricType;
enrolled: boolean;
}> => {
const compatible = await LocalAuthentication.hasHardwareAsync();
if (!compatible) return { supported: false, type: 'none', enrolled: false };
const enrolled = await LocalAuthentication.isEnrolledAsync();
const types = await LocalAuthentication.supportedAuthenticationTypesAsync();
// 認証タイプの判定(優先度: 顔認証 > 指紋 > 虹彩)
let type: BiometricType = 'none';
if (types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)) {
type = 'facial';
} else if (types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) {
type = 'fingerprint';
} else if (types.includes(LocalAuthentication.AuthenticationType.IRIS)) {
type = 'iris';
}
return { supported: true, type, enrolled };
}, []);
// 認証実行
const authenticate = useCallback(
async (promptMessage?: string): Promise<BiometricAuthResult> => {
setIsAuthenticating(true);
try {
const { supported, enrolled } = await checkSupport();
if (!supported) {
return { success: false, error: 'このデバイスは生体認証に対応していません' };
}
if (!enrolled) {
return { success: false, error: '生体認証が設定されていません。設定アプリから登録してください' };
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: promptMessage ?? '本人確認',
fallbackLabel: 'パスコードを使用',
cancelLabel: 'キャンセル',
disableDeviceFallback: false, // パスコードへのフォールバックを許可
});
if (result.success) {
return { success: true };
}
// エラーコードに応じたメッセージ
switch (result.error) {
case 'user_cancel':
return { success: false, error: 'キャンセルされました' };
case 'lockout':
return { success: false, error: 'ロックアウト中です。しばらく待ってから再試行してください' };
case 'lockout_permanent':
return { success: false, error: '認証がロックされました。パスコードで解除してください' };
default:
return { success: false, error: '認証に失敗しました' };
}
} finally {
setIsAuthenticating(false);
}
},
[checkSupport]
);
return { authenticate, checkSupport, isAuthenticating };
}画面遷移への組み込み
// screens/ProtectedScreen.tsx
import { useBiometricAuth } from '../hooks/useBiometricAuth';
import { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
export function ProtectedScreen() {
const { authenticate, checkSupport } = useBiometricAuth();
const [isUnlocked, setIsUnlocked] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// 画面表示時に自動で認証を開始
handleAuth();
}, []);
const handleAuth = async () => {
const { supported, enrolled } = await checkSupport();
if (!supported || !enrolled) {
// 生体認証が使えない場合は直接表示(または代替フローへ)
setIsUnlocked(true);
return;
}
const result = await authenticate('この操作には本人確認が必要です');
if (result.success) {
setIsUnlocked(true);
} else {
setError(result.error ?? '認証に失敗しました');
}
};
if (!isUnlocked) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
{error ? (
<Text style={{ color: 'red' }}>{error}</Text>
) : (
<ActivityIndicator />
)}
</View>
);
}
return <View>{/* 保護されたコンテンツ */}</View>;
}セキュアストレージの実装(expo-secure-store)
AsyncStorage はデータを平文で保存するため、認証トークン・APIキー・個人情報には絶対に使用してはいけません。代わりに expo-secure-store を使用します。iOS では Keychain、Android では EncryptedSharedPreferences を使用するため、OS レベルの暗号化保護が受けられます。
npx expo install expo-secure-storeセキュアストレージサービス
// services/secureStorage.ts
import * as SecureStore from 'expo-secure-store';
const KEYS = {
AUTH_TOKEN: 'auth_token',
REFRESH_TOKEN: 'refresh_token',
USER_PROFILE: 'user_profile_encrypted',
BIOMETRIC_ENABLED: 'biometric_enabled',
} as const;
type StorageKey = (typeof KEYS)[keyof typeof KEYS];
// SecureStore は文字列のみ保存可能なのでオブジェクトはJSONシリアライズ
async function setItem(key: StorageKey, value: string): Promise<void> {
await SecureStore.setItemAsync(key, value, {
// iOS: アプリがフォアグラウンド・バックグラウンドどちらでもアクセス可能
// 端末ロック中は不可(セキュリティとUXのバランスを考慮して選択)
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
}
async function getItem(key: StorageKey): Promise<string | null> {
return SecureStore.getItemAsync(key);
}
async function removeItem(key: StorageKey): Promise<void> {
await SecureStore.deleteItemAsync(key);
}
// 公開API
export const secureStorage = {
// トークン管理
saveAuthToken: (token: string) => setItem(KEYS.AUTH_TOKEN, token),
getAuthToken: () => getItem(KEYS.AUTH_TOKEN),
removeAuthToken: () => removeItem(KEYS.AUTH_TOKEN),
saveRefreshToken: (token: string) => setItem(KEYS.REFRESH_TOKEN, token),
getRefreshToken: () => getItem(KEYS.REFRESH_TOKEN),
removeRefreshToken: () => removeItem(KEYS.REFRESH_TOKEN),
// 生体認証設定
setBiometricEnabled: (enabled: boolean) =>
setItem(KEYS.BIOMETRIC_ENABLED, String(enabled)),
isBiometricEnabled: async () => {
const val = await getItem(KEYS.BIOMETRIC_ENABLED);
return val === 'true';
},
// ログアウト時に全トークンをクリア
clearAll: async () => {
await Promise.all([
removeItem(KEYS.AUTH_TOKEN),
removeItem(KEYS.REFRESH_TOKEN),
]);
},
};JWT トークン管理と自動リフレッシュ戦略
アクセストークンの有効期限を短く(15〜30分)保ちつつ、リフレッシュトークンで自動更新する仕組みは、セキュリティと UX を両立するための標準パターンです。
// services/authService.ts
import { secureStorage } from './secureStorage';
interface TokenPayload {
exp: number;
sub: string;
}
// JWTのペイロードをデコード(検証なし・クライアントサイドの有効期限チェック用)
function decodeJwtPayload(token: string): TokenPayload | null {
try {
const base64Payload = token.split('.')[1];
const payload = JSON.parse(atob(base64Payload));
return payload;
} catch {
return null;
}
}
// トークンが切れる60秒前から「期限切れ」とみなす
function isTokenExpiringSoon(token: string): boolean {
const payload = decodeJwtPayload(token);
if (!payload) return true;
const now = Math.floor(Date.now() / 1000);
return payload.exp - now < 60;
}
// APIクライアントのインターセプターとして使用
export async function getValidAccessToken(): Promise<string | null> {
const accessToken = await secureStorage.getAuthToken();
if (!accessToken) return null;
// まだ有効なトークンはそのまま返す
if (!isTokenExpiringSoon(accessToken)) return accessToken;
// 期限切れ間近 → リフレッシュを試みる
const refreshToken = await secureStorage.getRefreshToken();
if (!refreshToken) {
await secureStorage.clearAll();
return null;
}
try {
const response = await fetch('https://your-api.example.com/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
// リフレッシュ失敗 → 強制ログアウト
await secureStorage.clearAll();
return null;
}
const { accessToken: newAccessToken, refreshToken: newRefreshToken } =
await response.json();
await secureStorage.saveAuthToken(newAccessToken);
if (newRefreshToken) {
await secureStorage.saveRefreshToken(newRefreshToken);
}
return newAccessToken;
} catch {
return null;
}
}データ暗号化(AES-256 実装)
Secure Store に収まらない大きなデータ(ローカルキャッシュ・オフラインデータなど)をアプリ内で保持する場合は、AES-256 で暗号化してから AsyncStorage や SQLite に保存します。
npx expo install expo-crypto// services/encryption.ts
import * as Crypto from 'expo-crypto';
import * as SecureStore from 'expo-secure-store';
const ENCRYPTION_KEY_ID = 'app_encryption_key_v1';
// 暗号化キーを生成(初回のみ)または取得
async function getOrCreateEncryptionKey(): Promise<string> {
let key = await SecureStore.getItemAsync(ENCRYPTION_KEY_ID);
if (!key) {
// 256ビットのランダムキーを生成
const bytes = await Crypto.getRandomBytesAsync(32);
key = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
await SecureStore.setItemAsync(ENCRYPTION_KEY_ID, key, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
}
return key;
}
// テキストをSHA-256でハッシュ化(保存しない値の比較用)
export async function hashData(data: string): Promise<string> {
return Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, data);
}
// 機密データをAES-GCM相当の手法で保護する簡易ラッパー
// 本番環境では react-native-aes-gcm-crypto 等のネイティブモジュール使用を推奨
export async function encryptSensitiveData(plaintext: string): Promise<string> {
const key = await getOrCreateEncryptionKey();
// キーとデータを組み合わせてハッシュ化(簡易的な暗号化)
// 本番では必ず適切なAES-GCMライブラリを使用してください
const iv = await Crypto.getRandomBytesAsync(16);
const ivHex = Array.from(iv).map(b => b.toString(16).padStart(2, '0')).join('');
const combined = await hashData(key + ivHex + plaintext);
return `${ivHex}:${combined}:${Buffer.from(plaintext).toString('base64')}`;
}証明書ピンニング — 中間者攻撃の防止
証明書ピンニングは、サーバー証明書の公開鍵をアプリ側にハードコードし、通信相手が本物のサーバーであることを検証する手法です。Expo Go では動作しないため、EAS Build(カスタムビルド)または Expo Dev Client が必要です。
npx expo install react-native-ssl-pinning// services/pinnedFetch.ts
import { fetch as sslFetch } from 'react-native-ssl-pinning';
// 証明書の SHA-256 フィンガープリント(openssl で取得)
// openssl s_client -connect your-api.example.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | base64
const PINNED_CERTS = {
'your-api.example.com': [
'AAAA1234...your_cert_fingerprint_here==', // プライマリ証明書
'BBBB5678...backup_cert_fingerprint_here==', // バックアップ証明書(ローテーション時のため必須)
],
};
export async function pinnedFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const hostname = new URL(url).hostname;
const certs = PINNED_CERTS[hostname as keyof typeof PINNED_CERTS];
if (!certs) {
// ピンニング設定がないホストは通常のfetchで対応
return fetch(url, options);
}
return sslFetch(url, {
method: options.method ?? 'GET',
headers: options.headers as Record<string, string>,
body: options.body as string,
sslPinning: {
certs,
},
}) as unknown as Response;
}重要: 証明書はローテーション(更新)するため、必ず現行証明書と次の証明書の2つ以上をピンニングしておくこと。1つだけピンニングすると証明書更新時にアプリが全ユーザーで通信不能になります。
ルート検出・脱獄検知の実装
ルート化・脱獄された端末ではアプリのサンドボックスが破られ、セキュアストレージの内容も読み取られる可能性があります。金融・医療・高セキュリティアプリでは動作を制限することが求められます。
npx expo install expo-device// utils/deviceSecurity.ts
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
export async function isDeviceCompromised(): Promise<{
compromised: boolean;
reasons: string[];
}> {
const reasons: string[] = [];
// Expo の isRootedExperimentalAsync(推測ベース)
const isRooted = await Device.isRootedExperimentalAsync();
if (isRooted) reasons.push('root_detected');
if (Platform.OS === 'ios') {
// Cydia(脱獄ツール)のインストール確認
const cydiaPath = 'file:///Applications/Cydia.app';
const cydiaInfo = await FileSystem.getInfoAsync(cydiaPath);
if (cydiaInfo.exists) reasons.push('cydia_found');
}
if (Platform.OS === 'android') {
// Superuser アプリの存在確認
const superuserPath = '/system/app/Superuser.apk';
const suInfo = await FileSystem.getInfoAsync(superuserPath);
if (suInfo.exists) reasons.push('superuser_apk_found');
}
return {
compromised: reasons.length > 0,
reasons,
};
}ATT(App Tracking Transparency)の実装
iOS 14 以降、ユーザーをトラッキングする前に明示的な許可を求める ATT が必須です。AdMob などの広告 SDK を使用している場合は特に重要です。
npx expo install expo-tracking-transparency// hooks/useTrackingPermission.ts
import {
requestTrackingPermissionsAsync,
getTrackingPermissionsAsync,
PermissionStatus,
} from 'expo-tracking-transparency';
import { Platform } from 'react-native';
export async function requestTrackingIfNeeded(): Promise<boolean> {
// Androidは ATT 不要
if (Platform.OS !== 'ios') return true;
const { status } = await getTrackingPermissionsAsync();
if (status === PermissionStatus.GRANTED) return true;
if (status !== PermissionStatus.UNDETERMINED) return false;
// 初回のみダイアログ表示
const { status: newStatus } = await requestTrackingPermissionsAsync();
return newStatus === PermissionStatus.GRANTED;
}app.json に必ず説明文を追加します。
{
"expo": {
"ios": {
"infoPlist": {
"NSUserTrackingUsageDescription": "広告のパーソナライズとアプリ改善のために使用します。許可しない場合も引き続きアプリをご利用いただけます。"
}
}
}
}よくある実装ミスとセキュリティFAQ
Q1. AsyncStorage にトークンを保存してレビューで指摘されましました。移行方法は?
AsyncStorage から expo-secure-store への段階的な移行は以下の手順で行います。
// utils/migrateStorage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { secureStorage } from '../services/secureStorage';
export async function migrateTokensToSecureStore(): Promise<void> {
// 旧ストレージからトークンを取得
const oldToken = await AsyncStorage.getItem('user_token');
if (oldToken) {
// 新しいセキュアストレージへ移行
await secureStorage.saveAuthToken(oldToken);
// 旧データを削除
await AsyncStorage.removeItem('user_token');
console.log('✅ トークンをSecureStoreに移行しました');
}
}アプリ起動時に migrateTokensToSecureStore() を一度だけ実行することで、既存ユーザーへの影響なく移行できます。
Q2. 証明書ピンニングで本番環境のみ有効にするにはどうすればよいですか?
Constants.expoConfig?.extra?.environment などで環境を判定して切り替えます。
import Constants from 'expo-constants';
const isProd = Constants.expoConfig?.extra?.environment === 'production';
export const apiFetch = isProd ? pinnedFetch : fetch;Q3. リフレッシュトークンの有効期間はどのくらいに設定すべきですか?
ユースケースによりますが、一般的な指針は次の通りです。
- 一般ユーザーアプリ: アクセストークン15〜30分 / リフレッシュトークン30〜90日(非アクティブで延長リセット)
- 金融・医療アプリ: アクセストークン5〜10分 / リフレッシュトークン24時間以内
- 高頻度使用アプリ(SNS等): アクセストークン60分 / リフレッシュトークン180〜365日
バックエンドで「最終アクティビティから N 日間」という形式で管理すると UX も向上します。
セキュリティ実装チェックリスト
本番リリース前に以下の項目をすべて確認してください。
- 認証トークンを AsyncStorage ではなく expo-secure-store に保存している
- 生体認証フォールバック(パスコード)を適切に実装している
- APIキー・秘密鍵をアプリバイナリにハードコードしていない(環境変数または Secrets Manager を使用)
- HTTPS のみを使用し、HTTP をブロックしている(iOS ATS・Android NetworkSecurityConfig)
- 証明書ピンニングを実装し、バックアップ証明書も設定している
- ルート・脱獄検知を実装し、高リスク操作を制限している
- ATT ダイアログの説明文が App Store ガイドラインに準拠している
- ログに個人情報・トークンを出力していない(本番ビルドでの
console.log無効化) - アプリバックグラウンド時に画面をブラーまたはスクリーンショットを無効化している
まとめ
ここではRork アプリを本番環境で安全に運用するための 6 つのセキュリティ実装を解説しました。
生体認証(expo-local-authentication)でユーザー体験を損なわずに認証を強化し、セキュアストレージ(expo-secure-store)で認証情報を保護します。JWT の自動リフレッシュで常に最新の短命トークンを使い、証明書ピンニングで通信の安全性を担保します。そしてルート検出と ATT で OS レベルの攻撃とプライバシー規制の両方に対応します。
これらをすべて一気に実装する必要はありません。まず「セキュアストレージへの移行」と「ATT の追加」から始めて、段階的に深化させていくアプローチが現実的です。
本記事のセキュアストレージや JWT 管理を土台に、Rork × Supabase 認証&リアルタイム機能実装ガイドやRork でユーザー認証を実装する — Firebase・Supabase 連携ガイドも合わせて参照すると、バックエンド全体のセキュリティ設計をより体系的に理解できます。証明書ピンニングの導入には EAS カスタムビルドが前提となるため、GitHub Actions × EAS Build の CI/CD 構築との同時整備をおすすめします。
モバイルセキュリティをさらに体系的に