◉RORK LABEN
●EXPO — EAS Observe がネイティブクラッシュも記録(10/07)。SDK 57 は 57.0.21 以降で確認できます●RORK — 09-29 に GPT-6.1 Sol、09-28 に Claude Sonnet 5.5 を追加。モデル選択で使えます●RN 0.88 — 正式版は未公開。最新は rc.4(10/07)、Latest は 0.87.1 です●Q&A — Expo + Supabase のアプリを TestFlight に出すまでに詰まる4点が共有されています●ECOSYSTEM — Shopify はネイティブに戻る中、1人開発で Expo に残る理由を整理した記録が出ています●NEW — 「1週間でリリース」と書く前に、待ち日数の内訳を見る●EXPO — EAS Observe がネイティブクラッシュも記録(10/07)。SDK 57 は 57.0.21 以降で確認できます●RORK — 09-29 に GPT-6.1 Sol、09-28 に Claude Sonnet 5.5 を追加。モデル選択で使えます●RN 0.88 — 正式版は未公開。最新は rc.4(10/07)、Latest は 0.87.1 です●Q&A — Expo + Supabase のアプリを TestFlight に出すまでに詰まる4点が共有されています●ECOSYSTEM — Shopify はネイティブに戻る中、1人開発で Expo に残る理由を整理した記録が出ています●NEW — 「1週間でリリース」と書く前に、待ち日数の内訳を見る
記事一覧/開発ツール
⬡ 開発ツール/2026-10-13中級

リリース前に、書き出した Expo バンドルの中から自分の鍵を探してみる

ソースに鍵がなくても、出来上がったアプリの中に鍵が残ることがあります。Expo SDK 57 で実際に expo export を実行し、バンドルの中身を grep と 60 行ほどの Node スクリプトで調べて、見つかる鍵と見つからない鍵の違いを確かめました。

Expo214Rork579セキュリティ14EXPO_PUBLIC環境変数8リリース前チェックHermes9

.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 が出力されます)
Node22

試すために、.env に次の 3 つを置きました。

EXPO_PUBLIC_API_BASE=https://api.example.com
EXPO_PUBLIC_OPENAI_KEY=sk-proj-DUMMY0123456789abcdefABCDEF0123456789
SERVER_ONLY_TOKEN=whsec_DUMMYSERVERONLY0123456789

App.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 .env
SHAPE  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 行を流してみるつもりです。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥2,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⬡ 開発ツール2026-09-04
EAS の secret は「アプリに入れない」設定ではありません — 接頭辞と可視性を別々に決める
EXPO_PUBLIC_ の接頭辞は成果物に入るかを決め、EAS の可視性は誰が読めるかを決めます。二つを重ねたときに OTA 更新でだけ値が消える筋道と、出荷前に自分の手で確かめる手順をまとめました。
⬡ 開発ツール2026-10-07
日本語の見出しだけ太字にならない — Android で fontWeight が効かないときの切り分けと直し方
Rork で作ったアプリで、iPhone では太字なのに Android では日本語の見出しが細いままになる症状の切り分け記事です。原因の見分け方、fontFamily を重みごとに分ける直し方、確認用コードを載せています。
⬡ 開発ツール2026-09-21
exit code 0 で止まるビルド — ログを黙らせていた設定と、空ファイルを通した存在チェック
eas build --local が exit code 0 but produced no further output で止まる件と、0 バイトのスタブが埋め込まれず dyld で落ちる件。どちらも原因は出力を減らす設定でした。自分のスクリプトを棚卸しする手順まで書き残します。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます