Next.jsの環境変数がクライアントに漏れる3つの経路と対策|NEXT_PUBLIC_以外も要注意(2026)
NEXT_PUBLIC_を付けていないのに、Next.js App Routerのビルドでサーバー用シークレットが漏れる経路を実機で検証しました。Client Componentへのprops、Client Component内のprocess.env、ビルド時に固定される公開値の3パターンと、server-only・DTO・taint・CI用スキャナーでの防ぎ方を解説します。
目次
NEXTPUBLICを付けていないシークレットは、なぜブラウザに届くのか
結論
シークレットがブラウザに届く経路は NEXT_PUBLIC_ だけではありません。Next.jsの環境変数ガイドのとおり、クライアントのJavaScriptに埋め込まれるのは NEXT_PUBLIC_ 付きの値だけです。そのため「接頭辞を付けなければ安全」と考えがちですが、App Routerで検証用ビルドを作ったところ、接頭辞なしのサーバー用キーが次の2つの経路で外部に出ました。
- Server Componentが、キーを含むオブジェクトをClient Componentにpropsとして渡した。キーは事前レンダーされたHTMLと
.rscペイロードにそのまま書き出された。 - Client Componentの中で
process.env.STRIPE_SECRET_KEYを直接読んだ。クライアントのJSにはキーが入らなかったが、事前レンダーされたHTMLには入っていた。Client Componentもサーバー上でレンダーされるためです。
対策は構造で行います。process.env の読み取りを server-only のモジュールに閉じ込め、クライアント境界には必要な項目だけのDTOを渡し、保険としてtaintを有効にし、CIでビルド成果物に実際のシークレット値が含まれていないかを検査します。
脅威モデル
守る対象は、サーバー側の権限を持つ認証情報です。決済APIのシークレットキー、データベースURL、署名用シークレット、外部サービスの管理トークンなどが該当します。攻撃者に特別な能力は要りません。ページを開ければ、HTML、JavaScriptのチャンク、RSCペイロードはすべて読めます。静的に事前レンダーされたページで漏れると被害が大きくなります。次のデプロイまで、CDNが同じファイルを全訪問者に配信し続けるからです。
Next.jsの境界は2か所にあり、それぞれ破れ方が違います。
- クライアントバンドル(
.next/static): ビルド時にNEXT_PUBLIC_*の値だけを埋め込み、それ以外はprocess.env.Xのまま残します。誤って接頭辞を付けた値は、そのビルドでは永久に公開されます。 - サーバーからクライアントへの出力(HTMLと
.rsc): Client Componentに渡したpropsと、Client Componentがサーバー上で描画した文字列をすべてシリアライズします。Client Componentのpropsや描画結果に入ったサーバー専用の値は、データとして配信されます。
Next.jsのデータセキュリティガイドも、Client Componentは事前レンダー時にサーバーで動くが、ブラウザで動くコードと同じ前提で扱うべきだと明記しています。
検証:実際のビルドで3つの経路を確かめる
本リポジトリの固定バージョンであるNext.js 15.1.9とReact 19.0.0で、最小構成のApp Routerプロジェクトを作りました。.env.production には、検出用の目印(カナリア値)を2つ入れています。
NEXT_PUBLIC_API_BASE=https://api.example.com/canary-public-7f3a
STRIPE_SECRET_KEY=sk_test_CANARY_SERVER_9b21
経路1:シークレットをpropsで渡す
// app/leak-prop/page.jsx (Server Component)
import ShowConfig from '../ShowConfig';
export default function Page() {
const config = {
apiBase: process.env.NEXT_PUBLIC_API_BASE,
stripeKey: process.env.STRIPE_SECRET_KEY,
};
return <ShowConfig config={config} />;
}
ShowConfig は config.apiBase しか表示しないClient Componentです。キーは画面に出ませんが、ビルド成果物には含まれていました。
$ grep -o 'stripeKey[^,}]*' .next/server/app/leak-prop.rsc
stripeKey":"sk_test_CANARY_SERVER_9b21"
Reactは、コンポーネントが使う項目だけでなく、渡されたpropsのオブジェクト全体をシリアライズします。
経路2:Client Componentの中で process.env を読む
// app/env-client/Client.jsx
'use client';
export default function Client() {
return (
<pre>
{String(process.env.NEXT_PUBLIC_API_BASE)}|{String(process.env.STRIPE_SECRET_KEY)}
</pre>
);
}
クライアントのチャンクはドキュメントどおりでした。公開値は埋め込まれ、シークレットへの参照は未解決の式のまま残っています。
$ grep -o '.\{20\}canary-public-7f3a.\{70\}' .next/static/chunks/app/env-client/page-*.js
[String("https://api.example.com/canary-public-7f3a"),"|",String(process.env.STRIPE_SECRET_KEY)]
一方、事前レンダーされたHTMLはサーバー上で生成されます。サーバーには process.env.STRIPE_SECRET_KEY が存在するため、値がそのまま入りました。
$ grep -o '<pre>.*</pre>' .next/server/app/env-client.html
<pre>https://api.example.com/canary-public-7f3a<!-- -->|<!-- -->sk_test_CANARY_SERVER_9b21</pre>
.next/static だけを検査していたら、このビルドは「問題なし」と判定されていたはずです。
経路3:ビルド時に固定される NEXT_PUBLIC_ の値
これはサーバー用シークレットの漏えいそのものではありませんが、危ない回避策を生みやすい挙動です。Next.jsのドキュメントにあるとおり、NEXT_PUBLIC_ の値はビルド後に固定されます。同じDockerイメージをステージングから本番へ昇格させると、ブラウザにはステージングの値が見え続けます。これを避けるために、接頭辞なしの変数に移してServer Componentから渡す方法を取るチームがあります。公開URLならそれで問題ありません。ただし、誰かが同じ設定オブジェクトにシークレットを追加した時点で経路1になります。
検証結果のまとめ
- Client Componentで
NEXT_PUBLIC_API_BASEを読む: クライアントJSに埋め込まれ、HTMLにも含まれる(想定どおり)。 STRIPE_SECRET_KEYをpropsで渡す: クライアントJSには含まれないが、HTMLと.rscペイロードに含まれる。- Client Componentで
STRIPE_SECRET_KEYを読む: クライアントJSには含まれないが、HTMLに含まれる。 - Client Componentから
server-onlyモジュール経由で読む: ビルドが失敗した。 experimental_taintUniqueValueで登録した値をClient Componentに渡す: ビルドが失敗した。
対策
1. シークレットは server-only のモジュールでだけ読む
認証情報を読む process.env は、クライアントからimportできない1つのモジュールに集めます。
// lib/secrets.js
import 'server-only';
export function getStripeKey() {
return process.env.STRIPE_SECRET_KEY;
}
Client Componentからこのモジュールをimportすると、ビルドが止まりました。
./lib/secrets.js
Error: You're importing a component that needs "server-only". That only works in a Server Component ...
Import trace for requested module:
./lib/secrets.js
./app/so/Client.jsx
これで経路2は防げます。ただし経路1は防げません。Server Componentはこのモジュールを正当にimportでき、その値をClient Componentのpropsに渡せてしまうからです。
2. 設定オブジェクトではなくDTOを渡す
データセキュリティガイドは、Data Access Layerから必要最小限のDTO(Data Transfer Object)を返すよう推奨しています。設定値にも同じ考え方を当てはめ、設定オブジェクトを丸ごと渡さず、公開してよい項目を名前で指定してpropsを組み立てます。
export default function Page() {
return <ShowConfig config={{ apiBase: process.env.NEXT_PUBLIC_API_BASE }} />;
}
コードレビューでは、Client Componentのpropsが Config、Env、Settings、DBの行そのものといった広い型になっていたら指摘対象にします。
3. 保険としてtaintを有効にする
experimental.taint を有効にすると、Reactの experimental_taintUniqueValue が使えます。シークレットを読み込む server-only モジュールの中で、一度だけ登録します。
// next.config.js
module.exports = { experimental: { taint: true } };
// lib/secrets.js
import 'server-only';
import { experimental_taintUniqueValue } from 'react';
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (stripeKey) {
experimental_taintUniqueValue('Do not pass STRIPE_SECRET_KEY to the client.', process, stripeKey);
}
export function getStripeKey() {
return stripeKey;
}
検証環境では、登録済みの値をClient Componentに渡すページが、指定したメッセージ付きで事前レンダーに失敗しました。
Error occurred prerendering page "/taint".
Error: Do not pass STRIPE_SECRET_KEY to the client.
Export encountered an error on /taint/page: /taint, exiting the build.
taintは実験的な機能で、Next.jsのガイドでも追加の防御層という位置づけです。Reactのドキュメントは、登録した文字列を大文字化したり一部を切り出したりして作った値は保護されないとも注意しています。主な対策はあくまでDTOです。
4. 本当に公開してよい値かを判断する
新しい NEXT_PUBLIC_ 変数を追加するときは、次の基準で判断します。
- アナリティクスID、公開APIのベースURL、機能フラグの既定値: 公開してよい。誰でも読める前提で扱います。
- ブラウザ用に設計された公開キー(例:Stripeの
pk_): 公開してよい。ベンダーがクライアントでの利用を想定しているキーです。ダッシュボード側で利用制限をかけます。 - シークレットAPIキー、DB URL、Webhook署名シークレット、JWTシークレット: 不可。一度でも接頭辞付きでビルドしたら、直ちにローテーションします。
- 同じイメージで環境ごとに値が変わるもの: 避ける。動的レンダリング中にサーバー側で読みます。
シークレットが公開ビルドに一度でも入ったなら、変数名を変えても解決しません。古いHTMLやチャンクが、CDNのキャッシュ、プレビューデプロイ、ブラウザのキャッシュに残っている可能性があります。まず認証情報をローテーションし、そのあとでコードを直します。
確認:ビルド成果物から実際のシークレット値を探す
ソースコードで NEXT_PUBLIC_ を検索しても、見つかるのは経路3の誤りだけです。経路1と2を見つけるには、ビルド成果物の中から実際のシークレット値を探します。次のスクリプトは、CIジョブの環境変数から指定した名前の値を読み、クライアントのチャンクや事前レンダー出力に含まれていれば失敗します。
// scripts/scan-build-for-secrets.mjs
// Usage: node scripts/scan-build-for-secrets.mjs STRIPE_SECRET_KEY DATABASE_URL
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const names = process.argv.slice(2);
const secrets = names
.map((name) => [name, process.env[name]])
.filter(([, value]) => value && value.length >= 8);
// Files that reach the browser: client chunks plus prerendered HTML and RSC payloads.
const roots = ['.next/static', '.next/server/app'];
const exts = ['.js', '.html', '.rsc', '.json', '.body'];
function* walk(dir) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) yield* walk(path);
else if (exts.some((ext) => path.endsWith(ext))) yield path;
}
}
let leaks = 0;
for (const root of roots) {
for (const file of walk(root)) {
// Server bundles under .next/server/app/**/page.js may legitimately reference secrets.
if (root === '.next/server/app' && file.endsWith('.js')) continue;
const text = readFileSync(file, 'utf8');
for (const [name, value] of secrets) {
if (text.includes(value)) {
console.log(`LEAK ${name} -> ${file}`);
leaks++;
}
}
}
}
console.log(
leaks
? `${leaks} leak(s) found`
: `OK: ${secrets.length} secret(s) not found in client-facing output`
);
process.exit(leaks ? 1 : 0);
脆弱な状態のプロジェクトで実行した結果です。
LEAK STRIPE_SECRET_KEY -> .next/server/app/env-client.html
LEAK STRIPE_SECRET_KEY -> .next/server/app/leak-prop.html
LEAK STRIPE_SECRET_KEY -> .next/server/app/leak-prop.rsc
3 leak(s) found
DTOと server-only の対策を入れたあとの結果です。
OK: 1 secret(s) not found in client-facing output
注意点が2つあります。1つ目に、このスクリプトが出力するのは変数名とファイルパスだけで、値は出しません。CIのログに出しても問題はありませんが、実行するのはそのシークレットにもともとアクセスできるジョブに限ってください。2つ目に、対象は事前レンダーされたルートだけです。動的ルートは、ステージング環境でページを取得し、HTMLとRSCレスポンス(リクエストヘッダー RSC: 1 を付ける)を保存して、同じ方法で検索します。
GitHub Actionsでは、next build の直後に同じジョブで実行します。ビルドと同じ環境のシークレットで検査するためです。ワークフロー自体の権限を絞る方法は、GitHub Actionsのセキュリティ強化チェックリストを参照してください。
参考資料
- How to use environment variables in Next.js — Next.js Docs
- How to think about data security in Next.js — Next.js Docs
experimental.taint— Next.js config referenceexperimental_taintUniqueValue— React Docs- 検証環境:Next.js 15.1.9、React 19.0.0(App Router、webpackビルド、静的事前レンダー)。上記の出力はこの検証用プロジェクトから取得したものです。ファイル名やチャンクのハッシュは、ビルドやバージョンによって変わります。