本文へスキップ
CodeAuditAgent
すべての記事

Next.js App Router セキュリティチェックリスト

Next.js App Routerの実践的チェックリスト:サーバーアクション、ルートハンドラー、環境変数、CSRF、ヘッダー、Webhook、ユーザー別キャッシュ。

· 8分で読めます · Lina Source LLC

App Routerは多くのコードをブラウザからサーバーへ戻しました。これはおおむねセキュリティ上の前進です。クエリ、シークレット、ビジネスロジックが、ユーザーには読めない場所で動くようになったからです。同時に、何が公開エンドポイントで、何が単に関数呼び出しに見えるだけなのかという線引きを曖昧にもしました。Next.jsアプリの深刻なバグの多くは、この曖昧さから生まれます。

このチェックリストは、App Routerのコードベースで最もよく見かける問題を、確認する価値のある順に並べたものです。特殊なものは1つもありません。どれも、フレームワークの便利さが信頼境界を覆い隠している場所です。

1. サーバーアクションは公開エンドポイント

'use server'が付いた関数はHTTPエンドポイントにコンパイルされます。サイトを読み込める人なら誰でもアクションIDを見つけ、任意の引数で呼び出せます。それを使うボタンをUIが描画するかどうかは関係ありません。管理者以外からフォームを隠しても、その背後のアクションは保護されません。

どのアクションにも、APIハンドラーと同じ3つの手順が必要です。呼び出し元を認証し、入力を検証し、対象となる具体的なレコードについて認可することです。これらのチェックはアクションの本体の中に置いてください。フォームを描画するページコンポーネント内のチェックは、ページの読み込み時に動くだけでアクションの呼び出し時には動かないため、何も守りません。

'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const Input = z.object({
  projectId: z.string().uuid(),
  name: z.string().trim().min(1).max(100),
});

export async function renameProject(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  const { projectId, name } = Input.parse({
    projectId: formData.get('projectId'),
    name: formData.get('name'),
  });

  // 所有者チェックは別の参照ではなく書き込みの一部にする
  const result = await db.project.updateMany({
    where: { id: projectId, ownerId: session.user.id },
    data: { name },
  });
  if (result.count === 0) throw new Error('Not found');

  revalidatePath('/projects');
}

多数のアクションをエクスポートしているヘルパーファイルには注意してください。'use server'モジュールからエクスポートされた関数はすべて呼び出し可能であり、誰かが社内スクリプト用に追加して忘れたものも含まれます。

2. ルートハンドラーにも同じチェックが必要

app/api配下のルートハンドラーはより明らかにエンドポイントですが、失敗の形は同じです。動的セグメントを使ってレコードを読み込み、誰が所有しているかを確認しないのです。最近のNext.jsではparamsはPromiseです。awaitして検証し、クエリを呼び出し元に絞り込んでください。

// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: 'unauthorized' }, { status: 401 });
  }

  const { id } = await params;
  const doc = await db.document.findFirst({
    where: { id, ownerId: session.user.id },
  });
  if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });

  return Response.json(doc, {
    headers: { 'Cache-Control': 'private, no-store' },
  });
}

呼び出し元が所有していないレコードには403ではなく404を返し、どのIDが存在するかをエンドポイントが確認させないようにします。認可の判断にparams、searchParams、ヘッダー、Cookieを信頼してはいけません。いずれも攻撃者が制御できる入力です。

3. ミドルウェアだけでは認証境界にならない

ミドルウェア(Next.js 16ではproxyに名称変更)は、サインアウト済みのユーザーをリダイレクトしたりヘッダーを設定したりするのに役立ちます。しかし、認可が行われる唯一の場所であってはいけません。マッチャーは間違えやすく、新しいルートがその外側に追加されますし、サーバーアクションはページのパスへPOSTするため、想定したパターンに一致しないことがあります。2025年には、CVE-2025-29927により、細工された内部ヘッダーで一部のNext.jsバージョンがミドルウェアを完全にスキップしうることが明らかになりました。

ミドルウェアは利便性の層として扱ってください。本当のチェックはデータの近く、つまり各アクションとハンドラーの中、さらに望ましくはサーバー側のすべての読み取りが通るデータアクセス層に属します。データアクセス層とは、getProjectForUser(projectId) のような関数を公開し、その内部でセッションの確認と所有者による絞り込みを行う、サーバー専用の単一モジュールです。ページ、アクション、ルートハンドラーはデータベースクライアントを直接使う代わりにこれを呼ぶため、新しいルートがチェックを忘れることはありえません。忘れられる未チェックの経路がそもそも存在しないからです。

4. サーバーのコードはサーバーに留める

NEXT_PUBLIC_が付いた環境変数はビルド時にJavaScriptバンドルへ埋め込まれ、すべての訪問者から見えます。公開可能なStripeのキーやアナリティクスのIDには正しい扱いですが、それ以外については漏えいです。コードベースでNEXT_PUBLIC_を検索し、それぞれが広告看板に載っても平気かを確認してください。逆の間違いも起こります。接頭辞のない変数をクライアントコンポーネントで読み、ブラウザでundefinedになり、エラーを消すために誰かがNEXT_PUBLIC_に改名してしまうのです。ブラウザで値が必要になったら、まずそもそもブラウザがそれを持つべきかを問い直しましょう。

// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const getCurrentUser = cache(async () => {
  const session = await auth();
  if (!session?.user) return null;
  // UIが必要とするフィールドだけを返す。行全体は決して返さない
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

