Next.jsはApp Routerの登場によってサーバーコンポーネント、Server Actions、ミドルウェアなど、サーバー側で動く処理の比重が大きくなりました。便利になった一方で「どこからどこまでが自分の責任範囲のセキュリティ対策なのか」が見えにくくなっているのも事実です。フレームワークが自動でやってくれる部分と、開発者が明示的に実装しなければならない部分の境界を理解しないまま本番リリースしてしまうと、CSP未設定・CSRF対策漏れ・環境変数の意図しない露出といった初歩的な穴が残ったままサービスが公開されてしまいます。
この記事では、Next.jsアプリケーションを実務で運用する開発者向けに、公開前チェック・実装コード・運用フェーズでの継続的な対策までを一気通貫でまとめました。単発の注意喚起ではなく、明日から手を動かせる「実践」の粒度を意識しています。
なぜNext.jsアプリのセキュリティ対策は後回しにされやすいのか
Next.jsはVercelを中心に多くの便利機能が提供されており、HTTPS化やビルド最適化など「インフラ寄りの安全性」は意識せずとも一定水準が確保されます。しかし、これはアプリケーションレベルの脆弱性(XSS、CSRF、インジェクション、認可漏れ、情報漏洩)まで自動的に防いでくれることを意味しません。特に次の3つの誤解が現場でよく見られます。
- 「Reactは自動でエスケープするから安全」という誤解:JSX内のテキスト展開は自動エスケープされますが、
dangerouslySetInnerHTMLや外部HTMLの挿入、URL属性への値埋め込みは対象外です。 - 「Vercelにデプロイしているからヘッダー周りは万全」という誤解:セキュリティヘッダー(CSP、X-Frame-Optionsなど)は既定で強制されるわけではなく、
next.config.jsで明示的に設定する必要があります。 - 「サーバーコンポーネントは全部サーバーで完結するから安全」という誤解:Server Actionsやルートハンドラーは実質的に公開APIエンドポイントであり、認可チェックを入れなければ誰でも呼び出せてしまいます。
Next.jsアプリで想定すべき主要リスク
1. クロスサイトスクリプティング(XSS)
ユーザー入力やCMSから取得したHTMLをそのまま画面に描画すると、悪意あるスクリプトが実行される可能性があります。特にdangerouslySetInnerHTML、next/linkのhrefへの動的な値の埋め込み、SVGのinlineレンダリングは要注意ポイントです。
2. CSRF(クロスサイトリクエストフォージェリ)
Server ActionsやAPI Routeで状態変更を伴う処理(登録・更新・削除)を実装する際、Cookieベースのセッション認証を使っていると、外部サイトからの偽装リクエストに脆弱になる場合があります。
3. インジェクション(SQL/NoSQL/コマンド)
ORMを使わず生のクエリを組み立てている箇所や、外部コマンドを呼び出す処理にユーザー入力を直接連結していないか確認が必要です。
4. セキュリティヘッダーの未設定
CSP、X-Content-Type-Options、X-Frame-Options、Referrer-Policy、Permissions-Policy、HSTSが未設定だと、クリックジャッキングやMIMEスニッフィング攻撃の対象になります。
5. 環境変数・機密情報の露出
NEXT_PUBLIC_プレフィックスを付けた環境変数はクライアントバンドルに含まれ、ブラウザから閲覧可能になります。APIキーやシークレットを誤ってこのプレフィックス付きで定義すると、そのまま漏洩します。
6. 依存パッケージの脆弱性
Next.js本体やnpmパッケージには継続的にCVEが報告されています。バージョン固定のまま放置すると、既知の脆弱性が残り続けます。
実装対策:コードで押さえるべきポイント
Content Security Policy(CSP)をnext.config.jsで設定する
CSPは外部から読み込めるスクリプトやリソースの出所を制限し、XSS成功時の被害を最小化する最重要のヘッダーです。ミドルウェアでnonceを発行し、インラインスクリプトを許可制にする実装が推奨されます。
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'nonce-{NONCE}' 'strict-dynamic'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
].join('; '),
},
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
];
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
];
},
};
nonceを動的に生成する場合はミドルウェアで実装します。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const response = NextResponse.next();
response.headers.set('x-nonce', nonce);
response.headers.set(
'Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self';`
);
return response;
}
Server ActionsとAPI RouteでのCSRF・認可対策
Server Actionsは内部的にPOSTリクエストとして送信されるため、Originヘッダーの検証や、セッションに紐づくCSRFトークンの検証を組み込むことが重要です。あわせて、呼び出し元のユーザーが本当にその操作を実行できる権限を持っているかを、Actionの先頭で必ず確認します。
// app/actions/updateProfile.ts
'use server';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
export async function updateProfile(formData: FormData) {
const origin = headers().get('origin');
const allowedOrigin = process.env.APP_ORIGIN;
if (origin !== allowedOrigin) {
throw new Error('Invalid request origin');
}
const session = await auth();
if (!session?.user) {
throw new Error('Unauthorized');
}
const name = String(formData.get('name') ?? '').trim();
if (name.length === 0 || name.length > 100) {
throw new Error('Invalid input');
}
// 認可済み・検証済みの値のみDB更新に渡す
await db.user.update({
where: { id: session.user.id },
data: { name },
});
}
入力値のサニタイズとバリデーション
フォーム入力やクエリパラメータは、スキーマベースのバリデーションライブラリ(zodなど)で型と値域を強制し、想定外の値をAPI層に到達させないようにします。
import { z } from 'zod';
const CommentSchema = z.object({
body: z.string().min(1).max(2000),
postId: z.string().uuid(),
});
export async function POST(request: Request) {
const json = await request.json();
const parsed = CommentSchema.safeParse(json);
if (!parsed.success) {
return Response.json({ error: 'Invalid payload' }, { status: 400 });
}
// parsed.data は型・値域ともに保証済み
}
dangerouslySetInnerHTMLを使う場合はサニタイズを必須にする
CMSからのリッチテキストなど、HTMLをそのまま描画せざるを得ないケースでは、DOMPurifyなどでサニタイズしてから挿入します。
import DOMPurify from 'isomorphic-dompurify';
function ArticleBody({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
環境変数の取り扱いを整理する
シークレット類はNEXT_PUBLIC_を付けないサーバー専用変数として定義し、クライアントコンポーネントから直接参照できないことを確認します。ビルド後の.next配下やブラウザの開発者ツールでバンドル内容を確認し、意図しない値が含まれていないかを定期的にチェックする運用も有効です。
運用フェーズで継続すべき実践
- 依存パッケージの定期監査:
npm auditやDependabot、Snyk等をCIに組み込み、Next.js本体を含む依存関係のCVEを継続的に検知する。 - Next.js本体のアップデート追従:セキュリティ修正を含むマイナー・パッチバージョンのリリースノートを確認し、放置期間を作らない。
- レート制限の導入:ログインAPIやフォーム送信系のエンドポイントには、ミドルウェアレベルでのレート制限を設け、総当たり攻撃を緩和する。
- ログと監視:認可エラー・異常系のアクセスパターンをログに残し、異常検知の仕組みと連携させる。
- 本番前のヘッダー確認:デプロイ後に実際のレスポンスヘッダーをcurlやブラウザの開発者ツールで確認し、設定漏れがないかをリリースごとに確認する。
公開前チェックリスト
- CSPを含むセキュリティヘッダーがnext.config.jsまたはミドルウェアで設定されているか
- Server Actions・API RouteでOrigin検証と認可チェックを実装しているか
- すべての外部入力にzod等でスキーマバリデーションをかけているか
- dangerouslySetInnerHTMLの使用箇所にDOMPurify等のサニタイズが入っているか
- シークレットが誤ってNEXT_PUBLIC_プレフィックスになっていないか
- npm audit / Dependabotが有効になっており、既知のCVEが放置されていないか
- ログインやフォーム送信エンドポイントにレート制限があるか
- 本番環境の実レスポンスヘッダーを目視確認したか
まとめ
Next.jsのセキュリティ対策は、フレームワークにお任せでは完結しません。CSPやセキュリティヘッダーの明示的な設定、Server Actions・API Routeでの認可とCSRF対策、入力値のスキーマバリデーション、そして依存関係の継続的な監査という一連の実践を組み合わせて初めて、実運用に耐えるレベルの安全性が確保できます。今回紹介したコード例とチェックリストを起点に、自分たちのプロダクトのどこに穴があるかを棚卸しし、リリースサイクルの中に「セキュリティ実践」を組み込んでいくことをおすすめします。

コメント