最初に結論を述べます。Next.js の環境変数には実質2種類しかありません——NEXT_PUBLIC_ 接頭辞が付いた「ビルド時にクライアントバンドルへ文字列として焼き込まれる=公開される」値と、接頭辞なしの「サーバープロセス内だけに存在する」値です。そしてこの一線こそが、秘密漏洩の境界そのものです。 service_role キーやAPI秘密を一文字の接頭辞で公開側に置いた瞬間、それは全世界に配布されます。
これは「環境変数は危ない」「Next.js が悪い」という話ではありません。仕組みは明快で、正しく使えば堅牢です。問題は、NEXT_PUBLIC_ という接頭辞が「クライアントに見せてよい」という設計判断を、たった一行のキー名に押し込んでいること——そしてAIも開発者も「動いたから大丈夫」でこの判断を素通りしやすいことにあります。本記事は、env の仕組みを正確に押さえ直したうえで、何が漏れるのか、なぜ漏れるのか、そして server-only 境界・型付き env・秘密スキャンでどう体系的に防ぐのかを、実コードと一次情報に基づいて解説します。
これは認可やRLSのような「あなたのデータモデルにしか分からない垂直リスク」ではなく、アプリ横断で一律に固められる水平統制の話です。だからこそ、人間の注意力ではなく仕組みに番をさせるのが正解です。アプリ層セキュリティ全体の地図はNext.js × Supabase アプリケーションセキュリティ完全ガイドにまとめており、本記事はその中の「秘密情報の衛生」を一本に深掘りしたものです。
1. Next.js の env はどう動くのか——「公開」と「サーバー専用」の二分法
すべての出発点は、Next.js が環境変数を2つの異なる経路で扱うという事実です。この挙動を正確に理解していないと、あらゆる対策が砂上の楼閣になります。
1-1. 接頭辞なし=サーバー専用(プロセス内にしか存在しない)
process.env.DATABASE_URL のように接頭辞なしで参照する値は、サーバー(Node.js / Edge ランタイム)のプロセス内にしか存在しません。 Server Component、Route Handler、Server Action、middleware.ts からは読めますが、ブラウザに送られるバンドルには一切含まれません。クライアントコンポーネントで process.env.DATABASE_URL を読もうとすると、undefined になります(公式の挙動。Next.js docs を参照)。
// これらはサーバーでしか読めない。クライアントでは undefined になる
const dbUrl = process.env.DATABASE_URL; // サーバー専用
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; // サーバー専用
const resendKey = process.env.RESEND_API_KEY; // サーバー専用
これがデフォルトであり、安全側です。何もしなければ、env はサーバーから出ません。
1-2. NEXT_PUBLIC_ 接頭辞=ビルド時にクライアントへインライン化(=公開)
一方、NEXT_PUBLIC_ で始まる変数は、ビルド時にその値が文字列リテラルとしてコードに置換(インライン化)されます。 ブラウザに配られるJavaScriptバンドルの中に、値そのものが平文で焼き込まれるのです。
// ソースコード上はこう書いても…
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
// ビルド後のクライアントバンドルでは、値が直接埋め込まれる:
// const url = "https://abcdefg.supabase.co";
ここで決定的に重要な点が2つあります。
- 「公開」は文字どおり全世界への公開です。 バンドルはブラウザに配信されるので、誰でもDevToolsの Sources タブやネットワークタブで生のJavaScriptを読めます。
NEXT_PUBLIC_の値は、HTMLソースを「表示」するのと同じくらい簡単に取り出せます。秘密ではありません。 - ビルド時に焼き込まれるため、後から変えられません。 実行時の環境変数差し替えでは値が変わらない(再ビルドが必要)。つまり「うっかり
NEXT_PUBLIC_に置いた秘密」は、デプロイ済みバンドルを引っ込めても、すでに配布された分は取り消せません。漏洩したら鍵のローテーションが必須になるのはこのためです。
| 種類 | 例 | どこに存在するか | クライアントから見えるか |
|---|---|---|---|
| 接頭辞なし | SUPABASE_SERVICE_ROLE_KEY | サーバープロセス内のみ | 見えない(安全側) |
NEXT_PUBLIC_ 付き | NEXT_PUBLIC_SUPABASE_URL | ビルド時にバンドルへ焼き込み | 見える=公開 |
この二分法を「NEXT_PUBLIC_ = 公開掲示板に貼る」と読み替えてください。掲示板に貼ってよい値だけに接頭辞を付ける——これが規律の核です。
2. 何が漏れるのか——3つの典型的な漏洩経路
env 由来の秘密漏洩は、ほぼ例外なく次の3つの経路に収束します。順に、脆弱なコードと修正をセットで見ていきます。
2-1. 経路①:秘密を NEXT_PUBLIC_ に置いてしまう
最も直接的で、最も致命的です。AIエージェントや急いでいる開発者が「クライアントコンポーネントから読みたいから」という理由で、秘密に NEXT_PUBLIC_ を付けてしまう。
# 危険:service_role キーに NEXT_PUBLIC_ を付けている(.env.local)
# これはビルド時にクライアントバンドルへ平文で焼き込まれ、全世界に公開される
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
NEXT_PUBLIC_RESEND_API_KEY=<resend-api-key>
NEXT_PUBLIC_STRIPE_SECRET_KEY=<stripe-secret-key>
なぜこれが起きるのか。クライアントコンポーネントで process.env.SUPABASE_SERVICE_ROLE_KEY(接頭辞なし)を読むと undefined になる。エラーに直面した開発者やAIは、「undefined を直す」最短経路として NEXT_PUBLIC_ を付ける——そして動いてしまう。 デモは通る。だが、その値はもう公開されている。
service_role キーは PostgreSQL の BYPASSRLS 権限で動き、RLS を完全に無視します。これを公開するということは、データベース全体への管理者アクセスを世界中に配布するのと同じです。Supabase公式も「service_role キーはサーバー側でのみ使うこと」と明記しています(Supabase: API keys)。anon キーと service_role キーの責務分離それ自体が大きな主題なので、anonキーとservice_roleキーの露出ガイドで詳述しています。
修正:そもそも秘密に NEXT_PUBLIC_ を付けない。 クライアントで秘密が必要に「見える」場合、設計が間違っています。秘密を使う処理はサーバー(Route Handler / Server Action)に置き、クライアントは結果だけを受け取ります。
# 修正:秘密は接頭辞なし=サーバー専用。公開してよい値だけ NEXT_PUBLIC_
SUPABASE_SERVICE_ROLE_KEY=<service-role-key> # サーバー専用
RESEND_API_KEY=<resend-api-key> # サーバー専用
STRIPE_SECRET_KEY=<stripe-secret-key> # サーバー専用
NEXT_PUBLIC_SUPABASE_URL=https://abcdefg.supabase.co # 公開してよい
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key> # 公開前提の鍵
「URL や anon キーを公開してよいのか」という疑問はもっともです。 anon キーは「ブラウザに配ることを前提に設計された公開鍵」で、その安全性は背後のRLSが担保します(Supabase: API keys)。一方 service_role は「公開を前提としていない管理鍵」。同じ『キー』でも、公開してよいかは性質で決まる——この判断こそ、後述する「人間の設計領域」です。
2-2. 経路②:サーバー設定をクライアントに import して巻き込む
これが最も見つけにくく、レビューをすり抜けます。秘密自体は接頭辞なしで正しく置いてある。なのに漏れる。原因は import です。
Next.js のバンドラは、クライアントコンポーネントが import しているモジュールを芋づる式にクライアントバンドルへ含めます。 サーバー専用のつもりの設定モジュールを、うっかりクライアントコンポーネントから import すると、そのモジュールが参照している process.env.SECRET の値まで、ビルド時にバンドルへ焼き込まれてしまうのです。
// lib/config.ts — サーバー専用のつもりで秘密を読んでいる(が、守りは何もない)
export const config = {
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY!,
resendApiKey: process.env.RESEND_API_KEY!,
stripeSecret: process.env.STRIPE_SECRET_KEY!,
};
// app/components/pricing-table.tsx — クライアントコンポーネント
"use client";
import { config } from "@/lib/config"; // ← この一行が秘密をクライアントへ巻き込む
export function PricingTable() {
// config.publicPlans のような公開値だけ使っているつもりでも…
// バンドラは config.ts 全体をクライアントバンドルに含める。
// → serviceRoleKey / resendApiKey / stripeSecret の値まで焼き込まれて公開される
return <div>{/* ... */}</div>;
}
恐ろしいのは、コード上は秘密を「使って」いないのに漏れる点です。config.serviceRoleKey を参照していなくても、モジュールのトップレベルで process.env.SUPABASE_SERVICE_ROLE_KEY を読んでいれば、その値はインライン化の対象になり得ます。undefined のエラーも出ないので、誰も気づきません。
これは前掲のアプリケーションセキュリティ完全ガイドで「水平統制」として触れた論点の、最も実害が出やすい形です。修正は次節の server-only 境界そのものです。
2-3. 経路③:.env* をコミットする
古典的ですが、いまだに最頻出です。.env.local や .env.production には平文の秘密が並んでいます。これを Git にコミットすると、たとえ後から削除しても Git 履歴に永久に残ります。 公開リポジトリなら即アウト、プライベートでも共有範囲全員に漏れます。
# 危険:秘密ファイルが追跡対象になっている
$ git status
new file: .env.local # ← service_role や API 秘密が入っている
new file: .env.production
.gitignore で確実に除外し、追跡してしまった場合は履歴からの除去(と鍵ローテーション)まで行います。
# .gitignore — Next.js 標準テンプレートが既定で入れている。消さないこと
.env
.env*.local
.env.production
# 例外として「秘密を含まない」例示ファイルだけは共有する
!.env.example
# 既にコミット済みかを確認する(クリーンなら何も出ない)
$ git ls-files | grep -E '^\.env'
# ← ここに .env.local 等が出たら、追跡解除+履歴除去+鍵ローテーションが必要
履歴からコミット済みの秘密を消すのは破壊的操作(force push を伴う履歴改変)です。**実行前に必ずチーム合意を取り、消す前提でも「漏れた鍵は使えなくなったものとしてローテーションする」**のが鉄則です。一度コミットされた秘密は、技術的には「漏れた」と扱うのが安全です。
3. 予防①:server-only でサーバー境界をビルド時に強制する
経路②(import による巻き込み)は、レビューや注意力では防ぎきれません。仕組みで弾く必要があります。その決定打が server-only パッケージです。
server-only は、それを import したモジュールがクライアントバンドルに混入したらビルドを失敗させる——ただそれだけの小さなパッケージです。秘密を読むモジュールの先頭に一行置くだけで、「このファイルはサーバー専用」という制約が型やコメントではなく、ビルドエラーとして強制されます。
// lib/env.server.ts — サーバー専用。クライアントに混入したらビルド時に弾く
import "server-only"; // ← この一行が境界。クライアントから import された瞬間ビルド失敗
import { z } from "zod";
const ServerEnv = z.object({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
RESEND_API_KEY: z.string().startsWith("re_"),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
DATABASE_URL: z.string().url(),
});
// 起動時に1度だけ検証。欠けていれば即クラッシュ=fail-fast
export const serverEnv = ServerEnv.parse(process.env);
これで、2-2 のような「クライアントから秘密設定を import する」ミスは、動くどころかビルドが通らなくなります。 開発者やAIが新しいコンポーネントを書いて誤って import しても、CIのビルド段階で機械的に止まる。これが「人間の注意力ではなく仕組みに番をさせる」ということです。
# 2-2 のミスを犯すと、ビルドがこう失敗する(概念):
# You're importing a component that imports server-only.
# It only works in a Server Component, but ...
# → 秘密がバンドルに焼き込まれる前に、ビルドが落ちて事故を防ぐ
対になる client-only パッケージもあり、「ブラウザAPIに依存するモジュールがサーバーに混入したら弾く」という逆方向の境界を貼れます。秘密漏洩対策としては server-only が主役です。
server-onlyは「秘密を守る魔法」ではありません。 これが保証するのは「サーバー専用モジュールがクライアントに混入しない」という一点だけです。NEXT_PUBLIC_に秘密を直接書く事故(経路①)や、.envのコミット(経路③)は防げません。境界の一枚にすぎず、後述の検出・他の予防策と重ねて初めて層になります。
4. 予防②:Zod で型付き env を境界検証する
環境変数は「外部入力」です。デプロイ環境のダッシュボードで誰かが値を打ち間違える、必須キーを設定し忘れる——これらは日常的に起きます。env を process.env.X!(非null断言)で散らばして使うのは、検証なしの外部入力を信じているのと同じです。境界で一度だけ検証し、以後は型安全な単一の窓口から使います。
4-1. なぜ「型付き env モジュール」なのか
process.env の型は Record<string, string | undefined> です。つまりすべてのキーが string | undefined で、型システムは「DATABASE_URL が存在するか」も「re_ で始まるか」も知りません。process.env.RESEND_API_KEY! の ! は「あるはず」という願望であって、保証ではない。本番で undefined のまま resend.emails.send() に渡り、意味不明なエラーで初めて気づく——これが典型的な事故です。
Zod で起動時に検証すれば、欠落や形式違反はアプリ起動の瞬間に明示的なエラーで落ちます(fail-fast)。 「動いているように見えて実は壊れている」状態を、構造的に排除できます。型安全はバグ予防であると同時に、秘密の取り違え(sk_ で始まるべき値に別物が入る等)の検出にもなります。
4-2. サーバー env とクライアント env を物理的に分ける
ここが設計の肝です。ファイルを2つに分け、サーバー env には server-only を貼り、クライアント env には公開可能値だけを置きます。 こうすれば「クライアント env モジュール」を誰がどこから import しても、そこには漏れて困る値が構造的に存在しません。
// lib/env.server.ts — サーバー専用の env(秘密を含む)
import "server-only";
import { z } from "zod";
const ServerEnv = z.object({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
RESEND_API_KEY: z.string().startsWith("re_"),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
DATABASE_URL: z.string().url(),
});
export const serverEnv = ServerEnv.parse(process.env);
// lib/env.client.ts — クライアントにも出てよい env(公開可能値のみ)
import { z } from "zod";
// 重要:NEXT_PUBLIC_ はビルド時にインライン化されるため、
// process.env.NEXT_PUBLIC_X を「分割代入」せず、各キーを直接参照する。
// バンドラは静的な参照しか置換できないため、これがインライン化の必須条件。
const ClientEnv = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
NEXT_PUBLIC_SITE_URL: z.string().url(),
});
export const clientEnv = ClientEnv.parse({
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
});
この2ファイル構成には、3つの効果があります。
server-onlyがサーバー env の流出をビルド時に止める。 クライアントからlib/env.server.tsを import した瞬間にビルドが落ちる。- クライアント env には秘密が物理的に無い。 万一クライアントから import されても、公開してよい値しか入っていない。
- 使う側が型安全になる。
serverEnv.STRIPE_SECRET_KEYはstring型で、undefinedの心配がない。検証は起動時に済んでいる。
4-3. NEXT_PUBLIC_ のインライン化を壊さない書き方
4-2 のコメントで触れた点は、見落とされがちな実装上の罠です。NEXT_PUBLIC_ の値はビルド時に静的置換されるため、バンドラが置換箇所を静的に特定できる書き方でなければなりません。
// 動く:各キーを「丸ごと」直接参照している → バンドラが値を置換できる
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
// 壊れる:動的アクセスや分割代入は静的解析できず、置換されない(実行時 undefined)
const key = "NEXT_PUBLIC_SUPABASE_URL";
const url2 = process.env[key]; // ← 置換されない
const { NEXT_PUBLIC_SUPABASE_URL } = process.env; // ← 置換されない
クライアント側で env が「なぜか undefined」になる事故の大半は、この動的アクセスが原因です。process.env.NEXT_PUBLIC_X の形で、キーをリテラルとして直接書く——これが鉄則です。
5. 予防③:公開可能値だけをクライアントに通す、という設計規律
予防①②は仕組みですが、その手前に設計判断があります。「この値はクライアントに出してよいか」を、キーを追加するたびに一度立ち止まって決める。この判断だけは自動化できません(理由は第7節)。
判断の補助線として、次の問いが効きます。
- この値が全世界に平文で公開されても、被害はゼロか? Noなら
NEXT_PUBLIC_を付けてはいけない。 - この値は「ブラウザに配ることを前提に設計された」ものか? anon キーや公開URLはYes。API秘密・DB接続文字列・署名鍵はNo。
- クライアントでこの秘密が必要に見えるのは、処理をクライアントに置いているからではないか? その場合、処理をサーバーへ移せば秘密も移せる。
最後の問いが本質です。「クライアントで秘密が要る」状況のほとんどは、設計の取り違えです。例えば「クライアントから外部APIを叩きたいから秘密が要る」なら、叩く処理を Route Handler に移し、クライアントは自分のサーバーだけを呼ぶ。秘密はサーバーに留まります。
// 悪い設計:クライアントから直接外部APIを叩こうとして秘密が要る、と錯覚する
"use client";
async function send() {
// ここで秘密が要る → NEXT_PUBLIC_ にしたくなる → 公開事故へ
await fetch("https://api.external.example/send", {
headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_API_KEY}` }, // ✗
});
}
// 良い設計:クライアントは自分のサーバーを呼ぶだけ。秘密はサーバーに留まる
"use client";
async function send() {
await fetch("/api/send", { method: "POST" }); // 秘密は一切クライアントに来ない
}
// app/api/send/route.ts — 秘密はここ(サーバー)でだけ使う
import { serverEnv } from "@/lib/env.server"; // server-only 境界の内側
export async function POST() {
await fetch("https://api.external.example/send", {
headers: { Authorization: `Bearer ${serverEnv.EXTERNAL_API_KEY}` }, // ✓
});
return Response.json({ ok: true });
}
この「秘密が要る処理はサーバーに置く」という規律が身につくと、NEXT_PUBLIC_ を秘密に付けたくなる場面そのものが消えます。問題が起きてから直すのではなく、問題が発生しない構造にするのが ETC(Easy To Change)の発想です。
6. 検出——混入はパターンで機械的に拾える
予防を固めたら、「漏れていないか」を検証します。秘密の混入は、認可の正しさと違って形(パターン)だけで検出できる——つまり自動化が効く領域です。3つの手段を重ねます。
6-1. ビルド出力を秘密の形で grep する
最も直接的な確認は、実際にビルドしたクライアントバンドルを、秘密の特徴で検索することです。NEXT_PUBLIC_ 経由でも import 巻き込み経由でも、漏れた秘密は最終的にバンドルの中に平文で現れます。出力を直接見れば、経路を問わず拾えます。
# ビルドして、クライアント向けJSに秘密の「形」が現れていないか検索する
npm run build
# service_role / Stripe secret / Resend など、鍵の接頭辞や形で grep
grep -rE 'eyJ[A-Za-z0-9_-]{20,}|sk_(live|test)_|re_[A-Za-z0-9]{16,}' .next/static \
&& echo "WARNING: 秘密の形がクライアントバンドルに見つかった" \
|| echo "OK: 既知の秘密パターンは検出されなかった"
注意点:これは「既知の形に一致する秘密」しか拾えません。独自形式のトークンや、形に特徴のない秘密はすり抜けます。**「検出されなかった=安全」ではなく「既知パターンには引っかからなかった」**にすぎない——この限界は常に意識します。
6-2. シークレットスキャナで履歴とコミットを守る
経路③(.env のコミットや、コードへの秘密ハードコード)は、コミット前・CI段階のシークレットスキャンで止めます。多くのOSSスキャナがこの種の検出を備えており、コミットフックやCIに常設して「秘密が入ったコミットは通さない」門にするのが定石です。GitHub には push 時に既知の秘密を検出する Push Protection もあります。
要点は、人間が毎回チェックする運用にしないこと。秘密混入は注意力で防ぐものではなく、機械が門で弾くものです。
6-3. npx @aegiskit/cli scan でハードコード秘密と NEXT_PUBLIC_ 誤用を拾う
私が公開しているOSS Aegis は、この水平統制の検出を実装しています。インストール不要・設定不要で走り、「NEXT_PUBLIC_ × 秘密鍵」の付け間違いや、コードに直書きされた秘密をルールとして検出します。
# インストール不要・設定不要でスキャン(NEXT_PUBLIC_ 誤用・ハードコード秘密を可視化)
npx @aegiskit/cli scan
Aegis はこのほか、汚染入力→危険シンクのデータフロー(注入クラス)や、SQLマイグレーションのRLS設定ミスも検出します。ただしいずれも「形」や「データフロー」を見る検出であって、後述のとおり「公開してよいか」という意味の判断はしません。 ここは機械に任せて取りこぼしを潰すべき領域です——詳しくはAegisを参照してください。これらの検出をCIで継続的に回す方法はCIでSARIFをGitHub Actionsに流す手順にまとめています。
7. 正直なスコープ——「混入の検出」と「公開可否の判断」は別物
ここは強調させてください。秘密の「混入」は機械的に検出できますが、「その値を公開してよいか」は人間の設計判断です。 この線引きを曖昧にする対策は、危険な油断を生みます。
検出ツールができるのは、次のような形の判定までです。
- 「
NEXT_PUBLIC_SERVICE_ROLE_KEYという名前は、公開すべきでない秘密に公開接頭辞が付いている疑いがある」——名前のパターンから警告できる。 - 「
sk_live_...の形の文字列がクライアントバンドルに含まれている」——形から確実に拾える。 - 「
.env.localがコミットされた」——ファイル名と中身のパターンで止められる。
しかしツールには、次が構造的に分かりません。
- ある独自トークンが「公開してよい公開鍵」なのか「隠すべき秘密」なのか。名前にも形にも現れない、そのシステムにおける意味で決まる。
NEXT_PUBLIC_FEATURE_FLAG_Xを公開してよいか。機能の内部情報を晒すなら問題だが、それは事業上の機密性の判断であって、コードの外形からは導けない。- 「この値はクライアントに必要か、それともサーバーに処理を移すべきか」という設計の取り違えそのもの。
// ツールには区別できない例:名前も形も中立な独自トークン
const token = process.env.NEXT_PUBLIC_INTERNAL_TOKEN;
// これが「公開前提の識別子」なら正しい。
// 「内部システムの認証トークン」なら重大な漏洩。
// 違いはこのシステムでの“意味”だけ。形も名前も同じ。
つまり、Aegis のようなスキャナは秘密の混入を止められても、「何を公開してよいか」は決められません。 ツールがクリーンでも、それは「既知の罠は踏んでいない」であって「公開設計が正しい」ではない。この判断は、OWASP の Application Security Verification Standard(ASVS) が示す「設定・秘密管理の検証」と同じく、人間が要件に照らして検証する領域です。秘密の不適切な公開は OWASP Top 10 の設定不備(Security Misconfiguration)が扱う典型でもあります。検出は補完であって、設計判断の置き換えではありません。
なお、鍵そのものの責務分離(anon と service_role で何が許されるか、どこに置くか)は、本記事の env 境界よりさらに踏み込んだ主題です。鍵の扱いの詳細はanonキーとservice_roleキーの露出ガイドに切り出しています。
8. 漏れた後の対応——ローテーションは「必須」であって「任意」ではない
予防と検出を固めても、事故は起こり得ます。重要なのは、一度でも秘密が公開側(バンドル・Git履歴・ログ)に出たら、その鍵は「漏れた」ものとして扱うことです。バンドルを差し替えても、Git履歴を消しても、すでに配布・取得された分は取り消せません。
対応の原則は次のとおりです。
- まず鍵をローテーションする。 漏れた鍵を無効化し、新しい鍵を発行する。これが最優先。「たぶん誰も見ていない」は根拠になりません。
NEXT_PUBLIC_に焼き込まれた値は、配布済みバンドルから誰でも取り出せます。 - 次に経路を塞ぐ。 第2〜5節の予防(接頭辞の修正、
server-only境界、.gitignore、設計の見直し)を入れ、同じ事故が再発しない構造にする。 - 最後に検出を常設する。 第6節のスキャンをCIに入れ、退行を機械的に止める。
ローテーション手順は鍵の種類で異なります(Supabase の API キー、Stripe の secret、Resend のキー等、各サービスのダッシュボードで再発行)。「漏れたかもしれない」段階で動くのが安全側です。疑わしきはローテーション、です。
9. 本番前チェックリスト
外注でもAI製でも、本番投入の前に最低限これだけは確認してください。観点と危険信号を併記します。
- 秘密に
NEXT_PUBLIC_が付いていない。NEXT_PUBLIC_は「公開掲示板に貼る値」だけ(service_role・API秘密・DB接続文字列は接頭辞なし) - 秘密を読むモジュールに
import "server-only"を貼っている。クライアントからの import がビルド時に弾かれる - env をサーバー用とクライアント用の2ファイルに分け、Zodで起動時検証している(クライアント側に秘密が物理的に無い)
-
NEXT_PUBLIC_をprocess.env.NEXT_PUBLIC_Xの形で直接参照している(動的アクセス・分割代入はインライン化されず undefined になる) -
.env*が.gitignoreされ、git ls-files | grep envがクリーン(履歴にも秘密が無い) - ビルド出力(
.next/static)を秘密の形で grep し、混入が無いことを確認した - シークレットスキャン(
npx @aegiskit/cli scan等)をCIに常設している - 過去に漏れた疑いのある鍵はローテーション済み(「たぶん大丈夫」で放置していない)
- クライアントで秘密が「要る」処理は、Route Handler / Server Action に移してある
発注者の視点で最も効くのは、**「NEXT_PUBLIC_ が付いている環境変数を全部見せてください」「service_role キーはどこで使っていますか」「秘密ファイルはコミットされていませんか」**の3問です。良い開発者は即答できます。
10. どこまで自分で、どこから設計相談か
最後に、正直に線を引きます。
秘密の混入対策は、ほぼ全面的に自動化と仕組みで固められます。 server-only 境界・型付き env・.gitignore・シークレットスキャンをCIに入れれば、「秘密が公開側に出る」事故は機械的に止まります。まずは Aegis(無料OSS、npx @aegiskit/cli scan)で現状を可視化するのが、最もコスパの良い第一歩です。混入の検出は、人間が毎回気をつける領域ではありません。
一方、「何を公開してよいか」の判断と、秘密を扱う処理の置き場所(クライアントかサーバーか)の設計は、人間の領域です。独自トークンの機密性、機能フラグの露出可否、そして「クライアントで秘密が要る」という錯覚の解消——これらはあなたのシステムの意味と事業ルールを理解した人間にしか判断できません。ツールは混入を止められても、公開設計が正しいことは証明しません。完全に安全にする魔法はありません。
env 境界の混入が水平統制であるのに対し、認可・RLS・テナント分離といった垂直リスクは、より深い設計判断を要します。アプリ層全体でどこを自動化し、どこから設計が要るかはNext.js × Supabase アプリケーションセキュリティ完全ガイドに地図を描きました。既存アプリの秘密管理・env 境界・公開設計のレビューが必要なら、セキュリティ監査で承ります。私自身、環境分野のサーバーレス決済プラットフォームで、複数バックエンド・フロントエンドにまたがる秘密管理と env 境界を、決済の信頼性レイヤーとあわせて実運用で設計してきました。
よくある質問(FAQ)
Q. まず何から手をつければいいですか?
A. 順番があります。(1) NEXT_PUBLIC_ が付いた env を全部洗い出し、秘密が混じっていないか確認、(2) 秘密を読むモジュールに import "server-only" を貼って境界を強制、(3) npx @aegiskit/cli scan とビルド出力の grep で混入を可視化。この3つで最頻出の事故をほぼ潰せます。
Q. anon キーや Supabase URL を NEXT_PUBLIC_ で公開するのは危なくないですか?
A. anon キーは「ブラウザに配ることを前提に設計された公開鍵」で、安全性は背後のRLSが担保します(Supabase: API keys)。URLも公開前提です。危険なのは service_role のような「公開を前提としない管理鍵」を NEXT_PUBLIC_ に置くこと。同じ『キー』でも公開可否は性質で決まるので、一律「キーは隠す」でも「URLは出してよい」でもなく、値ごとに判断します。
Q. process.env.NEXT_PUBLIC_X がクライアントで undefined になります。
A. 動的アクセス(process.env[key])や分割代入(const { NEXT_PUBLIC_X } = process.env)が原因のことが大半です。NEXT_PUBLIC_ はビルド時に静的置換されるため、キーをリテラルとして直接参照しないと置換されません。第4-3節の書き方に直してください。
Q. 秘密を .env から間違えてコミットしました。削除すれば大丈夫ですか?
A. ファイルを消すだけでは不十分です。Git履歴に残るうえ、共有済みなら取得された可能性があります。漏れた鍵はローテーション(無効化+再発行)が必須です。履歴除去は破壊的操作なので、チーム合意のうえで行い、いずれにせよ鍵は作り直してください。
Q. シークレットスキャンがクリーンなら安全ですか? A. いいえ。スキャンが見るのは「既知の形の秘密が混入していないか」までです。独自形式の秘密や、「その値を公開してよいか」という設計判断(第7節)は拾えません。クリーンは「よくある罠は踏んでいない」であって「公開設計が正しい」ではない。検出はレビューと脅威モデリングの補完です。
まとめ:境界は「接頭辞一文字」に宿る
要点を整理します。
- Next.js の env は2種類——
NEXT_PUBLIC_付きはビルド時にクライアントバンドルへ焼き込まれて公開、接頭辞なしはサーバープロセス内だけ。この一線が秘密漏洩の境界そのもの。 - 漏洩は3経路に収束する——(1) 秘密に
NEXT_PUBLIC_を付ける、(2) サーバー設定をクライアントから import して巻き込む、(3).env*をコミットする。とくに②は秘密を「使って」いなくても漏れるので見つけにくい。 - 予防は3層で固める——
server-onlyでサーバー境界をビルド時に強制し、Zodで型付き env を起動時検証し、クライアントには公開可能値だけを通す。仕組みで弾くから、注意力に頼らない。 - 検出は機械化できる——ビルド出力の grep、シークレットスキャナ、
npx @aegiskit/cli scanが混入を形で拾う。漏れた鍵は必ずローテーションする。 - 正直なスコープ——混入の検出は自動化できるが、「何を公開してよいか」は人間の設計判断。ツールは混入を止められても、公開設計の正しさは証明しない。
AIで速く作ること自体は正しい。速く作ったものから秘密を漏らさない——その仕組みづくりや、既存の Next.js アプリの env 境界・秘密管理レビューが必要であれば、お気軽にご相談ください。