.env は .gitignore に入っていて、リポジトリにも鍵は残っていません。個人開発のアプリでは、そこまで確認して「大丈夫」と思ってしまいがちです。ですが、利用者の手元に届くのはソースではなく、書き出されたバンドルです。
そこで、空の Expo プロジェクトを作り、ダミーの鍵をわざと置いてから expo export を実行し、出来上がったファイルの中を自分の手で探してみました。確かめる場所をソースからバンドルへ移す、というのが今回の試みです。
最初にお伝えしたいのは、鍵の確認はソースではなく、書き出したバンドルに対して行うということです。ここから、その手順を、実際に動かした結果と一緒に書き残してまいります。
きっかけは、公開前にアプリから AI 画像生成の呼び出し方を見直した方の記録(Zenn の記事)を読んだことでした。鍵は隠せていても、アプリを通さずに呼ばれたらどうなるか、という視点は、自分のアプリにも当てはまると感じました。
試した環境
手元の確認用に、空のプロジェクトを作って試しています。値はすべてダミーで、実在の鍵は使っていません。
| 項目 | 内容 |
|---|---|
| 日付 | 2026-10-13 |
| プロジェクト | create-expo-app の blank テンプレート(expo 57.0.28、react-native 0.86.3) |
| 書き出し | npx expo export --platform android(Hermes のバイトコード .hbc が出力されます) |
| Node | 22 |
試すために、.env に次の 3 つを置きました。
EXPO_PUBLIC_API_BASE=https://api.example.com
EXPO_PUBLIC_OPENAI_KEY=sk-proj-DUMMY0123456789abcdefABCDEF0123456789
SERVER_ONLY_TOKEN=whsec_DUMMYSERVERONLY0123456789App.js には、process.env.EXPO_PUBLIC_API_BASE と process.env.EXPO_PUBLIC_OPENAI_KEY を読むコードと、ソースに直接書いた Google API キー風の文字列、そして process.env.SERVER_ONLY_TOKEN を読むコードを置いています。つまり、あえて「やってはいけない 3 通り」を並べた状態です。
手順 1: 本番に近い形で書き出す
npx expo export --platform android --output-dir dist出力の最後に、dist/_expo/static/js/android/index-….hbc が現れます。このファイルが、アプリに同梱される JavaScript の本体です。Expo の公式ドキュメントには、EXPO_PUBLIC_ で始まる変数は「コンパイルされたアプリの中で平文のまま見える」(These variables will be visible in plain-text in your compiled application)と書かれています。その「見える」を、自分の目で確かめにいきます。
手順 2: まず grep で見る(落とし穴が 2 つあります)
grep -a -o -E 'sk-[A-Za-z0-9_-]{20,}' dist/_expo/static/js/android/*.hbc私の環境では、次の 1 行が返りました。
sk-proj-DUMMY0123456789abcdefABCDEF0123456789Invalidここで 2 つ気づきました。
1 つ目は、末尾の Invalid です。鍵の終わりに、隣にあった別の文字列がそのままくっついています。バイトコードの文字列領域には区切りがなく、正規表現が文字種の範囲で伸びてしまうのだと思います。見つけた文字列を、そのまま「鍵の全体」と思わないほうがよさそうです。
2 つ目は、grep -c の数字です。同じファイルに grep -c -a 'sk-proj' を流すと 1 と返りますが、これは出現回数ではなく「一致した行の数」です。バイナリはほぼ 1 行として扱われるため、何回含まれていても 1 になります。件数を見たいときは、-o を付けて数えるほうが確実です。
手順 3: 形と値の両方で探すスクリプト
grep だと、鍵の書式を思い出しながら毎回コマンドを打つことになります。そこで、次の 2 つを 1 本にまとめました。
- 形で探す: よくある鍵の書式に当てはまる文字列を拾います。
- 値で探す:
.envの値が、そのままバンドルに入っていないかを調べます。
// scan-bundle.mjs — 書き出した Expo バンドルに「秘密に見える文字列」と「.env の値」が残っていないかを調べる
// 使い方: node scan-bundle.mjs <exportしたフォルダ> [.envのパス] [--allow=公開してよい変数名,…]
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, extname } from 'node:path';
const args = process.argv.slice(2);
const allowArg = args.find((a) => a.startsWith('--allow='));
const allow = new Set((allowArg ?? '').replace('--allow=', '').split(',').filter(Boolean));
const [dir, envPath = '.env'] = args.filter((a) => !a.startsWith('--'));
if (!dir || !existsSync(dir)) {
console.error('使い方: node scan-bundle.mjs <exportフォルダ> [.env]');
process.exit(2);
}
// 1) 形で探す: よくある鍵の書式(誤検出は許容。見つかったら人間が判断する)
const SHAPES = [
['OpenAI 系', /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g],
['Google API キー', /AIza[0-9A-Za-z_-]{35}/g],
['Stripe シークレット', /sk_(?:live|test)_[0-9A-Za-z]{16,}/g],
['Webhook 署名鍵', /whsec_[0-9A-Za-z]{16,}/g],
['AWS アクセスキー', /AKIA[0-9A-Z]{16}/g],
['JWT らしき文字列', /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
];
// 2) 値で探す: .env の各値がバンドルの中にそのまま入っているか
const envValues = existsSync(envPath)
? readFileSync(envPath, 'utf8').split('\n')
.map((l) => l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/))
.filter(Boolean)
.map(([, k, v]) => [k, v.replace(/^["']|["']$/g, '')])
.filter(([k, v]) => v.length >= 8 && !allow.has(k))
: [];
const mask = (s) => (s.length <= 10 ? '***' : s.slice(0, 6) + '…' + s.slice(-2));
function* walk(d) {
for (const n of readdirSync(d)) {
const p = join(d, n);
statSync(p).isDirectory() ? yield* walk(p) : yield p;
}
}
let hits = 0;
for (const f of walk(dir)) {
if (!['.hbc', '.js', '.map', '.bundle'].includes(extname(f))) continue;
const text = readFileSync(f).toString('latin1'); // Hermes はバイトコードでも文字列は素のまま入る
for (const [label, re] of SHAPES) {
for (const m of text.matchAll(re)) {
hits++;
console.log(`SHAPE ${label.padEnd(16)} ${mask(m[0])} @${m.index} ${f}`);
}
}
for (const [k, v] of envValues) {
const i = text.indexOf(v);
if (i >= 0) {
hits++;
console.log(`VALUE ${k.padEnd(28)} ${mask(v)} @${i} ${f}`);
}
}
}
console.log(hits ? `\n${hits} 件。許可していない名前が VALUE で出たら要調査。` : '\n0 件');
process.exit(hits ? 1 : 0);終了コードは、見つかれば 1、見つからなければ 0 です。ビルド後の手順に差し込めば、そのままゲートとして使えます。
実行結果: 見つかったもの、見つからなかったもの
最初のビルド(3 通りのダメな例を並べた状態)に対して、次のように実行しました。
node scan-bundle.mjs dist .envSHAPE OpenAI 系 sk-pro…89 @131303 …/index-….hbc
SHAPE Google API キー AIzaSy…qr @140062 …/index-….hbc
VALUE EXPO_PUBLIC_API_BASE https:…om @150635 …/index-….hbc
VALUE EXPO_PUBLIC_OPENAI_KEY sk-pro…89 @131303 …/index-….hbc
4 件。許可していない名前が VALUE で出たら要調査。| 置き方 | バンドルに値が入ったか | 見つけた方法 |
|---|---|---|
EXPO_PUBLIC_OPENAI_KEY(コードで読む) | 入っていました | 形と値の両方 |
| ソースに直接書いた Google API キー風の文字列 | 入っていました | 形だけ(.env に無いため値では探せません) |
EXPO_PUBLIC_API_BASE(URL) | 入っていました | 値(公開して構わないので、許可リストに入れる対象です) |
SERVER_ONLY_TOKEN(EXPO_PUBLIC_ なし) | 入っていませんでした | ——(変数名の文字列だけが残り、値は見つかりませんでした) |
この結果から、書く前の想像と違っていた点が 2 つありました。
使っていない EXPO_PUBLIC_ 変数は、.env に書いてあっても埋め込まれません。 2 回目のビルドで App.js から鍵を読むコードを消し、.env はそのままにして書き出したところ、鍵の値はバンドルから消えていました。埋め込まれるのは、コードの中で process.env.EXPO_PUBLIC_… と書いて参照した変数だけです。ですから、.env を眺めて「鍵がある」と心配するより、「どの変数をコードが読んでいるか」を見るほうが、実態に近いのだと思います。
EXPO_PUBLIC_ を付けない変数は、アプリの中では空になります。 process.env.SERVER_ONLY_TOKEN は実行時に値を持たず、名前だけがバンドルに残りました。鍵が漏れなかったのは良いことですが、アプリ側から使えない、という意味でもあります。サーバーでしか使えない値は、サーバーに置くほかありません。
直したあとの再検査
鍵を呼ぶ処理は、アプリではなくサーバー側へ移しました。アプリは「何をしたいか」を表す短い種類(例: icon)だけを送ります。
import { Text, View } from 'react-native';
const API_BASE = process.env.EXPO_PUBLIC_API_BASE;
async function ask(kind) {
// 鍵はサーバー側。アプリは「何をしたいか」の選択肢だけを送る
const res = await fetch(`${API_BASE}/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ kind }),
});
return res.json();
}
export default function App() {
return (
<View style={{ flex: 1, justifyContent: 'center' }}>
<Text onPress={() => ask('icon')}>{API_BASE}</Text>
</View>
);
}書き出し直して、公開してよい URL だけを許可リストに入れて検査します。
rm -rf dist && npx expo export --platform android --output-dir dist
node scan-bundle.mjs dist .env --allow=EXPO_PUBLIC_API_BASE
# → 0 件(終了コード 0)許可リストを外すと EXPO_PUBLIC_API_BASE が 1 件だけ出ます。「出てもよいものを、名前で明示している」状態にしておくと、次に誰かが別の変数を足したときに、すぐ気づけます。
鍵をクライアントに置かない設計そのものは、別の記事で扱っています。中継の作り方はWorkers で薄い中継を挟む設計、鍵を取り替える手順は秘密境界とローテーションの手順をご覧ください。この記事は、その設計が実際のビルドで守れているかを確かめる側です。
限界と、確かめていないこと
- 形で探すため、独自の書式の鍵は拾えません。 値で探す側(
.envの突き合わせ)が、その穴を補います。それでも、.envに書いていない値には効きません。 - Android 向けの書き出しだけを試しています。 iOS 向けの書き出しや、EAS Build が最終的に作る
.aab・.ipaの中までは、この記事では確かめていません。 - ソースマップを公開する運用では、
.mapの中身も同じ検査に入れてください。 スクリプトは.mapも走査します。 - 1 つのファイルに「鍵らしき文字列がない」ことは、「安全」の証明ではありません。 検査は取りこぼしを減らす道具であって、鍵を置かない設計の代わりにはなりません。
鍵は、隠すものではなく、そもそもアプリに載せないもの。 検査はその線引きが守れているかを見る、最後の確認にすぎません。
次のリリースビルドを作る前に、expo export を 1 回流して、このスクリプトの終了コードだけ見てみていただければと思います。私自身も、次に手元のアプリを更新するビルドで、同じ 1 行を流してみるつもりです。