server-onlyパッケージは、クライアントコンポーネントがそのモジュールをimportした場合にビルドを失敗させるため、データベースクライアントやシークレットを読むヘルパーがブラウザに混入するのを防ぎます。サーバーコンポーネントがクライアントコンポーネントへpropsとして何を渡すかにも注意してください。その境界を越えて渡されたものはすべてページにシリアライズされるため、ユーザーの行をまるごと渡すと、パスワードハッシュや内部フラグまでブラウザへ送られます。

5. CSRF:フレームワークが守る範囲を知る

サーバーアクションはPOSTしか受け付けず、Next.jsは実行前にOriginヘッダーとホストを比較します。プロキシの背後や複数ドメインでデプロイする場合は、エラーが消えるまで範囲を広げるのではなく、serverActions.allowedOriginsを意図をもって設定してください。

ルートハンドラーにはこうした保護はありません。POST、PUT、DELETEのハンドラーがCookieで認証しているなら、他サイトのフォームから送信できてしまいます。コンテンツタイプの確認だけでは不十分です。HTMLのフォームはプリフライトなしでurlencoded、multipart、text/plainのボディを送れますし、ボディを緩くパースするハンドラーはそれらを受け入れてしまいます。セッションCookieにはSameSite=LaxかStrictを設定し、GETで状態を変更せず、Cookie認証の更新処理ではOriginヘッダーを確認してください。AuthorizationヘッダーのBearerトークンだけを受け付けるハンドラーは、古典的なCSRFの影響を受けません。

6. セキュリティヘッダーとCSP

Next.jsは既定ではセキュリティヘッダーをほとんど送りません。next.configのheaders()関数で追加するか、厳格なContent Security Policyのためにリクエストごとのnonceが必要ならミドルウェアで追加してください。

  • Content-Security-Policy。できればnonceベースにし、object-src 'none' とbase-uri 'self' を含めます。
  • Strict-Transport-Security。どこでもHTTPSが安定してから、長いmax-ageで設定します。
  • X-Content-Type-Options: nosniff。
  • Referrer-Policy: strict-origin-when-cross-origin。
  • クリックジャッキングを防ぐため、CSPのframe-ancestorsまたはX-Frame-Options。
  • X-Powered-Byヘッダーを取り除くため、next.configでpoweredByHeader: false。

7. レート制限

レート制限の機能は組み込まれていません。サインイン、サインアップ、パスワードリセット、ワンタイムパスワードの確認、そして呼び出しごとに費用がかかるもの(メール、SMS、AIへのリクエスト)には、ユーザーごととIPごとの制限が必要です。メモリ上のカウンターはサーバーレスや複数インスタンスのデプロイでは機能しないため、Redisやデータベースなどの共有ストアを使ってください。制限はミドルウェアだけでなく、認証済みユーザーが判明しているアクションやハンドラーの中で適用します。IPに加えて、攻撃者が安価に変えられないものを制限のキーにしましょう。認証済みルートではアカウント、リセットやワンタイムパスワードのフローでは対象のメールアドレスや電話番号です。正当なクライアントが適切に待てるよう、Retry-Afterヘッダー付きで429を返してください。

8. Webhookは生のボディに対して検証する

Webhookの署名は、プロバイダーが送信したバイト列そのものに対して計算されます。ボディをJSONとしてパースして再シリアライズするとそのバイト列が変わって検証が失敗するため、検証を省きたくなってしまいます。ルートハンドラーではreq.text()でボディを読み、他のことをする前に検証してください。

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // ハンドラーは冪等でなければならない:プロバイダーは再送し、二重配信もありうる
  await handleStripeEvent(event);
  return new Response('ok');
}

処理済みイベントのIDを一意制約付きで保存し、再送や再生された配信でサブスクリプションを二重に付与しないようにしてください。また、プロバイダーは配信順序を保証しないため、順序が入れ替わって届くイベントにも対応しましょう。

9. ユーザー別データをグローバルにキャッシュしない

App Routerは積極的にキャッシュし、その既定値はバージョンによって変わってきました。unstable_cacheや'use cache'で包まれた関数が「現在のユーザー」のデータを返すのにキャッシュキーにユーザーIDを含めていなければ、あるユーザーのデータが次のユーザーに配信されます。同じことは、動的であるべきなのに静的にレンダリングされたページや、APIレスポンスのCDNキャッシュにも当てはまります。直接テストしましょう。2つのブラウザで別々のユーザーとしてサインインし、同じページを読み込みます。1人目のデータが2人目に表示されるなら、それはキャッシュのバグであり、たいていは深刻なものです。

  • ユーザーIDやテナントIDをキャッシュ対象の関数に明示的に渡し、キーの一部になるようにします。
  • キャッシュ対象の関数の中でCookieやヘッダーを読まないでください。外側で読み、値を渡します。
  • ユーザー別データを含むレスポンスにはCache-Control: private, no-store を付けます。
  • ビルド出力を確認します。動的であるはずのルートが静的として一覧に出ていてはいけません。

チェックリストの回し方

ファイル単位ではなくルート単位で見ていきましょう。アクションとハンドラーのそれぞれについて、誰が呼び出せるか、どの入力を信頼しているか、何をキャッシュするか、何を返すかを確認します。CodeAuditAgentは公開GitHubリポジトリや貼り付けられたスニペットに対して最初の一巡を実行し、各指摘事項を深刻度、CWE、該当行の引用、修正パッチ案とともに報告します。そもそもどのルートを公開すべきかといった設計上の問いには、依然としてチームの判断が必要です。