はじめに
「Next.js のセキュリティ」と聞いて、まず何から手をつければよいか分かりますか?
XSS、CSRF、Open Redirect、CSP、HttpOnly Cookie、NEXT_PUBLIC_、Server Actions…キーワードは山ほど出てきますが、初心者が一番つまずくのは 「Next.js 特有の落とし穴がどこにあるのか」が見えないこと です。Next.js は Server Component と Client Component が同居するフレームワークなので、Rails や Express のような従来の MVC とは違う「サーバーとクライアントが地続きであるがゆえの罠」が存在します。
本記事では、Next.js (App Router) で開発を始める初心者のために、フロントエンドで押さえておくべきセキュリティの「境界」とその対策を10個に整理 しました。それぞれ「なぜそうするのか」を必ず添えています。
前提
- 対象: Next.js App Router を使い始めた初心者〜中級者
- スタック: Next.js / TypeScript / Zod、バックエンドは Laravel など HTTP API を返すサーバー
- スコープ: フロントエンドのコードで完結する対策のみ を扱います。WAF、TLS、レート制限、IP 制限といったインフラ側の話は別記事に譲ります
- 筆者の立場: バックエンドが別にある(SPA + API)構成を前提にしています。Next.js だけで認証を完結させる場合は適宜読み替えてください
大前提: セキュリティは「境界」で考える
セキュリティ対策は雑多に並べると覚えきれません。Next.js のフロントエンドで起きる脅威は、「どこに信頼の境界線が引かれているか」 で整理すると見通しが良くなります。
| # | 境界 | 主な脅威 | 主な対策 |
|---|---|---|---|
| 1 | 環境変数とバンドル | 機密情報の漏洩 |
NEXT_PUBLIC_ の使い分け / Zod 検証 / server-only
|
| 2 | Server Component → Client Component | データの過剰露出 | Props を Zod でフィルタリング |
| 3 | クライアント → Server Action / Route Handler | 不正リクエスト | 認証・認可・入力バリデーション |
| 4 | サーバーが知っているセッション ↔ ブラウザの保持領域 | トークン盗難 | HttpOnly Cookie |
| 5 | ユーザー入力 → DOM レンダリング | XSS | 自動エスケープを壊さない |
| 6 | 外部サイト → 自サイトの状態変更 | CSRF | Origin 検証 / Same-Site Cookie |
| 7 | URL パラメータ → リダイレクト先 | Open Redirect | ホワイトリスト検証 |
| 8 | HTTP レスポンス | クリックジャック・MIME スニッフィング等 | セキュリティヘッダー |
| 9 | アプリ内エラー → ユーザー表示 | 内部情報の漏洩 | 汎用メッセージ |
| 10 | 自前コード ↔ npm パッケージ | 依存の脆弱性 |
npm audit / Dependabot |
以下、この順番で1つずつ見ていきます。各章は独立しているので、気になるところから読んで構いません。
1. 環境変数とバンドルの境界 — NEXT_PUBLIC_ の有無で世界が分かれる
なぜ気をつける必要があるのか
Next.js では、環境変数の名前に NEXT_PUBLIC_ を付けるかどうかで クライアントの JavaScript バンドルに含まれるかどうか が決まります。これは「サーバーで実行されるコード」と「ブラウザに送られるコード」が同じファイルに同居しうる Next.js 特有の事情から生まれた仕様です。
つまり、NEXT_PUBLIC_API_SECRET_KEY のような名前を付けてしまうと、ビルド時に値がそのままブラウザの JavaScript に埋め込まれ、F12 で誰でも見られる状態になります。これは「漏洩しやすい」ではなく「公開している」と同じです。
対策
// ✅ サーバー専用(API シークレット、DB 認証情報など)
const apiKey = process.env.API_SECRET_KEY;
// ✅ クライアントでも参照可能(公開 URL など)
const publicUrl = process.env.NEXT_PUBLIC_API_URL;
// ❌ 絶対 NG: 機密情報に NEXT_PUBLIC_ を付ける
const apiKey = process.env.NEXT_PUBLIC_API_SECRET_KEY;
Zod スキーマで起動時に検証する
環境変数を process.env.FOO でそのまま参照すると、設定漏れに気づくのが「実行中に undefined が混じった URL を fetch した時」 になってしまいます。これは最悪のパターンです。
そこで Zod で起動時に検証しておきます。
// src/shared/config/env.ts
import { z } from "zod";
const envSchema = z.object({
APP_ENV: z.enum(["local", "development", "staging", "production"]).default("production"),
API_SECRET_KEY: z.string(),
NEXT_PUBLIC_API_URL: z.string().url(),
});
export const env = envSchema.parse(process.env);
// 必須 env が欠けていればここで throw → 起動失敗
ポイント: .optional() は安易に使わない。 未設定でも起動してしまい、本番でだけ undefined になってバグになります。「明示的にデフォルト値を付ける(.default())」か「必須にする(z.string())」かのどちらかにします。optional() を使うのは「未指定時に動的に構築するなど、明確な意図がある場合」だけです。
サーバー専用のモジュールには server-only を付ける
DB アクセスや認証ロジックなど「絶対にクライアントから import されてはいけない」関数には、server-only パッケージをインポートしておきます。
// features/auth/api/getSession.ts
import "server-only";
export async function getSession() {
// サーバー専用のロジック(クライアントから import するとビルドが落ちる)
}
なぜ? うっかり Client Component から import してしまった場合、ビルド時にエラーになって気づけます。チーム開発では事故防止の効果が大きい設定です。
2. Server Component → Client Component の境界 — Props は HTML に露出する
なぜ気をつける必要があるのか
Server Component で取得したデータを Client Component に Props で渡すと、その Props は シリアライズされて HTML / JavaScript に埋め込まれてブラウザに送られます。サーバー側で「画面に表示しないから安全」と思っていたフィールドも、ブラウザの DevTools で見えてしまいます。
たとえば「ユーザーの内部 ID」「ロール情報」「メールアドレス」など、API のレスポンスをそのまま Client Component に渡すと意図せず公開されることがあります。
対策: 渡す前に Zod スキーマでフィルタする
import { z } from "zod";
// クライアントに渡してよいフィールドだけを定義
const userPublicSchema = z.object({
id: z.string(),
name: z.string(),
avatarUrl: z.string().optional(),
});
async function UserPage() {
const user = await getUser(); // 内部用フィールドも含む
const safeUser = userPublicSchema.parse(user); // 公開可能なフィールドだけに絞る
return <UserProfile user={safeUser} />;
}
ポイント: TypeScript の型だけでは「実行時にどのフィールドが渡るか」は強制できません。Zod の parse は 余分なフィールドを落とす(strict mode を使えば検出も可能) ので、型と実行時の両方で守れます。
3. Server Actions / Route Handlers の境界 — 「公開 API」として扱う
なぜ気をつける必要があるのか
"use server" でエクスポートした Server Action は、クライアントから直接呼び出せる HTTP エンドポイント になります。Route Handler (app/api/.../route.ts) も同じです。
つまり、「フォームから送信される前提」だとしても、攻撃者は任意のペイロードで直接 POST してきます。「UI 側でバリデーションしてるから大丈夫」は通用しません。
必須の3点セット: 認証 → 認可 → 入力バリデーション
"use server";
import { z } from "zod";
import { auth } from "@/shared/lib/auth";
import { apiClient } from "@/shared/lib/apiClient";
import { redirect } from "next/navigation";
const updateProfileSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
export async function updateProfile(formData: FormData) {
// 1. 認証チェック(ログインしているか)
const session = await auth();
if (!session?.user) {
redirect("/login");
}
// 2. 認可チェック(その操作をする権限があるか)
const targetUserId = formData.get("userId");
if (session.user.id !== targetUserId && session.user.role !== "admin") {
throw new Error("権限がありません");
}
// 3. 入力バリデーション(送られてきたデータが想定通りか)
const result = updateProfileSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
});
if (!result.success) {
return { error: result.error.flatten() };
}
// 4. 業務ロジック実行
await apiClient.put(`/api/users/${session.user.id}`, { body: result.data });
}
なぜ「認証」と「認可」を分けるのか?
- 認証 (Authentication) = 「あなたは誰か」を確認する(ログインの有無)
- 認可 (Authorization) = 「あなたはこれをやってよいか」を確認する(権限)
たとえばログインしているユーザーが、別の人の userId を渡してきたら? 認証は通っていますが、認可が落ちなければ他人のデータを書き換えられてしまいます。両方が必要 です。
page.tsx の認証・認可は「ガード関数」で集約する
src/app/**/page.tsx の Server Component でも、認証・認可は必須です。ただし、page.tsx ごとに getSession() を呼んで if 文を手書きすると 判定漏れによる権限越境バグ が起きやすくなります(実プロジェクトでも頻発するパターンです)。
そこで「ガード関数」を1ファイルに集約しておくのがおすすめです。
// src/shared/lib/session/guards.ts
export async function requireSuperUser() {
const session = await getSession();
if (!session) return { session: null };
if (!session.isSuperUser) notFound(); // 権限不足は 404
return { session };
}
page 側はこう書きます。
// ✅ 推奨
export default async function AdminRoute() {
const auth = await requireSuperUser();
if (!auth.session) return redirectToLogin();
const data = await fetchSomething(auth.session.clientId);
return <AdminPage data={data} />;
}
// ❌ NG: 手書きの role 判定はチェック漏れの温床
const session = await getSession();
if (!session) return redirectToLogin();
if (!session.isSuperUser) notFound(); // ← これを書き忘れると別ロールでも入れてしまう
**ポイント: 未認証は「ログインへリダイレクト」、権限不足は「404 (notFound)」と分ける。**未認証で 404 を出してしまうと「URL を間違えたのか?」と利用者が混乱します。ロール越境は 404 にすることで「リソースの存在自体を秘匿する」効果もあります。
4. 認証トークンの境界 — 保存場所で安全性が決まる
なぜ気をつける必要があるのか
ログイン後のセッショントークン(または JWT)をどこに保存するかで、XSS が起きた時にどれだけ被害が広がるか が決まります。
| 保存場所 | XSS への耐性 | リロード耐性 | 推奨度 |
|---|---|---|---|
| HttpOnly Cookie | ◎ JavaScript から読めない | ◎ | ✅ 推奨 |
| localStorage | ✕ JS から全部読める | ◎ | ❌ 非推奨 |
| sessionStorage | ✕ JS から全部読める | △ タブを閉じると消える | ❌ 非推奨 |
| メモリ(状態管理) | ◎ DOM 経由でなければ読めない | ✕ リロードで消える | △ 短期用途のみ |
なぜ localStorage が危険なのか? localStorage は同一オリジンの JavaScript なら誰でも読み書きできます。1箇所でも XSS を許してしまうと、トークンが全部抜かれます。
HttpOnly Cookie は何が違うのか? HttpOnly フラグが付いた Cookie は JavaScript から document.cookie でアクセスできません。XSS でスクリプトが動いても、トークン本体は奪えません(ただし「Cookie を勝手に送信させる」CSRF の話は別。次章参照)。
推奨パターン: バックエンドが HttpOnly Cookie を発行、Next.js は自動送信に乗る
Laravel など API サーバー側で Set-Cookie ヘッダーに HttpOnly; Secure; SameSite=Lax を付けて発行し、Next.js からは credentials: "include" で自動的に Cookie を載せます。
const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/user`, {
credentials: "include", // Cookie を自動送信
});
やむを得ず localStorage を使う場合の最低限ルール: リフレッシュトークン(長期セッション用)は絶対に localStorage に置かない。漏れた瞬間に永続的なアカウント乗っ取りになります。
5. ユーザー入力 → DOM の境界 — XSS は React の自動エスケープを「壊さない」
なぜ気をつける必要があるのか
XSS (Cross-Site Scripting) は「ユーザーが入力した文字列が HTML として解釈されてしまう」脆弱性です。<script>alert(1)</script> を入力欄に入れたら本当にアラートが出る、というあれです。
React は JSX 内で {value} の形で挿入される値を自動的にエスケープ してくれます。つまり、何もしなければ XSS は起きにくいフレームワークです。
// ✅ 自動エスケープされるので安全
<p>{userInput}</p>
問題は、React の自動エスケープを わざわざ無効化する API が存在することです。それが dangerouslySetInnerHTML です。名前の通り、危険です。
// ❌ XSS 脆弱性そのもの
<div dangerouslySetInnerHTML={{ __html: userInput }} />
やむを得ず HTML を挿入する場合: DOMPurify でサニタイズ
ブログ記事のリッチテキスト表示など、どうしても HTML として描画したいケースはあります。その場合は 必ずサニタイズライブラリを通します。
import DOMPurify from "isomorphic-dompurify";
const sanitizedHtml = DOMPurify.sanitize(untrustedHtml);
<div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
ポイント: 「自前で <script> をフィルタする正規表現を書く」のは絶対にやめてください。XSS のバイパス手法は何百種類もあり、自前ロジックでは必ず抜けます。
6. 外部サイトからの境界 — CSRF は「自動送信される Cookie」が前提
なぜ気をつける必要があるのか
CSRF (Cross-Site Request Forgery) は「ログインしているユーザーのブラウザに、意図しないリクエストを送らせる」攻撃です。攻撃者のサイトに <img src="https://your-app.com/api/transfer?to=attacker&amount=10000"> のようなタグを仕込んでおき、被害者がそのページを開くだけで、被害者の Cookie が自動付与された状態でリクエストが飛びます。
ポイントは 「自動付与される Cookie」が攻撃の前提 であること。HttpOnly Cookie でトークンを守っていても、CSRF とは別問題です。
Next.js 14+ の Server Actions は自動保護される
Server Actions については、Next.js 14 以降で以下の保護が自動的に有効になっています:
- Same-Site Cookie: デフォルトで Cookie 自体がクロスサイトには送られない
- Origin ヘッダー検証: リクエスト元のオリジンが自サイトと一致するかを検証
したがって、Server Action を使っている限り、CSRF 対策は基本的に意識しなくてよい です(これが Server Actions の大きな利点の1つ)。
Route Handler は手動で Origin 検証が必要
app/api/.../route.ts を自前で書く場合は、Next.js の自動保護は効きません。明示的に Origin ヘッダーを検証します。
import { headers } from "next/headers";
export async function POST(request: Request) {
const headersList = await headers();
const origin = headersList.get("origin");
const host = headersList.get("host");
if (!origin || !origin.includes(host || "")) {
return new Response("Forbidden", { status: 403 });
}
// 処理を続行
}
ポイント: Cookie の SameSite=Lax か Strict を設定するのが最初の防壁です。SameSite=None は外部サイトからの送信を許可してしまうので、明確な理由(クロスドメイン埋め込みなど)がなければ使わないこと。
7. URL パラメータ → リダイレクトの境界 — Open Redirect でフィッシングに悪用される
なぜ気をつける必要があるのか
ログイン後の遷移先を ?returnUrl= のようなクエリパラメータで指定する設計はよくあります。
// ❌ 危険
const returnUrl = searchParams.get("returnUrl");
router.push(returnUrl || "/");
これだと、攻撃者が https://your-app.com/login?returnUrl=https://evil.example.com/fake-login のような URL を作って配布した場合、被害者は「自社ドメインの URL だから安全」と思ってログインし、ログイン後に 外部の偽サイトに飛ばされて再ログインを求められる という被害につながります。これが Open Redirect です。
対策: ホワイトリストで検証
const ALLOWED_HOSTS = ["your-app.com", "www.your-app.com"];
export function isSafeRedirectUrl(url: string): boolean {
try {
const parsed = new URL(url, window.location.origin);
// 相対パスは許可
if (parsed.origin === window.location.origin) return true;
// 許可リストに含まれるホストのみ許可
return ALLOWED_HOSTS.includes(parsed.hostname);
} catch {
return false;
}
}
// 使用側
if (returnUrl && isSafeRedirectUrl(returnUrl)) {
router.push(returnUrl);
} else {
router.push("/");
}
ポイント: returnUrl.startsWith("/") で判定するのは 不十分です。//evil.example.com のような Protocol-relative URL でバイパスされます。必ず new URL() でパースして origin を見ること。
8. HTTP レスポンスの境界 — セキュリティヘッダーは多層防御の要
なぜ気をつける必要があるのか
セキュリティヘッダーは、たとえアプリ側のコードに穴があったとしても 「ブラウザ側で被害を最小化する」最後の防壁 です。各ヘッダーは独立した脅威に対応しているので、複数組み合わせる「多層防御」の発想で設定します。
主要なヘッダーと役割
| ヘッダー | 役割 | これがないと? |
|---|---|---|
Strict-Transport-Security |
HTTPS を強制 | HTTP にダウングレードする中間者攻撃を許す |
X-Frame-Options: DENY |
iframe 埋め込み禁止 | クリックジャッキングを許す |
X-Content-Type-Options: nosniff |
MIME スニッフィング禁止 | ブラウザが勝手に Content-Type を推測して script として実行する |
Referrer-Policy |
Referer の送信制御 | 遷移先に内部 URL を漏らす |
Permissions-Policy |
カメラ / マイク等の機能制限 | 第三者 iframe から勝手に機能利用 |
Content-Security-Policy |
スクリプト等の読み込み元を制限 | XSS で外部スクリプトを実行される |
next.config.ts で一括設定
import type { NextConfig } from "next";
const securityHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
// (self): 自サイトからは許可ダイアログを出せる、第三者 iframe からはブロック
{ key: "Permissions-Policy", value: "camera=(self), microphone=(self), geolocation=(self)" },
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // 本番では unsafe-* を削減
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.example.com",
].join("; "),
},
];
const nextConfig: NextConfig = {
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
},
images: {
remotePatterns: [
{ protocol: "https", hostname: "api.example.com", pathname: "/storage/**" },
],
},
};
export default nextConfig;
注意点
-
images.remotePatternsでワイルドカード**を多用しない。 Next.js Image はリモート画像を自サーバー経由でプロキシ最適化しますが、許可ドメインを広げすぎると SSRF (サーバーから任意 URL に fetch される) のリスクが上がります。必要なドメインだけ明示的に許可しましょう -
CSP の
unsafe-inline/unsafe-evalは本番では削減を目指す。 Next.js の dev では便利さのために必要になりがちですが、本番では nonce / hash 方式で締めると XSS 耐性が大きく上がります
9. エラーの境界 — 内部情報をユーザー画面に出さない
なぜ気をつける必要があるのか
開発中は便利なスタックトレースも、本番でユーザー(=攻撃者の可能性)に見せるのは情報漏洩です。「どのファイルの何行目で」「どの ORM が」「どのカラム名で」失敗したかは、攻撃者にとってはアプリ内部の地図そのものになります。
対策: 開発と本番で出し分け
export function getErrorMessage(error: unknown): string {
if (process.env.NODE_ENV === "development") {
if (error instanceof Error) return error.message;
return String(error);
}
// 本番では汎用メッセージのみ
return "エラーが発生しました。しばらくしてから再度お試しください。";
}
app/error.tsx でも本番では詳細を出さない
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
// 本番では error.message を表示しない。digest は Sentry 等で内部参照する識別子
return (
<div>
<h2>問題が発生しました</h2>
<p>ページの読み込み中にエラーが発生しました。</p>
<button onClick={() => reset()}>もう一度試す</button>
</div>
);
}
API エラーの種類は HTTP ステータスから振る
「どのエラーで何を表示するか」を統一すると、ユーザー向けのメッセージを安全に管理できます。
export function handleApiError(status: number): string {
switch (status) {
case 401: return "ログインが必要です";
case 403: return "アクセス権限がありません";
case 404: return "データが見つかりません";
case 422: return "入力内容に誤りがあります";
default: return "エラーが発生しました";
}
}
ポイント: バックエンドから返ってきたエラーメッセージをそのまま表示するのは避けます。バックエンドの実装によっては SQL エラーが文字列で混じっていたりするので、フロント側でも「想定内のステータスコード」にマッピングし直すのが安全です。
10. 依存パッケージの境界 — 自前コードより脆弱性が多い
なぜ気をつける必要があるのか
現代の Next.js アプリは、依存パッケージのコード量が自前コードの何十倍にもなります。脆弱性の数も依存側のほうが圧倒的に多い です。npm install した瞬間に、知らない作者が書いたコードが本番に乗っているという事実は意識しておく必要があります。
npm audit を PR 単位で回す
# 脆弱性の確認
npm audit
# 自動修正可能なものを修正
npm audit fix
# CI で High 以上は止める
npm audit --audit-level=high
対応の優先度
| 重大度 | 対応 |
|---|---|
| Critical / High | PR マージ前に対応必須。パッチが無ければ代替パッケージ検討 |
| Moderate / Low | 別タスク化して計画的に対応 |
Dependabot / Renovate を入れる
毎週手動で npm audit を回すのは続きません。GitHub なら Dependabot を有効化するだけで PR を自動生成してくれる ので、まずはこれを入れるのが現実的です。
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
ポイント: 「全部自動マージ」にはしないこと。メジャーバージョンアップは破壊的変更が含まれるので、PR で人が見るプロセスは残します。
おまけ: ヘルスチェック API を1本だけ用意しておく
セキュリティそのものとは少しずれますが、運用と地続きなので触れておきます。本番で異変が起きた時に「アプリが生きているか」を機械的に確認できる窓口を1本作っておくと、後々助かります。
// app/api/health/route.ts
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
timestamp: new Date().toISOString(),
});
}
監視ツール、ロードバランサー、Kubernetes の readiness probe など、運用側からの監視窓口になります。バックエンド API の疎通確認まで含める「詳細ヘルスチェック」も後から拡張できます。
まとめ — 「境界」を意識すれば Next.js のセキュリティは整理できる
長くなったので、最初の表をもう一度貼ります。この10個の境界が一通り埋まっていれば、フロントエンドとしてはまず合格点 です。
| # | 境界 | 一言で言うと |
|---|---|---|
| 1 | 環境変数 |
NEXT_PUBLIC_ は公開ファイルへの転写。Zod で起動時検証 |
| 2 | Server → Client Props | HTML に乗るので Zod でフィルタ |
| 3 | Server Action / Route Handler | 認証・認可・バリデーション の3点セット |
| 4 | トークン保存 | HttpOnly Cookie が原則、localStorage は避ける |
| 5 | XSS | React の自動エスケープを壊さない、dangerouslySetInnerHTML 禁止 |
| 6 | CSRF | Server Action は自動保護、Route Handler は手動で Origin 検証 |
| 7 | Open Redirect |
returnUrl はホワイトリストで検証 |
| 8 | セキュリティヘッダー |
next.config.ts で多層防御 |
| 9 | エラー表示 | 本番ではスタックトレース禁止、汎用メッセージのみ |
| 10 | 依存パッケージ |
npm audit + Dependabot |
本記事で扱っていないこと
冒頭で書いた通り、本記事は フロントエンドのコードで完結する対策 に絞りました。実運用ではこれに加えて、
- WAF / CDN レイヤーでのレート制限・Bot 対策
- TLS / 証明書管理
- IP 制限・VPN 経由のアクセス制御
- ログ集約・監視・SIEM
- バックエンド (API サーバー) 側の SQL Injection 対策、ファイルアップロード検査
- インシデント対応プロセス
といったインフラ・運用層の話が必要になります。これらは別記事で扱う予定です。
セキュリティは「全部完璧にしてからリリース」よりも、「境界ごとに何を守っているかを言語化しておく」 ことが事故時の被害を最小化します。本記事の表が、その言語化の出発点になれば幸いです。