ビルド済みの .ipa を手元で展開し、strings を通したのは、ある夜のことでした。
出力の中に、自分が EXPO_PUBLIC_ANALYTICS_KEY と名付けた文字列がそのまま並んでいました。難読化もエンコードもされていません。ビルドから抽出まで、かかった時間は10秒ほどです。
頭では知っていたはずのことでした。それでも、自分のアプリのバイナリから自分の鍵が読み上げられるのを目で見た瞬間、背筋が冷えました。
個人開発で6本のアプリを並行して運用していると、鍵の本数はすぐに二桁になります。広告、課金、分析、クラッシュレポート、翻訳 API。生成 AI にアプリの骨格を書いてもらうと、それらの初期化コードは驚くほど素早く揃います。ただし「その鍵をどこに置くべきか」という判断だけは、生成物の外側に残されたままです。
この記事は、その判断を自分の中で言語化し、6本のアプリに一巡させた記録です。
クライアントに置いた鍵は、暗号ではなく公開情報
まず前提を固めます。モバイルアプリのバンドルは、ユーザーの端末に配布される時点で「読める状態」になります。
Expo(React Native)であれば JavaScript バンドルの中に、Swift であればバイナリの文字列テーブルの中に、鍵は文字列として存在します。難読化はコストを上げますが、境界にはなりません。
実際に確認してみるのが最短です。手元のビルド成果物に対して次を実行します。
# iOS: .ipa は zip なので展開してバイナリに strings を通す
unzip -q MyApp.ipa -d /tmp/ipa
strings /tmp/ipa/Payload/MyApp.app/MyApp | grep -iE 'key|secret|token' | head -20
# Expo(JS バンドル): バンドル本体を直接 grep する
grep -oE 'EXPO_PUBLIC_[A-Z_]+"[^"]*"' /tmp/ipa/Payload/MyApp.app/main.jsbundle | head -20
# Android: apk からも同様に取り出せる
unzip -q app-release.apk -d /tmp/apk
strings /tmp/apk/classes.dex | grep -iE 'AIza|sk_live|pk_live' | head
この3コマンドで、自分のアプリが何を公開しているかが分かります。私はこれを、新しいアプリをストアに出す前の儀式にしました。
ここで見つかった文字列は、すべて「公開情報」です。隠すのではなく、公開されても損害が出ない設計にするしかありません。
鍵を3つの層に分類する
「秘密かどうか」の二択で考えると判断が止まります。実運用では3層に分けると、置き場所が自動的に決まります。
層
性質
具体例
置き場所
漏洩時の影響
層1: 公開識別子
そもそも秘密ではない
AdMob アプリ ID / ユニット ID、RevenueCat の公開 SDK キー、Sentry DSN
クライアントに直書きで可
実質なし(悪用は別の防御層で止める)
層2: 制限付き公開鍵
公開前提だが制約が必須
Firebase Web API キー、Maps API キー、分析の書き込み専用キー
クライアント可。ただし発行元でバンドル ID・リファラ・スコープを制限する
制限が甘い場合のみ従量課金の被害
層3: 真の秘密
持つ者がサーバーとして振る舞える
OpenAI / Gemini の API キー、Stripe シークレットキー、RevenueCat のシークレット API キー、Supabase の service_role キー
クライアント禁止。サーバー / エッジ関数のみ
課金の直撃、データ全件の読み書き
判定に迷ったときの問いはひとつです。「その鍵を持った他人が、私の代わりに課金できるか、私のデータを全件読めるか」 。答えが「はい」なら層3であり、EXPO_PUBLIC_ を頭に付けた瞬間に負けが確定します。
層2で最も見落とされやすいのは、発行元での制限です。Google Cloud のコンソールでキーに「アプリケーションの制限(Android パッケージ名 / iOS バンドル ID)」と「API の制限(このキーで叩ける API の列挙)」を設定していない鍵は、層2のふりをした層3です。
環境変数そのものの読み込みでつまずいている段階であれば、先にRork で環境変数が読み込まれないときの原因と対処 を確認しておくと、この先の話が地続きになります。
EXPO_PUBLIC_ が実際にやっていること
Expo の環境変数には、名前が振る舞いを決めるという特性があります。
EXPO_PUBLIC_ で始まる変数は、ビルド時に バンドルへ文字列としてインライン展開 されます。実行時に環境から読み出すのではありません。ソース上の process.env.EXPO_PUBLIC_FOO という式そのものが、"実際の値" というリテラルに置換されます。
// あなたが書いたコード
const key = process.env. EXPO_PUBLIC_ANALYTICS_KEY ;
// バンドルに出力されるコード(イメージ)
const key = "a1b2c3d4e5f6..." ;
つまり .env を .gitignore に入れても、EAS の Secrets に登録しても、EXPO_PUBLIC_ を付けた時点で結果は同じです。Git には残らないが、ユーザーの端末には残ります。
一方、接頭辞のない変数はバンドルに入りません。その代わりクライアントのコードからは読めません。これは制限ではなく、境界が正しく機能している証拠です。読めないものは、読めない場所(サーバー)で使います。
生成されたコードを受け取ったとき、私が最初に走らせるのはこの一行です。
grep -rn "EXPO_PUBLIC_" app/ src/ 2> /dev/null | grep -viE "URL|ENV|VERSION|SENTRY_DSN"
残った行が、層1・層2として本当に妥当かを一件ずつ見ます。6本のアプリで最初に流したときは、合計で3件が層3の混入でした。3件とも、翻訳 API と生成 AI の呼び出しに使う鍵です。
層3の鍵をエッジプロキシへ逃がす
層3が見つかったときの移送先は、自前のサーバーである必要はありません。エッジ関数で十分です。Cloudflare Workers の場合、次の実装がそのまま動きます。
// worker.js — 生成 AI API への薄いプロキシ
// 鍵は Workers の Secret(env.UPSTREAM_API_KEY)にのみ存在する
const ALLOWED_ORIGINS = new Set ([ "https://myapp.example.com" ]);
const MAX_BODY_BYTES = 8 * 1024 ;
export default {
async fetch ( request , env ) {
if (request.method !== "POST" ) {
return new Response ( "Method Not Allowed" , { status: 405 });
}
// 1. アプリの正当性を確認する(後述の App Attest / Play Integrity トークン)
const attestation = request.headers. get ( "X-App-Attestation" );
if ( ! ( await verifyAttestation (attestation, env))) {
return new Response ( "Unauthorized" , { status: 401 });
}
// 2. 端末単位のレート制限(KV に 60 秒 TTL でカウンタを置く)
const deviceId = request.headers. get ( "X-Device-Id" ) ?? "unknown" ;
const bucket = `rl:${ deviceId }:${ Math . floor ( Date . now () / 60000 ) }` ;
const count = Number (( await env. RATE_KV . get (bucket)) ?? 0 );
if (count >= 20 ) {
return new Response ( "Too Many Requests" , { status: 429 });
}
await env. RATE_KV . put (bucket, String (count + 1 ), { expirationTtl: 90 });
// 3. 入力サイズを絞る(課金の暴走を止める最も安い方法)
const body = await request. text ();
if (body. length > MAX_BODY_BYTES ) {
return new Response ( "Payload Too Large" , { status: 413 });
}
// 4. 鍵をここで初めて付与して上流へ渡す
const upstream = await fetch ( "https://api.example-ai.com/v1/messages" , {
method: "POST" ,
headers: {
"Content-Type" : "application/json" ,
Authorization: `Bearer ${ env . UPSTREAM_API_KEY }` ,
},
body,
});
// 5. 上流のヘッダをそのまま返さない(鍵の痕跡・内部情報の遮断)
return new Response (upstream.body, {
status: upstream.status,
headers: { "Content-Type" : "application/json" },
});
} ,
} ;
async function verifyAttestation ( token , env ) {
if ( ! token) return false ;
// App Attest / Play Integrity の検証結果をキャッシュする実装に差し替える
const cached = await env. RATE_KV . get ( `att:${ token }` );
return cached === "ok" ;
}
なぜこの形なのか。プロキシは「鍵を隠す場所」であると同時に「損害の上限を決める場所」だからです。
鍵を隠すだけなら、上流へ素通しするだけの Worker で足ります。しかし素通しのプロキシは、鍵の代わりに URL が漏れる だけです。誰でも叩ける公開エンドポイントが、あなたの請求書に直結します。
だからレート制限(手順2)、入力サイズ上限(手順3)、端末の正当性検証(手順1)の3つを最初から入れます。私の運用では、1端末あたり毎分20リクエストという上限で、正規の利用が阻害されたことは一度もありません。
端末の正当性検証を本格的に運用するなら、Firebase App Check を段階的に enforce していく手順が実務的です。導入の順序と切り戻しの判断についてはFirebase App Check を段階的に enforce する運用手順 にまとめています。
「漏れている前提」で回すローテーション手順書
境界を引いても、鍵は漏れます。GitHub への誤コミット、スクリーンショット、ログ出力、退職した協力者の端末。前提を「漏れない」ではなく「漏れたときに何分で無害化できるか」に置き換えます。
私が各アプリの docs/ に置いている手順書は、次の6ステップです。所要は、慣れれば1鍵あたり15〜25分です。
止血(0〜5分) : 発行元コンソールで該当キーを 無効化ではなく制限強化 する。いきなり削除すると、稼働中のユーザーが全員エラーになり、原因の切り分けができなくなる。まず IP・バンドル ID・呼び出し可能 API を最小に絞る。
影響範囲の確定(5〜10分) : 発行元の使用量ダッシュボードで、直近24時間の呼び出し元と急増の有無を見る。課金系の鍵なら、この時点で上限アラートを引き下げる。
新しい鍵の発行(10〜13分) : 旧鍵を消す前に新鍵を作る。両方が有効な重複期間を作ることが、ダウンタイムを避ける唯一の方法。
配布(13〜20分) : 層3の鍵ならエッジ関数の Secret を差し替えるだけで完了する。アプリの再ビルドも審査も不要 。ここが、層3をプロキシへ逃がしておく最大の実利です。層2の鍵がクライアントに埋まっている場合は、OTA 更新か再申請が必要になり、反映まで数時間から数日かかります。
旧鍵の失効(20分〜) : 新鍵での通信が成功していることをログで確認してから、旧鍵を削除する。重複期間は長くても24時間にとどめる。
記録 : 日時・原因・鍵の層・所要時間を1行で残す。この行が、次の四半期に「どの鍵をプロキシへ移すべきか」を教えてくれます。
手順4で気づかれた通り、鍵の層は「ローテーションの速度」に直結します 。層3をプロキシに逃がしてある限り、緊急対応は Secret の差し替えで終わります。同じ鍵がクライアントに埋まっていたら、App Store の審査待ちの間ずっと漏れた鍵が生き続けます。
私はこの非対称性を知ってから、「クライアントに置くか否か」を、セキュリティの問題ではなく 復旧時間の問題 として考えるようになりました。
生成コードで繰り返し起きること、そして CI で止める
Rork のような生成環境は、動くコードを最短で出します。その性質上、「とりあえずクライアントから直接叩く」形になりやすいのは自然なことです。責めるべき挙動ではなく、こちらが受け取り方を決めておく話だと考えています。
6本のアプリで観測した混入パターンは、次の3つに集約されました。
EXPO_PUBLIC_ の過剰付与 : 変数が読めないというエラーを解消するため、接頭辞を付けて解決してしまう。エラーは消え、鍵は公開される
サンプル値の残存 : 生成時のプレースホルダが、本番の鍵に置換されないままコミットされる。逆に、本物の鍵がサンプルとして README に残る
ログへの流出 : console.log(config) が設定オブジェクト全体を出力し、リリースビルドでも残る
これらは人間のレビューでは漏れます。私は次のスクリプトを CI の pre-push に入れました。
#!/usr/bin/env bash
# scripts/secret_boundary_gate.sh — 層3の鍵がクライアント側へ混入していないか検査する
set -euo pipefail
FAIL = 0
# 1. 層3の鍵の実フォーマットがソースに存在しないか
# (プレースホルダは YOUR_API_KEY 等に統一しておく)
if grep -rnE 'sk_live_[A-Za-z0-9]{8,}|sk-[A-Za-z0-9]{20,}|service_role' app/ src/ 2> /dev/null ; then
echo "❌ 層3の鍵らしき文字列がクライアントコードに存在します"
FAIL = 1
fi
# 2. EXPO_PUBLIC_ の許可リスト方式(新設時は明示的に追加させる)
ALLOWED = 'EXPO_PUBLIC_API_URL|EXPO_PUBLIC_ENV|EXPO_PUBLIC_SENTRY_DSN|EXPO_PUBLIC_REVENUECAT_PUBLIC_KEY'
if grep -rhoE 'EXPO_PUBLIC_[A-Z0-9_]+' app/ src/ 2> /dev/null \
| sort -u | grep -vE "^(${ ALLOWED })$" | grep . ; then
echo "❌ 許可リストにない EXPO_PUBLIC_ 変数です。層1/層2であることを確認して許可リストへ追加してください"
FAIL = 1
fi
# 3. 設定オブジェクト全体のログ出力
if grep -rnE 'console\.log\((config|env|process\.env)\)' app/ src/ 2> /dev/null ; then
echo "❌ 設定オブジェクトをそのままログ出力しています"
FAIL = 1
fi
[ " $FAIL " -eq 0 ] && echo "✅ 秘密境界: 違反なし"
exit " $FAIL "
許可リスト方式にしている理由は、禁止リストが常に後追いになるからです。新しい鍵が増えたとき、CI が落ちて開発者に「これは層1ですか、層2ですか」と問いかける。その一瞬の立ち止まりが、境界を守ります。
導入から3か月で、このゲートは4回落ちました。4回とも、書いた本人(私です)が接頭辞を反射的に付けた瞬間でした。
生成物を受け取る側の検査という意味では、依存パッケージにも同じ発想が要ります。そちらは生成された依存関係の監査とサプライチェーン衛生 で扱いました。
明日、最初にやること
手元でリリース済みのアプリを1本選び、その .ipa か .apk を展開して strings に通してみてください。10秒で終わります。
そこに現れた文字列を、この記事の3層表に当てはめる。層3が1件でもあれば、その鍵のローテーション手順書を先に書く。プロキシの実装はその後で構いません。順番を逆にすると、移送の途中で本番が止まったときに戻る場所がなくなります。
私自身、6本すべてを一巡させるのに3週間かかりました。焦って一度に切り替えず、1本ずつ、重複期間を長めに取りながら進めるのが結局は近道でした。
境界を引く作業は地味で、リリースノートに書けることも増えません。それでも、自分のアプリのバイナリを自分で開いて中を見られるようになったとき、運用に対する感覚がひとつ確かになりました。お読みいただきありがとうございました。