最初に結論を述べます。Next.js のセキュリティヘッダーは、middleware.ts 1枚に集約して自動化できる「水平統制」です。 ヘッダーはアプリ固有のデータモデルを一切知らなくても、全リクエストに一律で効きます。だからこそ「人間が毎回正しく書く」前提を捨て、設定で一度固めるのが正解です。唯一の例外が nonce 方式の CSP で、これだけは「リクエストごとに毎回生成」が必須なため、静的な設定ファイルでは表現できません——middleware で生成し、ヘッダーとアプリの両方へ受け渡す必要があります。
ただし、ここで一つ正直に線を引いておきます。CSP は XSS の「最後の砦」であって、出力の安全設計の代わりにはなりません。 汚染データを dangerouslySetInnerHTML に素通しすれば、CSP があっても被害は出ます。本記事は「ヘッダーで何を機械的に塞げるか」と「ヘッダーでは塞げない、設計が要る領域はどこか」をセットで、Next.js App Router の実コードと一次情報に基づいて整理します。これは Next.js × Supabase アプリケーションセキュリティで描いた「水平統制 vs 垂直リスク」の地図の、ヘッダー領域を深掘りする記事です。
1. なぜヘッダーは「自動化できる水平統制」なのか
セキュリティ対策には、性質の異なる2種類があります。
| 種別 | 例 | 何に依存するか | 自動化 |
|---|---|---|---|
| 水平統制 | セキュリティヘッダー、CSP、レート制限、CSRF、入力検証 | アプリ横断で一律 | 設定で一律に肩代わりできる |
| 垂直リスク | 認可/IDOR、テナント分離、業務ロジック | 「誰が何を所有するか」 | 検出・警告まで。修正は人間の設計 |
セキュリティヘッダーは完全に前者です。Strict-Transport-Security や X-Content-Type-Options: nosniff は、あなたのテーブル構造もユーザーの所有関係も知りません。ただ「全レスポンスに同じ1行を足す」だけで効きます。人間が画面ごとに考えるべきものではなく、1か所に集約して機械に番をさせるべき領域です。
Next.js でこの「1か所」になるのが middleware です。middleware.ts はすべてのリクエストの前段で走るため、ヘッダー付与の単一の関所として理想的です(Next.js docs)。next.config.js の headers() でも静的ヘッダーは付けられますが、後述する nonce はリクエストごとに値が変わるため、next.config.js では表現できません。だからヘッダー戦略は middleware に寄せるのが筋が通ります。
OWASP の Application Security Verification Standard(ASVS)も、セキュリティを「入れたか」ではなく「検証できるか」で測ります。本記事も各ヘッダーについて「何を塞ぐか」と「どう確かめるか」をセットで述べます。
2. 主要セキュリティヘッダーと、各々が塞ぐ脅威
まず地図を渡します。ヘッダーは「1個で全部を守る魔法」ではなく、1個が1つの脅威を塞ぐ部品の集合です。混同すると「CSPを入れたのにクリックジャッキングされた」のような事故になります。
| ヘッダー | 塞ぐ脅威 | 典型値 |
|---|---|---|
Content-Security-Policy | XSS(被害の縮小)、データ持ち出し | default-src 'self'; script-src 'self' 'nonce-…' |
Strict-Transport-Security | HTTPSダウングレード、中間者(MITM) | max-age=63072000; includeSubDomains; preload |
X-Content-Type-Options | MIMEスニッフィングによる誤実行 | nosniff |
Referrer-Policy | URL(トークン等)の外部漏洩 | strict-origin-when-cross-origin |
frame-ancestors(CSP)/ X-Frame-Options | クリックジャッキング | frame-ancestors 'none' / DENY |
Permissions-Policy | カメラ/マイク/位置情報等の濫用 | camera=(), microphone=(), geolocation=() |
順に、要点だけ正確に押さえます。
Strict-Transport-Security(HSTS) は「このサイトは今後 max-age 秒間、必ずHTTPSで来い」とブラウザに強制します。初回アクセスの平文リクエストを後続で消すのが狙いで、preload を付けてブラウザの先読みリストに載せると初回も保護されます。ただし preload は一度載せると取り消しが難しいため、サブドメイン含め恒久的にHTTPS化できる確証がある場合だけにします(MDN Strict-Transport-Security)。
X-Content-Type-Options: nosniff は、ブラウザがレスポンスのMIMEタイプを「推測」して実行する挙動を止めます。例えばユーザーがアップロードしたファイルを、ブラウザが勝手に text/html と解釈してスクリプト実行する、という事故を防ぎます。値は nosniff 一択です。
Referrer-Policy は、別サイトへ遷移する際に送る Referer ヘッダーの粒度を制御します。クエリ文字列にトークンやセッションIDを載せている場合、これが緩いと外部サイトのアクセスログに秘密が漏れます。strict-origin-when-cross-origin(同一オリジンはフルパス、クロスオリジンはオリジンのみ)が実用的な既定です。
クリックジャッキング対策は、CSP の frame-ancestors が現代の正解です。古い X-Frame-Options は後方互換のために併記する程度で、frame-ancestors 'none'(または許可するオリジンを列挙)があれば本質的に十分です。両者が矛盾した場合は CSP が優先されます。
Permissions-Policy は、カメラ・マイク・位置情報・全画面などのブラウザ機能を、自サイトと埋め込みiframeのどちらに許すかを宣言します。使わない機能は camera=() のように空で明示的に閉じておくと、万一のXSSやサードパーティスクリプトによる濫用面が減ります。
ここまでの5つ(HSTS / nosniff / Referrer-Policy / frame-ancestors / Permissions-Policy)は値が固定です。リクエストによって変わりません。問題は6つ目、CSP の script-src だけが「リクエストごとに変わる値(nonce)」を必要とする点です。次節で深掘りします。
3. CSPの基礎 — default-src / script-src と、なぜ 'unsafe-inline' が危険か
CSP(Content Security Policy)は「このページがどこからリソースを読み込み、何を実行してよいか」をブラウザに宣言するヘッダーです。XSS が混入しても、攻撃者のスクリプトを「許可されていない」として実行させないのが狙いです(MDN Content-Security-Policy)。
ディレクティブは「リソース種別ごとの許可リスト」です。最重要な数個だけ押さえます。
Content-Security-Policy:
default-src 'self'; # 既定: 同一オリジンのみ許可
script-src 'self' 'nonce-rAndOm=='; # JS: 自オリジン + このnonce付きのみ
style-src 'self'; # CSS: 自オリジンのみ
img-src 'self' data:; # 画像: 自オリジン + data URI
object-src 'none'; # <object>/<embed> 禁止(古い攻撃面を閉じる)
base-uri 'none'; # <base> 改ざんによる相対パス乗っ取りを防ぐ
frame-ancestors 'none'; # クリックジャッキング対策
default-src はフォールバックで、個別指定のないリソース種別すべてに適用されます。script-src がスクリプトの実行可否を握る最重要ディレクティブです。
なぜ 'unsafe-inline' が危険か
ここが CSP の核心です。多くの「とりあえずCSPを入れた」設定は、こう書かれています。
# アンチパターン: これは「CSPを入れた」が「XSSをほぼ防げない」設定
script-src 'self' 'unsafe-inline';
'unsafe-inline' は「ページ内に直接書かれた <script>…</script> やインラインの onclick= を全部実行してよい」という許可です。しかしXSS の本質は、攻撃者がページにインラインスクリプトを注入することです。つまり 'unsafe-inline' を許した瞬間、CSP は XSS に対してほぼ無力になります。「鍵をかけたが、合鍵を玄関マットの下に置いた」状態です。
同様に 'unsafe-eval'(eval() や文字列からのコード生成を許可)も避けます。
正解: nonce + strict-dynamic
ではどうやって「自分の正規のインラインスクリプトだけ」を許すか。答えが nonce(number used once) です。リクエストごとにランダムな使い捨ての値を生成し、(1) CSP ヘッダーに 'nonce-<値>' として宣言し、(2) 自分が出力する <script> タグに nonce="<値>" 属性を付けます。両者が一致したスクリプトだけが実行されます。攻撃者は、サーバーがそのリクエストで生成したランダム値を事前に知り得ないため、注入したスクリプトに正しい nonce を付けられません。
# 厳格版: nonce で「正規のインラインだけ」を許可
script-src 'self' 'nonce-rAndOm==' 'strict-dynamic';
'strict-dynamic' は「nonce で信頼したスクリプトが、さらに動的に読み込むスクリプトも連鎖的に信頼する」指示です。これにより、ホスト名のallowlist(https://cdn.example.com のような列挙)に頼らずに済みます。allowlist 方式は、信頼したCDN上に1つでも悪用可能なスクリプトがあると破られるため、nonce + strict-dynamic の方が堅牢です。
決定的な制約がここにあります。nonce は「リクエストごとに必ず新しく生成」しなければ意味がありません。 全ユーザー・全リクエストで同じ固定値を使えば、攻撃者がその値を1度読み取るだけで自由にスクリプトを注入できてしまい、nonce を使う意味が消えます。だから nonce 付き CSP は、静的な next.config.js の headers() や、ビルド時に固定される設定では原理的に実現できません。リクエストのたびにコードが動いて値を作る場所——すなわち middleware が必要になります。
4. 実装 — middleware 1枚でnonceを生成し、ヘッダーとアプリへ受け渡す
ここが本記事の実装の核です。難しさは「生成した同じ nonce を、レスポンスヘッダーとレンダリングされる <script> の両方に行き渡らせる」という受け渡しにあります。Next.js App Router では、middleware からリクエストヘッダーに nonce を載せ、Server Component 側で headers() から読み取る、という経路が定石です。
4-1. middleware: nonce生成とヘッダー付与
// middleware.ts — リクエストごとに nonce を発行し、全ヘッダーを1か所で付与する
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// リクエストごとに必ず新規生成。crypto は Edge ランタイムでも利用可
const nonce = crypto.randomUUID().replace(/-/g, "");
const isDev = process.env.NODE_ENV !== "production";
const csp = [
`default-src 'self'`,
// 開発時は HMR のため eval を許す。本番ビルドでは付けない(下の分岐参照)
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`,
// Next.js は styled-jsx 等でインラインstyleを使う。nonce が効かない環境向けの現実解は §6 で後述
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' blob: data:`,
`font-src 'self'`,
`object-src 'none'`,
`base-uri 'none'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`upgrade-insecure-requests`,
].join("; ");
// 1) 後段(Server Component)が同じ nonce を読めるよう、リクエストヘッダーに載せる
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
// 2) ブラウザに効かせる本番のヘッダー群(値が固定のものはここで一律付与)
response.headers.set("Content-Security-Policy", csp);
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("X-Frame-Options", "DENY"); // frame-ancestors の後方互換
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
);
// HSTS は HTTPS 環境でのみ意味を持つ。ローカルHTTPで付けると後で苦労するので本番だけ
if (!isDev) {
response.headers.set(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload",
);
}
return response;
}
// 静的アセットやプリフェッチには CSP を流さない(不要なオーバーヘッドと誤検知を避ける)
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};
ポイントを3つ。(1) crypto.randomUUID() は Edge ランタイムでも使える標準APIで、外部依存なしに十分なランダム性が得られます(base64化したい場合は btoa だが、UUIDのハイフン除去でも実用上問題ありません)。(2) リクエストヘッダーとレスポンスヘッダーの両方に CSP を載せているのは、前者が「アプリへの受け渡し」、後者が「ブラウザへの適用」という別の役割だからです。(3) matcher で静的アセットを除外し、ヘッダー付与のコストとノイズを抑えます。
4-2. アプリ側: nonceを読んで <script> に付ける
middleware が x-nonce に載せた値を、Server Component で読み出します。
// app/layout.tsx — middleware が生成した nonce を読み、自前のインラインスクリプトに付与
import { headers } from "next/headers";
import Script from "next/script";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const nonce = (await headers()).get("x-nonce") ?? "";
return (
<html lang="ja">
<body>
{children}
{/* 例: アナリティクス等の自前インラインスクリプトには必ず nonce を付ける */}
<Script
id="analytics-init"
nonce={nonce}
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `window.dataLayer = window.dataLayer || [];`,
}}
/>
</body>
</html>
);
}
headers() を呼ぶとそのルートは動的レンダリングになります。これは nonce 方式の本質的なトレードオフです——リクエストごとに値が変わる以上、そのページを静的にキャッシュすることはできません。完全静的配信が最優先で、かつ自前のインラインスクリプトが皆無なページでは、nonce を諦めてハッシュ方式('sha256-…')を選ぶ判断もあります。設計上の天秤として認識しておくべき点です。
4-3. 動作確認
実装したら、必ず「効いているか」を確かめます。型チェックや見た目では分かりません。
# レスポンスヘッダーを直接観測する。nonce がリクエストごとに変わることも確認
curl -sI https://localhost:3000/ | grep -iE "content-security-policy|strict-transport|x-content-type|referrer-policy|permissions-policy"
content-security-policy: default-src 'self'; script-src 'self' 'nonce-a1b2c3...' 'strict-dynamic'; ...
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
ブラウザのDevTools Consoleに Refused to execute inline script because it violates the following Content Security Policy directive という違反が出れば、それは「nonce の付け忘れたスクリプトが弾かれている」証拠です。次節の Report-Only を使えば、本番を壊さずにこの違反を観測できます。
5. Report-Only で段階導入する — いきなり強制しない
CSP を最初から強制(enforce)モードで本番投入するのは危険です。サードパーティのウィジェット、計測タグ、next dev の挙動、見落としていた自前インラインスクリプト——これらが一斉に弾かれ、画面が壊れます。正しい順序は「観測 → 修正 → 強制」です。
ブラウザはこのために Content-Security-Policy-Report-Only ヘッダーを用意しています。これは「ポリシーに違反するが、ブロックはせず違反だけを報告する」モードです。
// 段階導入: まず Report-Only で「何が弾かれるはずか」を観測する
const cspHeaderName = ENFORCE_CSP
? "Content-Security-Policy"
: "Content-Security-Policy-Report-Only";
response.headers.set(cspHeaderName, csp);
// 違反レポートの送信先(report-to/report-uri)。収集は Sentry 等のエンドポイントでも可
response.headers.set(
"Reporting-Endpoints",
`csp-endpoint="https://example.com/api/csp-report"`,
);
導入手順は以下です。
- Report-Only で本番にデプロイする。 画面は一切壊れません。
- 数日〜1〜2週間、違反レポートを集める。 実トラフィックでどのスクリプト/スタイル/接続先が弾かれるかが分かります。
- 正規のものをポリシーに足す。 自前スクリプトには nonce を付け、必要なサードパーティだけを許可に加えます。違反が枯れるまで繰り返します。
Content-Security-Policy(強制)へ昇格させる。 ここで初めてブロックが有効になります。
この「Report-Only で実トラフィックの違反を観測してから強制へ昇格」というプロセスを踏むかどうかが、CSP導入の成否を分けます。いきなり強制して障害を出し、CSP ごと外してしまう——これが最もよくある失敗です。
6. 正直な落とし穴 — Next.js特有の現実
実運用で必ずぶつかる点を、誇張せず挙げます。
インラインstyleの扱い。 Next.js は styled-jsx やフレームワーク内部のインラインスタイルを使う場面があり、style-src に nonce を効かせきれないことがあります。現実解は2つ——style-src 'self' 'unsafe-inline' を許容する(styleのインライン許可は、scriptのそれと比べてリスクは限定的だがゼロではないため、データ持ち出し系の攻撃面が残る点は認識する)か、ハッシュで個別許可する。完璧を狙って画面を壊すより、script-src を厳格に保ちつつ style-src は現実的に運用する方が、トータルのリスクは下がります。
サードパーティスクリプト。 計測・チャット・広告タグは、それぞれが要求するオリジンや 'unsafe-inline' 相当を持ち込みます。'strict-dynamic' を使えば多くは nonce 連鎖で動きますが、対応していないタグもあります。「CSPに合わないタグは入れない」という逆方向の意思決定も、セキュリティ上はしばしば正解です。
headers() による動的化。 §4-2 で触れたとおり、nonce を読むページはISR/静的キャッシュの対象外になります。CDNフルキャッシュが事業上クリティカルなページでは、ハッシュ方式や、そのルートだけCSPを緩める判断を要します。
middleware のランタイム。 middleware は Edge ランタイムで動くため、crypto.randomUUID() のような標準APIは使えても、Node.js固有のモジュールは使えません。nonce 生成にNode専用ライブラリを持ち込まないよう注意します。
これらはどれも「ヘッダーを入れれば終わり」ではなく、アプリの構成に合わせた調整が要ることを示しています。とはいえ調整の範囲はアプリ横断の設定の中に閉じており、データモデルの設計判断は不要です。だから依然として「水平統制=自動化できる層」です。
7. これは「水平統制」— Aegisのinitで自動化できる
ここまでの実装は、突き詰めれば定型作業です。nonce 生成、ヘッダー6種の付与、Report-Only からの段階導入、matcher の設定——どれもアプリ固有の知識を要さず、正しく一度書けば全プロジェクトで使い回せます。「人間が毎回 middleware を手書きする」より、雛形を自動生成して必要な調整だけ加える方が、ヒューマンエラー('unsafe-inline' の付け忘れ消し忘れ、nonceの固定化、HSTSの取り違え)を構造的に減らせます。
私が公開している OSS のセキュリティツールキット Aegis は、この水平統制のランタイム強化——ヘッダー/CSP・レート制限・CSRF・型付きenv境界の導入——を1コマンドで雛形化します。
# ランタイム強化の雛形を導入(ヘッダー/CSP・レート制限・CSRF・型付きenv境界)
npx @aegiskit/cli init
過大に主張するつもりはありません。init が生成するのは出発点となる設定であって、§6 で見たサードパーティ調整や、ページごとの動的化トレードオフは、結局あなたのアプリの構成に合わせて詰める必要があります。それでも、白紙から書いて落とし穴を踏むより、検証済みの雛形から始める方が安全で速い。これは Aegis の scan(SASTで現状の穴を可視化)と組み合わせると、「今ある穴を見える化 → ランタイム強化を導入」という流れになります。
8. 最大の注意 — CSPはXSS対策の補完であって、出力設計の代わりではない
ここが、本記事で最も強調したい点です。CSP を入れても、XSS が「なくなる」わけではありません。
CSP は多層防御の一層、いわば「XSS が混入してしまった場合に、被害を縮小する最後の砦」です。攻撃者のスクリプトの実行を妨げる確率は上げますが、そもそも汚染データを安全に出力する責任は、依然としてアプリのコード側にあります。例えば次のコードは、CSP が厳格でも問題が残ります。
// CSP があっても安全設計の代わりにはならない例
// 汚染データをそのまま HTML として注入している
<div dangerouslySetInnerHTML={{ __html: userSuppliedHtml }} />
'unsafe-inline' を許していない nonce 方式 CSP は、ここに注入された <script> の実行を多くの場合は妨げます。しかし、CSP のバイパス手法は研究され続けており、base-uri の不備、許可した古いライブラリ経由の実行、'strict-dynamic' の連鎖の悪用など、設定の穴次第で抜けられます。さらに、スクリプト実行を伴わない攻撃(リンクの差し替え、フォームの乗っ取り、コンテンツ改ざんによるフィッシング)は CSP の script-src では止まりません。
正しい順序はこうです。まず出力を設計で安全にする(汚染データをサニタイズ/エスケープし、危険なシンクに素通ししない)。その上で、CSP を「それでも何か漏れたとき」の保険として重ねる。 順序を逆にして「CSP があるからサニタイズは適当でいい」と考えるのが、最も危険な誤解です。
XSS そのものの防ぎ方——dangerouslySetInnerHTML の安全な扱い、DOM XSS、汚染データのサニタイズ——はNext.jsのXSS/DOM XSS対策で別途詳説しています。CSP はその記事の対策を置き換えるものではなく、補完するものです。これは OWASP のOWASP Top 10が一貫して示す多層防御の思想そのものです。
9. ヘッダーと並ぶ、他の水平統制
ヘッダー/CSP は水平統制の一部にすぎません。同じ「設定で一律に固める」発想で、middleware や境界に寄せるべき仲間がいます。本記事の範囲外なので、それぞれ専門記事に委ねます。
- CSRF / Origin 検証 — Server Actions や
POSTルートのような状態変更経路は、SameSiteCookie に加えて Origin を検証して二段で守ります。詳細はNext.jsのCSRF/Origin保護へ。 - 秘密情報の衛生 —
NEXT_PUBLIC_接頭辞の誤用で秘密がクライアントバンドルに混入する事故は、ヘッダーでは防げません。env境界の設計はNext.jsの環境変数・秘密情報の漏洩防止で扱います。 - レート制限・入力検証 — これらも水平統制で、アプリケーションセキュリティ全体像に地図としてまとめてあります。
重要なのは、**これらはすべて「自動化できる層」**だということです。設定とライブラリで肩代わりでき、CIに番をさせられます。問題は、その先にある認可やテナント分離といった「設計でしか守れない垂直リスク」で、そこは次節の監査領域に移ります。
10. 本番前チェックリスト
外注でもAI製でも、本番投入の前にこれだけは確認してください。
- CSP に
'unsafe-inline'(script)を入れていない。入れるなら nonce/hash を使う - nonce がリクエストごとに変わる(
curl -Iを2回叩いて値が違うこと) - 自前のインラインスクリプト/
<Script>すべてに nonce を付与している - Report-Only で実トラフィックの違反を観測してから強制へ昇格した
-
Strict-Transport-Securityを本番(HTTPS)でのみ付与している(preloadの不可逆性も理解の上で) -
X-Content-Type-Options: nosniff/Referrer-Policy/frame-ancestors(+X-Frame-Options)/Permissions-Policyを付与している -
matcherで静的アセットを除外し、不要なヘッダー付与を避けている - CSP を「サニタイズの代わり」にしていない。汚染データの出力は設計側で安全にしている
- middleware が Edge ランタイム互換(Node専用APIに依存していない)
発注者の視点で効くのは、**「CSPに 'unsafe-inline' は入っていますか?」「nonceはリクエストごとに変わりますか?」「Report-Onlyで段階導入しましたか?」**の3問です。良い開発者は即答できます。
11. どこまで自動化、どこから監査か
最後に正直に線を引きます。
セキュリティヘッダーと CSP は、水平統制として大半を機械的に固められます。 middleware 1枚に集約し、雛形から始め、Report-Only で段階導入する——この型さえ守れば、人間が毎回ゼロから考える必要はありません。まずは Aegis(無料OSS、npx @aegiskit/cli init でランタイム強化、scan で現状把握)で雛形を入れるのが、最もコスパの良い第一歩です。
一方、CSP は XSS の被害を縮小しても、認可・テナント分離・業務ロジックといった「正規リクエストとして現れる垂直リスク」には一切効きません。 これらはヘッダーでもWAFでも構造的に防げず、あなたのデータモデルを理解した人間にしか設計・検証できません。ここで「ヘッダーを入れたから安全」と考えるのが、最も危険な油断です。ヘッダーは入口の保険であって、認可の正しさを証明するものではありません。
だからこそ線引きが要ります。どこまで自分で固め、どこから専門家のレビューが要るか——その判断や、既存アプリの認可・RLS・出力設計を含むレビューが必要なら、セキュリティ監査で承ります。私自身、サーバーレス決済プラットフォームの案件で、決済信頼性レイヤーを含むアプリ層の堅牢化を実運用で設計・検証してきました。
よくある質問(FAQ)
Q. next.config.js の headers() ではダメですか?
A. 値が固定のヘッダー(HSTS、nosniff、Referrer-Policy 等)なら headers() でも付けられます。ただし nonce はリクエストごとに変わるため next.config.js では表現できません。CSPを nonce 方式で運用するなら middleware が必須です。固定ヘッダーも含めて middleware に集約した方が、関所が1つになり管理しやすくなります。
Q. nonce と hash、どちらを使うべきですか?
A. 自前のインラインスクリプトが動的に変わる、または複数ある場合は nonce が扱いやすい。インラインスクリプトが皆無、または固定で、ページを完全静的にキャッシュしたい場合は hash('sha256-…') が向きます。hash なら headers() を呼ばずに済み、静的配信を維持できます。アプリの大半は nonce で問題ありませんが、CDNフルキャッシュ最優先のページだけ hash、という使い分けも有効です。
Q. CSPを入れれば XSS は防げますか? A. 防げません。CSP は XSS が混入した場合の被害を縮小する最後の砦であって、出力の安全設計(サニタイズ/エスケープ)の代わりにはなりません。CSP のバイパス手法も存在します。まず出力を設計で安全にし、その上で CSP を保険として重ねる——この順序が必須です。詳細はXSS対策の記事を参照してください。
Q. Report-Only のまま運用し続けてもいいですか?
A. よくありません。Report-Only は何もブロックしません。違反を観測する移行期間のためのモードであり、ここで止まると「CSPがあるように見えて、実際には何も守っていない」状態になります。違反が枯れたら必ず Content-Security-Policy(強制)へ昇格させてください。
Q. 個人開発や小規模でもここまでやるべきですか?
A. ヘッダーは水平統制で、導入コストが極めて低い(middleware 1枚)のに、塞げる攻撃面は広い。費用対効果が最も高い対策の一つです。最低でも nosniff・Referrer-Policy・frame-ancestors・HSTS の4つと、'unsafe-inline' を使わない CSP は、規模に関わらず入れる価値があります。
まとめ — 1枚に集約し、保険として重ねる
要点を整理します。
- セキュリティヘッダーは アプリ横断で一律に効く「水平統制」。
middleware.ts1枚に集約して自動化するのが正解。 - 各ヘッダーは別々の脅威を1つずつ塞ぐ(CSP→XSS被害縮小、HSTS→MITM、nosniff→MIMEスニッフィング、frame-ancestors→クリックジャッキング、Referrer-Policy→URL漏洩、Permissions-Policy→機能濫用)。1個で全部は守れない。
- nonce 方式 CSP だけはリクエストごとの生成が必須で、静的設定では実現できない。middleware で生成し、ヘッダーとアプリの両方へ受け渡す。
- 本番投入は Report-Only で観測 → 修正 → 強制へ昇格。いきなり強制すると壊れる。
- これらは雛形化でき(
npx @aegiskit/cli init)、自動化できる層。ただし CSP は XSS の最後の砦であって、出力サニタイズ=設計の代わりにはならない。 - ヘッダーで塞げない認可・テナント分離は垂直リスクで、設計と監査の領域。
AIで速く作ること自体は正しい。速く作ったものを、漏らさず安全に固める——その水平統制の自動化や、既存アプリのヘッダー/CSP・出力設計のレビューが必要であれば、お気軽にご相談ください。