セキュリティヘッダーの設定: CSP・HSTSを事故らせず段階導入する手順
CSP・HSTS・X-Frame-Options・Referrer-Policy・Permissions-Policyの意味と設定値、Report-Onlyでの段階導入、検証ツールまで。広告や計測を壊さず守りを固める手順。
「とりあえず一番強いCSPを入れとこう」
そう思って Content-Security-Policy を本番に直で入れた金曜の夜。月曜にサイトを開いたら、AdSenseの広告が全部消えていました。GA4も計測ゼロ。問い合わせフォームの送信ボタンが、押しても無反応。
ヒヤッとしました。攻撃を受けたわけじゃない。自分で自分のサイトの足を撃ち抜いていただけです。
セキュリティヘッダーは、地味なのに公開サイトの守りを大きく左右します。たった1行の不足でXSSやクリックジャッキングを通してしまうし、逆に欲張りすぎると広告も計測もフォームも一緒に止まる。「全部を最強にする」作業じゃなくて、「自分のサイトが本当に読み込むものを棚卸しして、少しずつ狭める」作業なんですね。
この記事では、僕が事故った経験をもとに、主要なヘッダーの意味と設定値、そして一番大事な「壊さずに入れる順番」をまとめます。
この記事の要点
- セキュリティヘッダーは攻撃を防ぐ盾だが、強すぎると広告・計測・フォームを巻き込んで壊す。だから段階導入が必須。
- CSPは
Content-Security-Policy-Report-Onlyから始める。いきなり本番モードにしない。違反を「報告だけ」させて、何が壊れるかを先に観測する。 - HSTSの
preloadは初日に入れない。まずmax-age=300の短い値で試し、全サブドメインのHTTPS化を確認してから1年(31536000秒)以上に伸ばす。戻すのに時間がかかるから。 X-Frame-Optionsより、いまの主役はCSPのframe-ancestors。役割の違うframe-srcと混同しない。- 入れたら必ず検証する。
curl -I、ブラウザのConsole、Security Headers、CSP Evaluatorの4点セット。
6つのヘッダーが、それぞれ何を守るのか
まず登場人物を整理します。専門用語のまま並べても頭に入らないので、身近な例えを添えます。
| ヘッダー | ひとことで言うと | 防ぐ事故 |
|---|---|---|
Content-Security-Policy(CSP) | ブラウザに渡す「読み込み許可リスト」 | XSS、不正スクリプト混入 |
Strict-Transport-Security(HSTS) | 「次から必ずHTTPSで来て」とブラウザに記憶させる札 | 通信の盗み見、なりすまし |
X-Frame-Options / frame-ancestors | 「このページを誰がiframeに入れていいか」 | クリックジャッキング |
X-Content-Type-Options | 「ファイルの中身を勝手に推測するな」の指示 | MIMEタイプ詐称 |
Referrer-Policy | 「リンク先にどこまで元URLを教えるか」 | 参照元URLの漏えい |
Permissions-Policy | 「カメラ・マイク・位置情報を使わせるか」 | 意図しない機能許可 |
CSPは、いわばクラブの入口に立つ門番です。「このサイトに名前が載ってる業者だけ通す」と決めておく。名簿にないスクリプトは、たとえ攻撃者が忍び込ませても実行できません。
HSTSは郵便の転送届に近い。一度「HTTPSで届けて」と登録すると、ブラウザは次回から勝手にHTTPSで取りに行きます。だから途中で盗み見されにくい。ただし転送届と同じで、間違えて登録すると取り消しに時間がかかる。これが後で効いてきます。
frame-ancestors と X-Frame-Options はどちらも「自分のページを他人のiframeに入れさせない」ための仕組み。銀行のログイン画面を透明なiframeで重ねて、利用者に気づかれず操作させる——あのクリックジャッキングを防ぎます。
設定する前に、外部リソースを棚卸しする
いきなりコードを書くと、たいてい失敗します。自分のサイトが何を読み込んでいるか把握しないまま許可リストを書くと、必ず何かが漏れて壊れるからです。
僕は最初に、この表を埋めます。
| 確認項目 | 例 | どこに反映するか |
|---|---|---|
| スクリプト | Next.js本体、GTM、AdSense、決済SDK | script-src(nonce優先) |
| 画像 | 自サイト、OGP、Cloudinary、S3、Google広告 | img-src |
| 通信先 | API、GA4、Sentry、決済、CSP report | connect-src |
| 埋め込み | YouTube、地図、SNSウィジェット | frame-src |
| フォント | Google Fonts | font-src |
| 自分が埋め込まれる先 | 管理画面は埋め込ませない/一部だけ許可 | frame-ancestors |
| HTTPS化 | 全サブドメインがHTTPSか | HSTSの includeSubDomains / preload |
ここで大事なのが、似た名前のディレクティブを混同しないこと。frame-src は「自分のページがどのiframeを読み込めるか」(例: YouTubeを埋め込む)。frame-ancestors は「自分のページを誰がiframeに入れていいか」(クリックジャッキング対策)。名前が似てるだけで役割は正反対です。GA4の通信が失敗するなら大体 connect-src、画像CDNが映らないなら img-src、というふうに、症状とディレクティブを対応づけて覚えると迷いません。
棚卸しが済んだら、ようやく設定です。
まずReport-Onlyで「壊れる場所」を観測する
ここが今日いちばん伝えたいところです。冒頭の事故は、Content-Security-Policy をいきなり本番モードで入れたから起きました。
正しい順番は、先に Content-Security-Policy-Report-Only を出すこと。これは「違反を見つけたら報告するけど、ブロックはしない」モードです。つまり広告も計測もフォームも普通に動いたまま、「もし本番CSPだったら、ここが止まっていましたよ」という情報だけが集まる。実害ゼロで予行演習ができるわけです。
全体の流れはこうです。
flowchart LR
A["外部リソースを棚卸し"] --> B["Report-OnlyでCSPを配信"]
B --> C["ConsoleとCSP reportを確認"]
C --> D["広告・計測・CDNの必要ドメインを分類"]
D --> E["nonce/hash中心の本番CSPへ移行"]
E --> F["Security HeadersとCSP Evaluatorで検証"]
Report-Onlyで違反レポートを受け取る最小の受け口を、コピペで動く形で書きます。Node.jsとExpressがあれば動きます。
// report-server.mjs — CSP違反レポートを受け取って中身を見るだけの最小サーバー
import express from "express";
const app = express();
// CSP reportはブラウザによってContent-Typeが違う。まとめてテキストで受ける
app.post(
"/csp-report",
express.text({ type: ["application/csp-report", "application/reports+json", "*/*"] }),
(req, res) => {
let body = req.body;
try {
// JSONで来たら整形して見やすくする
body = JSON.stringify(JSON.parse(req.body), null, 2);
} catch {
// テキストのままならそのまま使う
}
// 本番では個人情報を保存しないこと。ここでは違反内容だけ確認する
console.warn("=== CSP違反レポート ===\n" + String(body).slice(0, 4000));
res.status(204).end(); // ブラウザには「受け取ったよ」とだけ返す
}
);
// 動作確認用のテストページ。わざとCSPに違反するインラインスクリプトを置く
app.get("/", (_req, res) => {
res.setHeader(
"Content-Security-Policy-Report-Only",
"default-src 'self'; script-src 'self'; report-uri /csp-report"
);
res.send(`<!doctype html><html><body>
<h1>CSPテスト</h1>
<script>console.log("このインラインJSが違反として報告される");</script>
</body></html>`);
});
app.listen(3000, () => console.log("http://localhost:3000 で確認"));
これを node report-server.mjs で起動して http://localhost:3000 を開くと、インラインスクリプトが script-src 'self' に引っかかって、ターミナルに違反レポートが流れてきます。本番ではこのログにURL・違反ディレクティブ・blocked URI・発生回数を残し、個人情報は保存しない設計にします。
注意。報告されたドメインを全部許可リストに足してはいけません。ブラウザ拡張機能、社内プロキシ、古いタグ、攻撃の試行も混ざっています。「自分が意図的に使っているもの」だけを拾います。
Next.jsでnonce付きCSPを本番運用する
Report-Onlyで壊れる場所が分かったら、本番CSPに移ります。CSPの難所はNext.jsです。
App Routerでnonce(このレスポンスで1回だけ有効な合言葉)を使う場合、Next.js公式は proxy.ts でリクエストごとにnonceを作る方法を示しています。2026年6月時点のNext.js 16系では「Proxy」という名称ですが、古いプロジェクトでは middleware.ts として同じ考え方が残っている場合があります。
ここで知っておくべき大事な前提があります。nonceはリクエストごとに変わるため、ページを完全に静的キャッシュする前提とは相性が悪い。公式ドキュメントも「nonceを使うと全ページが動的レンダリングになる」と明記しています。ブログやドキュメントのように静的配信したいページではhashベースCSP(ファイルの中身から作る指紋で許可する方式)や外部JSへの分離を検討し、ログイン後のアプリ・決済・管理画面ではnonceの価値が高い、と使い分けます。
// proxy.ts
import { NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const isDev = process.env.NODE_ENV !== "production";
const csp = [
"default-src 'self'",
// 'strict-dynamic' を付けると、nonce付きスクリプトが読み込む子スクリプトも信頼される
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ""} https:`,
`style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${nonce}'`} https://fonts.googleapis.com`,
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https:",
"connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com",
"frame-src 'self' https://www.youtube-nocookie.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'", // このページを他サイトのiframeに入れさせない
"upgrade-insecure-requests",
"report-uri /api/csp-report",
]
.join("; ")
.replace(/\s{2,}/g, " ")
.trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce); // layout側でnonceを読めるようにする
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp);
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=(self)"
);
// HSTSは本番初日は短く。全サブドメイン確認後に段階的に伸ばす
response.headers.set("Strict-Transport-Security", "max-age=300; includeSubDomains");
return response;
}
export const config = {
matcher: ["/((?!api/csp-report|_next/static|_next/image|favicon.ico).*)"],
};
GTMなどにnonceを渡すときは、layout.tsx で headers() から x-nonce を読みます。GoogleのCSPガイドもnonceを推奨していて、AdSenseもCSPを使うならドメイン許可だけでなくstrict CSP(nonce方式)を前提にした案内を出しています。
// app/layout.tsx
import { GoogleTagManager } from "@next/third-parties/google";
import { headers } from "next/headers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="ja">
<body>
{children}
<GoogleTagManager gtmId="GTM-XXXXXXX" nonce={nonce} />
</body>
</html>
);
}
落とし穴がひとつ。開発環境用の 'unsafe-eval' を本番に残さないこと。Reactの開発用デバッグでは eval が必要になりますが、本番では不要です(公式も「本番では使わない」と明言)。style-src 'unsafe-inline' を永久に残すのも、CSPの効果をかなり削ります。CSS-in-JSの事情がある場合も、最終的にはnonce対応や外部CSS化を目指します。
HSTSのpreloadは、急がないほうがいい
HSTSで一番やらかしやすいのが preload です。
preload を付けて hstspreload.org に登録すると、ブラウザに最初からHTTPS強制が焼き込まれます。強力です。が、公式サイトは「preload をデフォルトで入れるな」とはっきり書いています。理由は単純で、登録後に「検証用のサブドメインだけHTTPだった」と気づいても、ブラウザ側のリストから消えるまで時間がかかるから。下手をすると一部のサブドメインが数週間アクセス不能になります。
しかも登録には条件があります。公式が求める本番ヘッダーは max-age=63072000; includeSubDomains; preload(max-ageは2年)で、2017年10月11日以降の申請は最低でも 31536000 秒(1年)が必要です。1年以上HTTPS強制を確定させる覚悟がいる、ということです。
だから順番はこう。
- まず
max-age=300(5分)で出す。何か壊れてもすぐ戻せる。 - 問題なければ
max-age=604800(1週間)に伸ばす。 - 全サブドメインのHTTPS化を確認したら
max-age=31536000(1年)へ。 - 本気でリスト登録するときだけ、最後に
includeSubDomains; preloadを足して2年に。
各段階でアクセスログと、売上・問い合わせ・広告表示を見ます。攻撃を防ぐつもりで自分の収益導線を止めたら本末転倒なので。
静的サイトはCloudflare Pagesの _headers で始める
nonceが要らない静的サイトやLPなら、Cloudflare Pagesの _headers ファイルで固定ヘッダーを置くのが一番ラクです。ただし静的な _headers ではリクエストごとのnonceは作れません。nonceが要るならWorkers/Functionsかhashベースに切り替えます。
# public/_headers または dist/_headers
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self)
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https:; connect-src 'self' https://www.google-analytics.com; frame-src 'self' https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri /csp-report
ここでもCSPはまず Report-Only で出している点に注目してください。静的サイトでも予行演習は飛ばしません。Cloudflare側でHSTSを設定するなら、アプリ側と二重に出さないこと。二重ヘッダーは検証ツールで読みにくく、どちらが効いているか分からなくなります。
入れたら必ず検証する: curl・Console・スキャナ
設定して終わり、にしないでください。僕の金曜の事故も、curl -I を1回叩いていれば防げたものでした。
まずレスポンスヘッダーを直接見ます。
curl -I https://example.com/
curl -I https://example.com/login
curl -I https://example.com/embed/widget
確認するのは次の5点です。
- CSPが想定ルートに出ているか
- Report-Onlyと本番CSPを二重に出していないか
- HSTSがHTTPSレスポンスにだけ出ているか
- 埋め込み専用ページで
frame-ancestorsが正しいか - 広告・計測がブラウザのConsoleでブロックされていないか
外部スキャナでは Security Headers で全体を、CSP Evaluator でCSPの弱点を見ます。スコアは便利ですが、A+だけを目的にしないこと。たとえば他社サイトに埋め込ませるウィジェットを提供しているなら、frame-ancestors 'none' が正解ではないページもあります。サイトの機能を理解した上で、ページ単位で評価します。
なお、こうした設定とレビューはClaude Codeに任せると速いです。ただし「CSPエラーを直して」とだけ頼むと script-src * 'unsafe-inline' 'unsafe-eval' のような広すぎる緩和を提案されることがある。エラーは消えますが、CSPの価値も消えます。「調査→Report-Onlyで設計→検証」の順番を指示に書いておくと安定します。セキュリティ監査の全体像(依存脆弱性・シークレット・OWASP観点)はClaude CodeでWebアプリを監査する手順に、Claude Codeに鍵や本番を任せる前の基本は初心者がまず守る5つの安全対策にまとめています。広告や計測が止まると収益に直結するので、計測側の設計はアナリティクス実装ガイドも合わせて読むと判断しやすいはずです。
よくある質問
Q. CSPとHSTS、どちらから入れるべき?
A. まずHSTSの短い max-age とCSPの Report-Only を同時に出すのが安全です。HSTSは挙動が読みやすく事故が少ない。CSPは観測しながら詰めるものなので、いきなり本番モードにしないでください。
Q. X-Frame-Options と frame-ancestors は両方いる?
A. いまの主役はCSPの frame-ancestors です。ただし古いブラウザのために X-Frame-Options: DENY(または SAMEORIGIN)も併記しておくと無難。ALLOW-FROM は廃止扱いなので使いません。
Q. AdSenseやGTMが消えた。ドメインを許可すれば直る? A. 単純なドメイン追加では直らないことがあります。GTMのコンテナスニペットやAdSenseはインラインJavaScriptを使うため、nonceの受け渡しが必要です。Googleの公式CSPガイドもstrict CSP(nonce方式)を案内しています。
Q. Permissions-Policy は何を書けばいい?
A. 使わない機能は camera=(), microphone=(), geolocation=() のように空で閉じ、決済など必要なものだけ payment=(self) で許可します。親frameで禁止した機能は子frameで復活できない点に注意。
Q. ブログのような静的サイトでもnonceは必要?
A. 必須ではありません。静的配信を優先するなら、インラインJSを外部ファイル化してhashベースCSP、もしくはCloudflareの _headers で固定ヘッダー、という選択が現実的です。nonceの価値が高いのはログイン後のアプリです。
実際に試した結果
冒頭の金曜の事故のあと、僕は「最初から本番CSPを入れる」のをやめました。代わりに必ず Report-Only を先に出します。
検証用のNext.js構成でReport-Onlyを配信したら、GTMのインラインスニペット、GA4の connect-src、YouTube埋め込みの frame-src、画像CDNの img-src が、それぞれ別の理由で違反レポートに上がってきました。本番モードだったら全部止まっていたやつです。Claude Codeにそのログを分類させると、「全部許可」ではなく、ページ別CSP・nonce付与・不要タグ削除に切り分けて直せました。
HSTSも max-age=300 で始めたおかげで、サブドメインのHTTPS化に抜けがあると気づいたとき、安全に戻せました。もしあのとき preload を付けていたら、復旧に何週間もかかっていたはずです。
結局のところ、2026年6月時点で妥当なセキュリティヘッダーの方針はシンプルです。CSPはnonceかhash中心で設計してReport-Onlyから観測する。HSTSのpreloadは慎重に段階導入する。frame-ancestors と X-Frame-Options を役割別に使う。広告・計測・CDNの衝突は検証で潰す。最強の設定を一発で当てにいくより、転んでもすぐ戻せる順番を守る。遠回りに見えて、これが一番速くて安全でした。
自分のリポジトリで同じ手順を試すなら、ヘッダー設定をCLAUDE.mdのレビュー項目に入れておくのがおすすめです。実リポジトリ前提でレビュー手順から相談したい場合は研修・導入相談もどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。