Next.jsでアプリケーションを作り始めるとき、多くの開発者が一度は同じ疑問にぶつかります。「Cookieを見てログイン中かどうかを判定しているが、これで本当に安全なのか」「Server ActionsやServer Componentsは、従来のAPIサーバーと何が違うのか」。実際、Reddit(r/nextjs)に投稿されたある質問でも、投稿者は「CookieにJWTを保存し、その有無でログイン状態を判定している。これは信頼できるやり方か」と率直に疑問を投げかけています。寄せられた返信は「Supabaseなど多くのライブラリも同じ方式を採っており一般的ではあるが、唯一の正解と言えるかは分からない」というものと、Next.js公式ブログ「How to Think About Security in Next.js」へのリンクの2件のみでした。つまり実務者の間でも、Next.jsのセキュリティ設計は「なんとなく動いているが、体系的に理解できていない」状態になりやすいテーマだと言えます。
本記事では、この公式ブログの内容を軸に、App Router時代のNext.jsで最低限押さえておくべきセキュリティの基本対策を、仕組みの理解から具体的なコード例、運用チェックリストまで整理して解説します。
なぜNext.jsのセキュリティは「勘」で語られがちなのか
Pages Routerの時代、Next.jsのセキュリティモデルは比較的シンプルでした。getServerSidePropsはサーバーで完結し、APIルートはREST APIとして単独で守ればよく、クライアントとサーバーの境界も明確でした。ところがApp RouterのReact Server Components(RSC)とServer Actionsは、この境界を意図的に曖昧にします。同じファイルの中にサーバーでしか実行されないコードとクライアントに送られるコードが混在し、開発者が明示的に「これはサーバー専用」「これはクライアントに公開してよい」と指定しない限り、うっかり機密データを混入させてしまうリスクが生まれます。
Next.js自身の公式見解でも、RSCは新しいパラダイムであるがゆえに、既存のセキュリティ運用や監査手順がそのまま通用しないケースがあると明言されています。つまり「Reactが安全だからNext.jsも自動的に安全」という理解は誤りで、意識的に設計方針を決める必要があります。
まず決めるべきこと:データアクセスの方式を統一する
Next.js公式は、Server Componentsからデータを扱う方式として大きく3つのパターンを挙げ、プロジェクト内で1つの方式に統一することを推奨しています。方式が混在すると、どこにアクセス制御のチェックがあるのか開発者にも監査者にも分かりにくくなるためです。
1. HTTP API方式
既存の大規模プロジェクトに向く方式です。Server Componentsからのデータ取得も、クライアントから呼び出す場合と同様にfetch()で自前のREST/GraphQL APIを叩きます。「サーバー内部からの呼び出しだから信頼してよい」という前提を置かないゼロトラストな設計になり、既存のバックエンドチームのセキュリティノウハウをそのまま活かせます。
2. Data Access Layer(DAL)方式
新規プロジェクトに推奨される方式です。データベースやAPIへのアクセスをすべて一つの内部ライブラリに集約し、呼び出し側は必ず「現在のユーザーがこのデータを見てよいか」をチェックしてから結果を返します。監査の対象をこの層に絞り込めるため、UIコードを高速に変更してもセキュリティレビューの負担が増えにくいという利点があります。
// data/auth.ts
import { cache } from 'react';
import { cookies } from 'next/headers';
export const getCurrentUser = cache(async () => {
const token = cookies().get('AUTH_TOKEN');
const decoded = await decryptAndValidate(token);
// 機密情報をpublicなフィールドに含めない
return new User(decoded.id);
});
// data/user-dto.ts
import 'server-only';
import { getCurrentUser } from './auth';
function canSeePhoneNumber(viewer, team) {
return viewer.isAdmin || team === viewer.team;
}
export async function getProfileDTO(slug) {
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
const userData = rows[0];
const currentUser = await getCurrentUser();
// 必要なフィールドだけを返す(Data Transfer Object)
return {
username: userData.username,
phonenumber: canSeePhoneNumber(currentUser, userData.team)
? userData.phonenumber
: null,
};
}
ポイントは、SQLをパラメータ化して呼び出す(プレースホルダを使う)ことでSQLインジェクションを避けること、そして呼び出し元には「クライアントに渡しても安全な形」に整形済みのオブジェクト(DTO)だけを返すことです。
3. コンポーネントレベル直書き方式
プロトタイピングや小規模チーム向けの方式で、Server Componentsの中に直接DBクエリを書きます。素早く動くものが作れる反面、"use client"と書かれたファイルに渡すpropsの型が広すぎないか(ユーザーオブジェクト丸ごと、token、電話番号などを不用意に渡していないか)を常に見張る必要があります。クライアントコンポーネントには「表示に必要な最小限のデータ」だけを渡すのが原則です。
サーバー専用コードを機械的に守る仕組み
server-only パッケージ
ファイル冒頭にimport 'server-only'と書いておくと、そのモジュールをクライアントコンポーネントがimportしようとした時点でビルドエラーになります。DBクライアントやAPIキーを扱うコードには必ず付けておくべき、事故防止の第一防波堤です。
環境変数の公開範囲
Next.jsでは環境変数はデフォルトでサーバーのみアクセス可能ですが、NEXT_PUBLIC_を先頭に付けた変数はビルド時にクライアントバンドルへ埋め込まれます。APIキーやDB接続文字列に誤ってNEXT_PUBLIC_を付けてしまうと、ブラウザの開発者ツールから丸見えになるため、命名規則のレビューを徹底してください。
実験的なTaint API
Reactのexperimental_taintObjectReferenceやexperimental_taintUniqueValueを使うと、「このオブジェクト(あるいはトークン文字列)はクライアントに渡してはいけない」という印を付けられます。ただし、これはオブジェクトからフィールドを取り出して個別に渡すケース(分割代入して渡す等)までは防げない補助的な仕組みです。根本的な対策はあくまでDALでデータを絞り込むことで、Taint APIは「うっかりミス」を検知する保険と捉えるべきです。
読み取り処理での注意点:searchParamsとcookiesは毎回検証する
App Routerのページには、URLのsearchParamsや動的パラメータ、ヘッダーが入力として渡されますが、これらはすべてクライアント側から自由に書き換えられる値です。例えば?isAdmin=trueのようなクエリパラメータを信用して権限判定に使うのは典型的な脆弱性です。同様に、URLが/[team]/の形をしていても、そのチームへのアクセス権があるとは限りません。データを読み取るたびに、その場で認可チェックとCookieの検証をやり直す必要があります。「一度チェックした結果をpropsやparamsとしてバケツリレーする」設計は、途中のコンポーネントでチェック漏れが起きやすいため避けるべきです。
また、Server Componentのレンダリング中に副作用(DBへの書き込みやCookieの発行)を行うことはできない仕様になっています。これは設計上の制約であると同時に、GETリクエストで状態を変更してしまうことによるCSRFの温床を構造的に防いでいるとも言えます。状態変更は必ずServer Actionsで行いましょう。
書き込み処理の要:Server Actionsの正しい守り方
Server Actionsは"use server"を付けた関数を、クライアントから直接呼び出せるエンドポイントとして公開する仕組みです。ここで見落としやすいのが、TypeScriptの型注釈はあくまで開発時の補助であり、実行時には強制されないという点です。悪意あるクライアントは型を無視して任意の引数を送り込めるため、Server Actionsの内部では必ず引数の型・値を検証し、さらに現在のユーザーがその操作を行う権限を持っているかを確認する必要があります。
// actions.ts
"use server";
export async function deletePost(id: number) {
if (typeof id !== 'number') {
throw new Error('invalid id');
}
const user = await getCurrentUser();
if (!canDeletePost(user, id)) {
throw new Error('forbidden');
}
await deleteFromDb(id);
}
手動でのif文チェックでも構いませんが、引数が増えるほどzodのようなスキーマバリデーションライブラリを使う方が漏れが少なくなります。
クロージャに機密情報を持たせない
Server Actionsはクロージャとしてレンダリング時点のデータを閉じ込めて、クライアントへ送り返し、実行時にまた戻ってくるという仕組みも持っています。Next.jsはビルドごとに生成される鍵でこのクロージャの中身を暗号化し、改ざんも検知しますが、.bind()で引数を渡した場合はこの暗号化の対象外になる点に注意が必要です。.bind()で渡した引数は平文でクライアントとやり取りされるため、機密情報を含めるべきではなく、Server Actionの内部では「渡された引数は常に信用できない外部入力」として再検証する姿勢が欠かせません。
CSRF対策とHTMLサニタイズ
Server Actionsは常にPOSTメソッドのみで呼び出される実装になっており、これ自体がSameSite Cookieのデフォルト挙動と組み合わさって多くのCSRF攻撃を防いでいます。さらにNext.jsは呼び出し時にOriginヘッダーとHostヘッダー(またはX-Forwarded-Host)を比較し、一致しない場合はリクエストを拒否します。ただし、これはCSRFトークンによる保護ではないため、XSSを許してしまうとこの防御を迂回されるリスクが残ります。ユーザー入力を表示する箇所のHTMLサニタイズは、Server Actionsを使っていても省略できない対策です。
一方、独自のroute.tsx(Route Handlers)でGET/POSTを実装する場合は、この自動的なCSRF対策の恩恵を受けられないため、従来型のAPIと同じ水準で個別に対策する必要があります。
Cookie+JWTによるログイン判定は「危険」なのか
冒頭のReddit投稿者が悩んでいた「Cookieの有無でログイン判定する」方式そのものは、Supabaseをはじめ多くの認証ライブラリが採用している一般的な手法であり、それ自体が誤りというわけではありません。重要なのは実装の中身です。次のような要件を満たしていなければ、方式の名前に関わらず脆弱になります。
- JWTの署名を毎回サーバー側で検証しているか(クライアントが送ってきた値をデコードするだけで信用していないか)
- Cookieに
HttpOnly・Secure・SameSite属性を付与し、JavaScriptからの読み取りやクロスサイトでの送信を防いでいるか - 「Cookieが存在する」ことと「ログインしている」ことを同一視せず、有効期限切れや失効済みトークンを都度検証しているか
- ログイン状態の判定結果を、ページ単位ではなくデータ取得のたびに(DALの入口で)再チェックしているか
つまり「Cookie+JWT方式か否か」よりも「検証と認可チェックをどこで、何度行っているか」の方が本質的な論点です。ミドルウェアだけでログイン判定を済ませて安心してしまう設計は、次章の理由からも避けるべきです。
ミドルウェアだけに認証を任せてはいけない理由
Next.jsのMiddlewareは特定のページへのアクセスを制限するのに便利ですが、公式ドキュメントも「低レベルのエスケープハッチ」と位置づけており、フレームワークが自動的に提供する保護の外側にあるコードだと明言しています。Middlewareで許可リスト(許可するパスだけを列挙する方式)ではなく拒否リスト(禁止するパスだけを列挙する方式)を使うと、リライトやクライアントナビゲーションなど想定していない経路からのアクセスを見落としやすくなります。
また、あるページへのアクセスをMiddlewareが許可している場合、そのページに紐づくServer Actionsも同様にアクセス可能になってしまう点も見落とされがちです。Server Actionsへのアクセスだけを制限したい場合は、該当ページでPOSTメソッドを個別に禁止するといった対応が必要です。
実際に、2025年にはNext.jsのMiddlewareにおける認可バイパスの脆弱性が公表され、特定のヘッダーを細工することでMiddlewareによるアクセス制御をすり抜けられる問題が報告されました。この種の問題が繰り返し起こる背景には、「Middlewareさえ通せば安全」という設計思想そのものに無理があるという事情があります。だからこそNext.js公式は、認可チェックの最終防衛ラインはMiddlewareではなくデータアクセス層(DAL)に置くべきだと繰り返し強調しています。Middlewareは「入口でのふるい分け」、DALは「データを渡す直前の最終確認」という役割分担で捉え、Next.jsやNext.js関連ライブラリのアップデート情報は定期的に確認し、認可バイパス系の修正が出た場合は速やかに追随することが基本対策の一つになります。
エラーハンドリングと本番モードの重要性
サーバーで発生した例外は、最終的にクライアントのUIへ届けられます。この時、エラーメッセージやスタックトレースに機密情報(クレジットカード番号や内部パスなど)が含まれてしまうと、そのまま情報漏洩につながります。Next.js(React)は本番モードにおいて、詳細なエラー内容をクライアントへ送らず、代わりにログと突き合わせ可能なハッシュ値に置き換える仕組みを持っています。一方で開発モードでは、デバッグのためにエラーの詳細がそのままクライアントに送られます。したがって、本番環境を開発モードで運用してしまうと、この保護がまるごと無効化されることになります。「本番では必ずproductionビルド・productionモードで動かす」というごく基本的な運用ルールが、実は重要なセキュリティ境界線でもあると理解しておく必要があります。
合わせて実施したい一般的な基本対策
App Router特有の論点に加えて、Webアプリケーション全般に共通する以下の基本対策もNext.jsアプリで省略すべきではありません。
セキュリティヘッダーの設定
next.config.jsのheaders()を使い、Content-Security-Policy、X-Frame-Options、Strict-Transport-Security、X-Content-Type-Optionsといった基本的なレスポンスヘッダーを付与します。特にCSPは、Server Actionsの防御が及ばないXSS由来の攻撃を抑える最後の砦として機能します。
依存パッケージの継続的な更新
Next.js本体および依存ライブラリは、脆弱性修正を含むアップデートが定期的にリリースされます。npm auditやDependabotのような仕組みを使い、放置されたバージョンのまま運用しないことが、実際に公表されている脆弱性の多くから身を守る最も費用対効果の高い対策です。
入力バリデーションの徹底
フォーム、Server Actionsの引数、Route Handlersのリクエストボディなど、外部から渡されるあらゆる値は「信用できない入力」として扱い、zodなどのスキーマバリデーションで型・範囲・形式をチェックします。
データベースアクセスのパラメータ化
SQLを文字列連結で組み立てず、必ずプレースホルダ(パラメータ化クエリ)を使うORMやクエリビルダを利用し、SQLインジェクションの入り込む余地をなくします。
Next.jsセキュリティ基本対策チェックリスト
- データアクセスの方式(HTTP API / DAL / コンポーネント直書き)をプロジェクトで1つに統一しているか
- DBクライアントやAPIキーを扱うモジュールに
import 'server-only'を付けているか - 環境変数のうち
NEXT_PUBLIC_接頭辞が付いているものが、本当にクライアントへ公開してよい値だけか - クライアントコンポーネントへ渡すpropsが、表示に必要な最小限のデータに絞られているか
- searchParams・動的パラメータ・Cookieを、読み取るたびにその場で再検証しているか
- Server Actionsの引数を型・値ともに検証し、実行前に呼び出しユーザーの権限を確認しているか
.bind()で渡す引数に機密情報を含めていないか- ユーザー入力を表示する箇所のHTMLサニタイズ(XSS対策)ができているか
- 認可チェックの最終防衛ラインをMiddlewareではなくDALに置いているか
- 本番環境が必ずproductionモードでビルド・起動されているか
- Content-Security-Policyなど基本的なセキュリティヘッダーを設定しているか
- Next.js本体と主要ライブラリを継続的に最新化し、脆弱性情報を追っているか
まとめ
Next.jsのApp Router・React Server Components・Server Actionsは、開発体験を大きく向上させる一方で、「サーバーとクライアントの境界をどこで引くか」を開発者自身が意識的に設計しなければならないという新しい責任も生んでいます。Reddit上の素朴な疑問――Cookie+JWTでのログイン判定は安全かという問いに対する本当の答えは、「方式そのものの是非」ではなく「検証と認可チェックを、データに近い場所で・読み取るたびに・確実に行っているか」という運用の中身にあります。データアクセス層の一本化、server-onlyによる機械的な防御、Server Actionsでの引数検証と権限チェック、Middlewareへの過信を避ける設計、そして本番モードでの運用とセキュリティヘッダーやバリデーションといった基本を積み重ねることが、結局のところ最も確実なNext.jsセキュリティの基本対策になります。まずは本記事のチェックリストを自分のプロジェクトに当てはめ、抜けている項目から着手することをおすすめします。

コメント