リリース前の点検で、書き出したばかりの JavaScript バンドルを grep にかけていました。探していたのは、自分の手で EXPO_PUBLIC_ を付けた解析用のキーです。
出てきました。想定どおりではあるのですが、少し落ち着かない結果でもありました。その値は EAS 側で可視性を絞ってあり、私はしばらくのあいだ、そこを絞れば成果物の中身のほうも絞れるのだと受け取っていたからです。
実際には、二つはまったく別のことを決めています。接頭辞は成果物に入るかどうかを決め、可視性は誰がその値を読めるかを決めます。接頭辞は成果物、可視性は経路。 ここに辿り着いてから、鍵の置き場所で迷う時間がほとんどなくなりました。
接頭辞と可視性は、別々のことを決めています
Expo の環境変数で最初に噛み合わなくなるのは、この二つを一本の「安全レベル」だと考えてしまうところです。並べて書くと、重なっていないことがはっきりします。
| 軸 | 決めていること | 効き方 |
EXPO_PUBLIC_ の接頭辞 | その値がクライアントのコードに埋め込まれるかどうか | バンドル時に process.env.EXPO_PUBLIC_* が値そのものへ置き換わります |
| EAS の可視性(plaintext / sensitive / secret) | その値を誰がどこで読めるか | ダッシュボード・EAS CLI・ジョブのログでの見え方が変わります |
つまり、可視性を secret にしても、名前が EXPO_PUBLIC_ で始まっていて、その値をクライアントのコードから参照しているのであれば、成果物には入ります。Expo のドキュメントも、Environment variables in EAS で secret について「アプリ自体に埋め込む値に追加の安全性を与えるものではない」と書いています。
私はこの一文を、最初に読んだときには通り過ぎていました。読み落としたというよりも、secret という語の手触りに引っ張られていたのだと思います。
secret が実際に止めているのは、EAS の外側だけです
secret 指定が効くのは、値が EAS のサーバーの外へ出ていく経路のほうです。止まるものと止まらないものを、はっきり分けておきます。
止まるもの。ダッシュボードでの表示、EAS CLI からの読み出し、ジョブのログへの出力、そして eas env:pull でローカルの .env に降りてくること。ここは確かに閉じます。
止まらないもの。ビルドジョブの中で環境変数として渡り、そこでバンドラーがクライアントのコードへ埋め込む経路です。ビルドは EAS のサーバーの内側で走りますので、secret でも値は届きます。届いた値は、そのまま成果物の一部になります。
「誰にも読ませたくない」と「アプリに入れたくない」は、言葉が似ているだけで別の要求です。前者は可視性、後者は接頭辞と設計の側で満たすことになります。
接頭辞と secret を重ねると、更新のときだけ値が消えます
ここが、仕組みの側から先に見つけておいてよかったと感じている点です。EXPO_PUBLIC_ の変数を secret にすると、経路によって結果が変わります。
ビルドのとき
EAS Build のジョブは EAS のサーバーで走りますので、secret の値もジョブに渡ります。バンドルは値が入った状態で作られ、ストアに出す版では期待どおりに動きます。
更新のとき
eas update は事情が違います。secret の変数は EAS のサーバーの外では読めない扱いですので、更新の処理では使われません。process.env.EXPO_PUBLIC_ANALYTICS_KEY は置き換えられないまま、undefined として残ります。
症状の出方
本番運用でつまずくのは、この差が現れるタイミングです。審査を通り、ストアに並び、しばらく問題なく動いていたアプリに、あとから OTA 更新を当てた瞬間だけ設定が空になります。
// 置き換えが起きなかったとき、これは文字列 "undefined" ではなく
// undefined そのものになります。空文字と混同しないよう気をつけます
const key = process.env.EXPO_PUBLIC_ANALYTICS_KEY;
// よくある書き方。ここでは何も起きず、送信先だけが静かに壊れます
analytics.configure({ apiKey: key });
リリースノートには「文言の修正」としか書いていない更新です。ネイティブのコードは何も変わっていません。それでも、更新を受け取った端末だけが別の挙動になります。原因を追う側から見ると、これはかなり意地の悪い形の食い違いです。
規約の側でも似た構図があります。EAS Update は SDK 55 以降で --environment の指定が必須になりましたので、ビルドと更新で別の環境を指しているだけでも、値の組み合わせが変わります。eas.json の image を書かないままにしたときに動くものと同じで、書かなかった項目には既定の解釈が入り、その解釈は自分の意図とは無関係に働きます。
出荷物に何が入ったかは、自分の手で確かめられます
推測で済ませずに、書き出したものを見に行きます。手順は3つで、5分もかかりません。
- ローカルで本番と同じ環境変数を読み込みます(
eas env:pull --environment production で降りてくる範囲を確認します)
npx expo export でバンドルを書き出します
- 書き出したファイルを、自分の鍵の一部で検索します
#!/usr/bin/env bash
# ship-check.sh — 出荷前に「実際に何が埋まったか」を確かめます
# 使い方: ./ship-check.sh "sk_test_" "api.internal.example.com"
set -euo pipefail
OUT_DIR="dist"
rm -rf "$OUT_DIR"
npx expo export --platform ios --output-dir "$OUT_DIR" >/dev/null
FOUND=0
for NEEDLE in "$@"; do
# テキストのバンドルと、Hermes バイトコードの両方を見ます
HITS=$(grep -rl --binary-files=text -F "$NEEDLE" "$OUT_DIR" 2>/dev/null || true)
if [ -n "$HITS" ]; then
echo "❌ 成果物に含まれています: $NEEDLE"
echo "$HITS" | sed 's/^/ /'
FOUND=1
else
echo "✅ 見つかりません: $NEEDLE"
fi
done
exit "$FOUND"
Hermes のバイトコードになっていても、文字列テーブルには残りますので --binary-files=text を付けた検索で拾えます。ここで出てはいけない値が出たなら、その鍵はもう公開されたものとして扱います。
逆に、出るべき値が出てこないときも収穫です。EXPO_PUBLIC_ の付け忘れか、可視性が secret のままか、参照している名前が違うかのいずれかに絞り込めます。
鍵を三つの箱に仕分けます
私は個人開発でアプリを何本か並べて運用しておりますが、鍵の置き場所は次の三つしか使っていません。迷ったときに戻れる場所を減らしておくと、判断が速くなります。
| 箱 | 置き方 | 入るもの |
| クライアントに入れてよい識別子 | EXPO_PUBLIC_ あり/可視性は plaintext か sensitive | AdMob の広告ユニット ID、Sentry の DSN、公開前提の API のベース URL、行動計測の公開キー |
| ビルドのときだけ要る値 | 接頭辞なし/可視性は sensitive か secret | ソースマップ送信用のトークン、google-services.json のようなファイル、ストア提出用の資格情報 |
| 端末に渡してはいけない値 | そもそも EAS にも置かず、サーバー側に閉じる | 生成 AI の API キー、決済の秘密鍵、データベースの管理者権限キー |
三つ目の箱に入るものは、環境変数の設定をどう工夫しても解決しません。自分のサーバーやエッジの関数を1枚挟んで、端末にはそこへのアクセス権だけを渡す形にします。この層を置くと、鍵の失効も呼び出し回数の制限も、アプリを出し直さずに変えられるようになります。
判断に迷う値が出てきたときは、「これが第三者の手に渡ったとき、私は請求書と向き合うことになるか」を基準にしています。請求が発生し得る鍵は、例外なく三つ目の箱へ置くことを推奨します。
仕分けが崩れていないかを、機械に見張らせます
人の目で守る決まりは、忙しい週に崩れます。EAS の一覧は JSON で取り出せますので、矛盾の検出はスクリプトに任せます。
# --json は --non-interactive を含みます。sensitive の値も名前と可視性だけ見たいので、
# ここでは値そのものは取り出しません
eas env:list --environment production --json > env-production.json
// audit-env.js — 接頭辞と可視性の食い違いを見つけます
// 実行: node audit-env.js env-production.json
const fs = require('fs');
const raw = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const vars = Array.isArray(raw) ? raw : (raw.variables ?? []);
// 端末に渡してはいけない語を含む名前。自分の運用に合わせて足していきます
const SERVER_ONLY = /(SECRET|PRIVATE|SERVICE_ROLE|_TOKEN|ADMIN|WEBHOOK)/i;
const problems = [];
for (const v of vars) {
const name = v.name ?? v.key;
const visibility = String(v.visibility ?? '').toLowerCase();
const isPublic = name.startsWith('EXPO_PUBLIC_');
// (1) 更新のときだけ消える組み合わせ
if (isPublic && visibility === 'secret') {
problems.push(`${name}: EXPO_PUBLIC_ と secret の組み合わせです。ビルドでは埋まり、eas update では埋まりません`);
}
// (2) 端末に渡ってはいけない語が、公開側に置かれている
if (isPublic && SERVER_ONLY.test(name)) {
problems.push(`${name}: 名前の意味と置き場所が合っていません。サーバー側へ移します`);
}
// (3) 秘密として扱いたい値が plaintext のまま
if (!isPublic && SERVER_ONLY.test(name) && visibility === 'plaintext') {
problems.push(`${name}: plaintext です。sensitive 以上へ上げるか、EAS の外へ出します`);
}
}
if (problems.length > 0) {
console.error('環境変数の仕分けに食い違いがあります:');
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}
console.log(`✅ ${vars.length} 件を確認しました。食い違いはありません`);
EAS CLI の版によって JSON の形が違いますので、配列でも variables キー付きでも読めるようにしてあります。最初に一度だけ、手元の出力を目で見て確かめておくと安心です。
このスクリプトはリリース用のワークフローの入口に置いています。ビルドを始める前に落ちてくれるほうが、審査を通ったあとで気づくよりもはるかに安いのです。
生成されたコードには、最初にこの一手をかけます
Rork のような AI のアプリビルダーで作り始めると、外部サービスの鍵は会話の流れで自然に登場します。プロンプトに鍵を貼り、生成されたコードがそれを fetch の見出しに直接書く、という形になりがちです。動くものが早く出てくるぶん、置き場所の判断だけが後回しになります。
最初にかける一手は2つです。プロンプトに貼った鍵は、動作を確認できた時点で失効させて発行し直します。そして、クライアントに残す値には必ず接頭辞を付けて、残さない値はサーバー側の関数へ移します。
// app/config/env.js — 起動時に「あるべき値があるか」を見ます
// 本番では落とさず、開発と内部配布のときだけ気づける形にします
const REQUIRED = ['EXPO_PUBLIC_API_BASE_URL', 'EXPO_PUBLIC_ANALYTICS_KEY'];
export function assertPublicEnv() {
const missing = REQUIRED.filter((name) => !process.env[name]);
if (missing.length === 0) return;
const message = `公開環境変数が置き換わっていません: ${missing.join(', ')}`;
if (__DEV__) {
throw new Error(message); // 開発中はここで止めます
}
// 本番はクラッシュさせず、計測にだけ残します。
// OTA 更新の版でだけ欠ける現象は、この1行があると当日中に見つかります
reportIssue('public_env_missing', { missing, channel: releaseChannelName() });
}
本番でクラッシュさせない理由は、この欠落が読者の操作とは無関係に起きるからです。落として気づくよりも、静かに記録して自分だけが気づくほうが、受け取る側の1日を壊しません。
埋まってしまった値は、あとから取り消せません
ここが、環境変数の話でいちばん見落とされやすいところだと感じています。成果物に入った値は、更新の配布では回収できません。すでにその版を持っている端末は、これからも同じ値を持ち続けます。
ですので、対処は「差し替える」ではなく「失効させる」になります。順番としては、新しい鍵を発行し、サーバー側で両方をしばらく受け付け、新しい版が行き渡ってから古い鍵を止める、という段取りです。行き渡り方を見ながら進める点では、段階的なリリースの回し方と同じ考え方になります。
呼び出す側を検証する仕組みを先に入れておくと、この作業がずいぶん楽になります。鍵そのものの秘匿に頼らず、正規のアプリからの呼び出しかどうかを別の軸で確かめる形です。Firebase App Check を段階的に有効化する設計は、まさにその層を足す話でした。
私は最初のうち、鍵を隠すことばかりを考えていました。結果は芳しくありませんでした。いまは、隠しきれない前提で「渡してよいものだけを渡し、渡したものは失効できるようにしておく」という線引きに置き換えています。
明日、最初にやること
出荷前の点検に、バンドルの検索を1行だけ足してみてください。自分の鍵の一部を ship-check.sh に渡して走らせるだけで、いま出そうとしている版に何が入っているかが分かります。
そこで想定外のものが出てきたなら、それは今日のうちに失効させられる鍵です。すでに配ってしまった版の鍵よりも、はるかに安く片づきます。
私自身もまだ、置き場所の判断を一つずつ確かめながら進めております。同じところで手を止めた方の役に立てば幸いです。お読みいただき、ありがとうございました。