「環境変数に入れたので安全です」と説明したあと、ブラウザから秘密の値が見つかった
木曜14時42分。地域の作家が商品を出品する通販サイトで、公開前のデモが始まった。
担当エンジニアは、配送会社のAPIとつなぐ機能をAIと一緒に実装していた。住所から送料を計算し、注文が確定したら集荷を依頼する機能だ。ところが前夜、プレビュー環境では「トークンが見つからない」というエラーが出た。
AIへエラーを貼ると、修正案が返ってきた。
- PARTNER_API_TOKEN=...
+ PUBLIC_PARTNER_API_TOKEN=... フロント側のコードから参照できる名前へ変えればよい、という説明だった。変更後はビルドが通り、送料も表示された。担当者は胸をなで下ろした。開発が遅れていたので、原因を調べ直すより、このままデモへ進める方がありがたかった。
デモの冒頭で、運営会社の担当者から「配送会社の認証情報は、利用者には見えませんよね」と聞かれた。エンジニアは「環境変数に入れているので、ソースコードには書いていません。大丈夫です」と答えた。
14時51分、同席していた運営会社の開発担当者がブラウザの開発者ツールを開いた。送料計算の通信を選ぶと、リクエストの認証欄に、配送会社のトークンがそのまま表示されていた。
さっき安全だと言い切った言葉が、まだ会議室に残っているように感じた。
AIの提案をそのまま使ったと思われたくない。まず値を消して、直ってから説明したい。これはプレビュー環境だから、本番事故ではないと言えるかもしれない。
恥ずかしさが強いほど、画面を閉じ、誰にも見られないうちに一人で直したくなる。しかし、見えた値をコードから消しても、その値がすでに使える状態なら、問題は終わらない。何人がページを開いたのか、どの権限を持つトークンだったのかも、まだ分かっていなかった😨
「環境変数に入れた」という説明は、誰から秘密を守れるかを示していなかった。
環境変数という置き場所だけでは、安全かどうかは決まらない
環境変数は、設定値をコードから分けて管理する方法だ。ただし、環境変数に入れた値がすべてサーバーだけで読まれるわけではない。
大切なのは保存場所の名前ではなく、その値が最終的にどこへ届くかだ。
1. 「秘密かどうか」は、誰が読んでよいかで決める
環境変数を整理する時は、最初に読み手を決める。
- ブラウザを使う人が知ってよい値
- 自社のサーバーだけが知ってよい値
- 開発や運用の一部の担当者だけが知ってよい値
公開してよい分析用IDや、公開利用を前提に設計された識別子もある。それらまで一律に秘密と呼ぶ必要はない。一方で、外部サービスの管理APIを呼べるトークン、データベースの接続情報、署名に使う鍵は、ブラウザへ渡してはいけない。
値の名前や置いた画面ではなく、「その値を知った人が何をできるか」まで確認する。 今回のトークンには送料の参照だけでなく、集荷依頼を作成する権限も含まれていた。だから、利用者の端末へ届ける設計にしてはいけなかった。
2. PUBLIC_ や VITE_ は、秘密を守る印ではない
SvelteKitの$env/static/publicは、値がビルド時に組み込まれ、ブラウザからアクセスできると公式ドキュメントに明記されている。既定では、PUBLIC_から始まる変数が対象だ。
Viteも、VITE_から始まる変数をクライアント側のソースコードへ公開する。そのため公式ガイドは、VITE_*へAPIキーなどの秘密情報を入れないよう注意している。
つまり、公開プレフィックスは安全装置ではない。「この値をブラウザへ届ける」という指定だ。AIの修正案は、エラーを解消した代わりに、サーバーとブラウザの境界を越えていた。
フレームワークが違っても考え方は同じだ。Next.jsのNEXT_PUBLIC_など、ブラウザ向けに値を埋め込む仕組みがある。使っている技術の公式ドキュメントで、どの接頭辞と読み込み方法が公開対象になるかを確認する必要がある。
3. ビルド成功は、秘密が守られた証拠ではない
今回、ビルドが通ったのは、ブラウザから必要な値を読めるようになったからだった。機能が動いたことと、境界が正しいことは逆の結果になる場合がある。
マージ前には、コードだけでなく生成後の状態を見る。
- ブラウザの開発者ツールで、通信先と送信内容を確認する
- 配信されたJavaScriptへ、秘密情報が組み込まれていないかを安全な方法で確認する
- 外部APIの呼び出しが、自社サーバーを経由しているかを見る
- 公開してよい変数だけが、クライアント側の読み込み方法を使っているかを確認する
本物の秘密情報を画面共有、ログ、CIの出力へコピーして探すのは避ける。検証用の無効な値や、値そのものを表示しない検査を使う。秘密を見つける確認が、新しい露出を作らないようにする。
見つけた直後は、消すより先に止める
14時54分、担当エンジニアはデモを止めた。まず口にしたのは、原因の言い訳ではなかった。
配送会社の認証トークンが、プレビュー画面の通信から見える状態です。この画面の公開を止めます。トークンは失効して再発行し、閲覧した人と利用履歴を確認します。現時点では、不正利用があったかは確認できていません。30分後に分かった事実を共有します。
声が震え、AIの提案を採用したことまで一息に謝りたくなった。それでも、今見えている事実、これから止めること、まだ分からないことを分けた。運営会社の担当者は「まず配送会社へ連絡しましょう」と答えた。
1. 公開を止め、値を失効・再発行する
公開プレフィックスを外したり、JavaScriptを差し替えたりするだけでは、見えた値そのものは使えるままだ。漏えいの可能性がある秘密情報は、提供元の手順に従って失効させ、新しい値へ切り替える。
順序はサービスの仕様で変わる。先に古い値を止めると業務が止まる場合は、新しい値を発行し、利用先を切り替えてから古い値を失効させる。判断できない時は、提供元の緊急窓口や自社のセキュリティ担当へ確認する。
OWASPのSecrets Management Cheat Sheetも、侵害された可能性がある秘密情報を安全に失効できる仕組みと、定期的な更新を求めている。コードを直すことと、露出した値を使えなくすることは別の作業だ。
2. 確認できた事実と、まだ分からないことを分ける
「ブラウザから見えた」は事実だ。「誰かに悪用された」は、記録を確認する前には断定できない。反対に、悪用の証拠がまだないことを「影響なし」と言い換えてもいけない。
初回の共有では、次を短く残す。
- どの環境で、いつからいつまで見える状態だったか
- どの値が露出し、どの操作ができる権限だったか
- ページへアクセスできた人や範囲
- すでに停止・失効したもの
- 利用履歴や影響で、まだ確認中のもの
- 次に共有する時刻と担当者
GitHubのプッシュ保護は、対応している形式の秘密情報がリポジトリへ送られるのを防げる。しかし、デプロイ設定にだけ保存され、ビルド時にブラウザへ埋め込まれた値は、リポジトリの検査だけでは見つからない場合がある。検出機能が通ったことも、公開画面の確認に置き換えない。
3. 一人で抱えず、調査と復旧を分ける
今回、担当者はプレビューを非公開にし、同僚が配送会社側でトークンを失効させた。運営会社の開発担当者は、アクセスできたアカウントと時間を整理した。別の担当者が、トークンの利用履歴を確認した。
役割を分けたことで、コードを直しながら記憶だけで時刻を説明する状態を避けられた。失敗を見られた恥ずかしさは消えなかったが、「全部直してから話さなければ」という重さは少し下がった。
15時23分の時点で確認できたのは、プレビューを開いた社内外の6人と、デモ中の正常な送料照会だった。不正利用の有無は、その時点のログで確認できる範囲を明記し、後続の確認も残した。「影響なし」と急いで結論を作らず、確認できる範囲を言葉にすることが信頼を守る。
AIに直してもらう前に、境界を依頼文へ書く
AIは、参照できない環境変数を見つけると、名前や読み込み方法を変えて機能を動かす案を出すことがある。しかし、その値をブラウザへ渡してよいかは、エラー文だけでは判断できない。
1. 実際の秘密情報は渡さず、性質を伝える
AIへの依頼には、トークンの実値を貼らない。代わりに、値の性質と制約を書く。
配送会社のサーバー向けAPIトークンを使います。
実値は示しません。TOKEN_PLACEHOLDERとして扱ってください。
条件:
- ブラウザへ値を渡さない
- 公開プレフィックスを付けない
- サーバー側の処理からだけ読み込む
- 外部APIの呼び出しは自社サーバーを経由する
- 変更後に、ブラウザへ含まれないことを確認する手順も示す これなら、AIは実装案を考えられる一方、秘密情報を会話や生成ログへ増やさずに済む。AIサービスへ何を送ってよいかは、案件の契約や社内ルールも確認する。
2. 環境変数を「名前・読み手・使用場所」で一覧にする
チームは翌日、環境変数を三つの列で棚卸しした。
| 変数名 | 読んでよい人・処理 | 使用場所 |
|---|---|---|
PUBLIC_ANALYTICS_ID | ブラウザ利用者を含む | ブラウザ |
PARTNER_API_TOKEN | 自社サーバーのみ | サーバー |
DATABASE_URL | DB接続処理のみ | サーバー |
名前だけを見て分類せず、値がどのコードから読み込まれ、最終的にどこへ届くかを確かめた。外部サービス側では、必要以上の権限を持つトークンを分け、利用できる環境や送信元も絞った。
権限の境界をAIへ任せきらない考え方は、AIエージェントに「すべて許可」を押した。速く終わったのに、何を触ったか説明できないでも扱っている。問題発生後に残す情報は、障害対応でAIにログを全部貼ったあと、顧客情報まで送ったと気づいたも参考になる。
3. 最後はブラウザから見える結果で確認する
コードレビューでは、公開プレフィックス、クライアント側のimport、ブラウザから外部APIへ直接通信する処理を探す。次に、検証用の値で本番と同じ方法のビルドを作り、配信ファイルと通信を確認する。
秘密情報の管理サービスを導入しても、ブラウザへ渡すコードが残れば守れない。反対に、公開してよい識別子まで隠して複雑にする必要もない。道具の名前ではなく、実際のデータの流れで合否を決める。
「安全です」と言い切った自分を守るより、次の確認を共有する
翌週の振り返りで、担当エンジニアは「環境変数なら安全だと思っていた」と話した。正確には、環境変数へ入れることと、サーバーだけで使うことを同じだと思い込んでいた。AIはその思い込みに合う、最短の修正を出していた。
運営会社から責められるのが怖くて、最初はAIを使ったことを隠したかった。しかし、相手が知りたかったのは、誰のせいかより、トークンが今も使えるのか、注文情報に影響があるのか、次はどう確認するのかだった。
チームは、プレビュー公開前の確認に二つを追加した。
- ブラウザへ公開される環境変数の一覧を確認する
- 開発者ツールで、外部サービスへの通信と認証情報の有無を確認する
AIの利用はやめなかった。エラーの切り分けやサーバー側の処理案には引き続き使った。ただし、秘密情報を誰に見せてよいかと、露出した時にどこまで止めるかは、人が事業と契約の条件をつないで決めることにした。
次の作業で一つだけ行うなら、環境変数を「変数名」「読んでよい人・処理」「使用場所」の三列に書き出してほしい。ブラウザから読める場所に、サーバーだけが知るべき値が一つでもあれば、機能追加より先に経路を直す。
秘密情報は、.envに置いた時ではなく、許された相手だけが読める状態になって初めて守られる。 説明を間違えた恥ずかしさから画面を閉じる前に、止める、失効する、分かった事実を共有する。その順番なら、信用を失う怖さを、一人で抱える作業から共同の確認へ変えられる🙂