Next.jsでAPI RoutesやRoute Handlers(App Routerのroute.ts)を使ってバックエンド機能を実装するプロジェクトは年々増えている。フロントエンドとバックエンドを1つのリポジトリで完結できる利便性は大きいが、その一方で「サーバーサイドの防御」を意識しないまま本番公開してしまうケースも少なくない。実際に、公開直後のAPIエンドポイントが短期間で数百万〜数千万回のリクエストを受け、想定外のクラウド利用料が発生したという事例も報告されている。本記事では、Next.jsのAPI Route/Route Handlerを対象に、認証・認可、レート制限、CORS設定、入力検証、エラーハンドリング、シークレット管理という6つの観点から、実務で使えるセキュリティ対策を体系的に整理する。
Next.jsのAPI RouteがWebアプリのセキュリティ上の弱点になりやすい理由
Next.jsのAPI Routes(Pages Router)やRoute Handlers(App Router)は、フロントエンドと同じプロジェクト内に置けるため、フォーム送信やSaaS連携、決済処理などの重要な処理をつい「片手間」で実装してしまいがちだ。しかしURLが公開された時点で、そのエンドポイントは世界中の誰からでもリクエストを受け取れる状態になる。フロントエンドのReactコンポーネントであれば「見た目が崩れる」程度で済むミスも、API Routeでは「認証を回避されてデータを抜き取られる」「大量リクエストで課金が跳ね上がる」といった実害に直結する。
フロントエンドとバックエンドが同居することで生まれる油断
App RouterのRoute Handlerはapp/api/xxx/route.tsに関数をエクスポートするだけで動作するため、Express等でミドルウェアを積み上げて構築していた頃に比べて「認証・検証・制限を明示的に書く」という意識が薄れやすい。Next.js自体は便利な抽象化を提供するが、CORSやレート制限、入力検証を自動でやってくれるわけではない。これらはすべて開発者が明示的に実装する必要がある。
認証・認可の設計:誰にAPIを叩かせるかを明確にする
API Routeのセキュリティで最初に固めるべきは「誰がこのエンドポイントを叩けるのか」という認証・認可の設計である。
JWTによるステートレス認証の基本構成
Next.jsのEdge Runtimeでも動作するjoseのようなライブラリを使い、有効期限の短いAccess Token(目安として15〜30分程度)と、再発行用の長期Refresh Tokenを分ける構成が広く使われている。Access Tokenの寿命を短くすることで、万一トークンが漏えいしても被害の時間的範囲を限定できる。
// lib/jwt.ts
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function signAccessToken(userId: string) {
return await new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret);
return payload;
}
トークンの保存場所はHttpOnly Cookieを基本にする
発行したトークンをlocalStorageやReactのstateに保存すると、XSSが発生した際にJavaScriptから読み取られてしまう。httpOnly・secure・sameSite属性を付けたCookieに格納することで、クライアント側のスクリプトからのアクセスを防ぎ、XSSの被害範囲を狭められる。認証まわりを自前で作り込む余力がない場合は、NextAuth.js(Auth.js)のようなライブラリを使い、セッション管理やCSRF対策を任せるのも有効な選択肢である。
Middlewareでの一括保護と、Route Handler内での二重チェック
Next.jsのmiddleware.tsを使えば、特定パス配下のAPIをまとめて認証必須にできる。ただしMiddlewareだけに依存せず、Route Handler内でも認可(そのユーザーがそのリソースを操作できるか)を再確認する「多層防御」が重要だ。Middlewareの設定ミスや将来的なルーティング変更によって保護が漏れるリスクを、Handler側のチェックで吸収できる。
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifyAccessToken } from "@/lib/jwt";
export async function middleware(req: NextRequest) {
const token = req.cookies.get("access_token")?.value;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const payload = await verifyAccessToken(token);
const res = NextResponse.next();
res.headers.set("x-user-id", payload.sub as string);
return res;
} catch {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
}
export const config = { matcher: ["/api/private/:path*"] };
レート制限:無防備なAPIは「無限に叩ける課金装置」になる
認証があっても、あるいは認証不要な公開APIであっても、1つのIPやアカウントから短時間に大量のリクエストを送られると、サーバーコストの急増やDB負荷、ブルートフォース攻撃の温床になる。Next.jsのAPI Routeはデフォルトではリクエスト数を制限しないため、レート制限は必ず自前で組み込む必要がある。
Serverless環境ではRedisベースのレート制限が現実的
Vercelなどのサーバーレス環境では、関数インスタンスがリクエストごとに使い捨てられるため、メモリ内カウンタでは制限がすり抜けられてしまう。@upstash/ratelimitとRedisを組み合わせ、スライディングウィンドウ方式でIPアドレスやユーザーIDごとにカウントするのが現実的な実装だ。
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
export const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, "60 s"),
});
// app/api/example/route.ts
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "unknown";
const { success, reset } = await ratelimit.limit(ip);
if (!success) {
return Response.json(
{ error: "Too many requests" },
{ status: 429, headers: { "Retry-After": String(reset) } }
);
}
// 本処理
}
制限値はエンドポイントの性質ごとに変える
ログインやパスワード再設定のような「悪用時の影響が大きい」エンドポイントは厳しめの制限を、一般的な読み取りAPIは緩めの制限を設定するなど、一律の数値ではなくリスクに応じて調整するとよい。制限に達した際は429ステータスとRetry-Afterヘッダーを返し、クライアント側が適切に再試行できるようにする。
CORS設定ミスは「意図しない第三者からのAPI呼び出し」を許してしまう
異なるオリジン(ドメイン)のWebページからAPIを呼び出す際、ブラウザは事前にPreflightリクエスト(OPTIONS)を送り、サーバー側の許可を確認する。この仕組みがCORSであり、Next.jsのAPI Routeでも明示的に設定しなければ、意図しないオリジンからの呼び出しを許してしまったり、逆に正当なフロントエンドからのリクエストがブロックされてしまったりする。
許可オリジンは必ず個別に列挙する
認証情報を扱うAPIでAccess-Control-Allow-Origin: *を設定してはならない。Cookieなどの認証情報を伴うリクエストではワイルドカードは仕様上使えず、たとえ使えたとしても任意のドメインからの呼び出しを許可することになり、CSRFに近いリスクを生む。許可するオリジンをホワイトリストとして明示的に管理するべきだ。
// app/api/data/route.ts
const ALLOWED_ORIGINS = [
"https://okojo.co.jp",
"https://app.okojo.co.jp",
];
function corsHeaders(origin: string | null) {
const allow = origin && ALLOWED_ORIGINS.includes(origin) ? origin : "";
return {
"Access-Control-Allow-Origin": allow,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
export async function OPTIONS(req: Request) {
const origin = req.headers.get("origin");
return new Response(null, { status: 204, headers: corsHeaders(origin) });
}
export async function GET(req: Request) {
const origin = req.headers.get("origin");
const data = { message: "ok" };
return Response.json(data, { headers: corsHeaders(origin) });
}
next.config.jsでの一括設定とRoute Handler個別設定の使い分け
プロジェクト全体で一律のCORSポリシーを敷きたい場合はnext.config.jsのheaders設定でまとめて指定できる。一方、エンドポイントごとに許可オリジンや許可メソッドを変えたい場合は、上記のようにRoute Handler内で個別に判定するほうが柔軟性が高い。混在させる場合は、どちらの設定が優先されるかをNext.jsのバージョンごとに確認しておくこと。
入力検証:クライアントの言い分をそのまま信用しない
フロントエンド側でフォームバリデーションを実装していても、API Routeを直接叩けば検証をすり抜けたリクエストを送信できてしまう。サーバー側での入力検証は「あれば安心」ではなく「なければ即インジェクションや不正データの温床になる」必須要素である。
Zodによるスキーマベースの検証
TypeScriptと相性のよいzodを使うと、型定義と実行時の検証ロジックを1つのスキーマにまとめられる。メールアドレスやURL、日付といった形式チェックに加え、文字列長・配列長の上限、必須項目の有無まで宣言的に表現できる。
// app/api/users/route.ts
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
bio: z.string().max(500).optional(),
});
export async function POST(req: Request) {
const body = await req.json();
const parsed = CreateUserSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { name, email, bio } = parsed.data;
// 検証済みデータのみを使って以降の処理を行う
}
データベースアクセスはORMまたはパラメータ化クエリで
入力検証を通過した値であっても、SQLを文字列連結で組み立てるとSQLインジェクションのリスクが残る。PrismaやDrizzleのようなORM、またはパラメータ化クエリを一貫して使い、ユーザー入力を直接SQL文字列へ埋め込まない設計を徹底する。
エラーハンドリングとシークレット管理:情報を漏らさない作り方
エラーメッセージはスタックトレースや内部構造を含めない
開発環境ではデバッグのために詳細なエラーメッセージを表示したくなるが、本番環境でスタックトレースやDBのテーブル構造、内部パスをそのままレスポンスに含めると、攻撃者に有用な情報を与えてしまう。ユーザー向けには汎用的なメッセージを返し、詳細はサーバー側のログにのみ記録する。
export async function POST(req: Request) {
try {
// 処理
} catch (err) {
console.error("[api/orders] failed:", err); // サーバーログにのみ詳細を残す
return Response.json(
{ error: "internal_error" },
{ status: 500 }
);
}
}
環境変数の扱い:NEXT_PUBLIC_プレフィックスの罠
Next.jsではNEXT_PUBLIC_を付けた環境変数がビルド時にクライアントバンドルへ埋め込まれ、ブラウザから閲覧可能になる。APIキーやDB接続文字列、署名用シークレットに誤ってこのプレフィックスを付けてしまうと、フロントエンドの配信物を調べるだけで漏えいする。サーバー側でしか使わない値には決してNEXT_PUBLIC_を付けないことを、命名規則として徹底する必要がある。
CSRF対策とContent Security Policy
Cookieベースの認証を使う場合、状態を変更するAPI(POST/PUT/DELETEなど)にはCSRF対策も欠かせない。NextAuth.jsを利用していれば標準でCSRFトークンが扱われるが、独自実装の場合はsameSite=strictまたはlaxのCookie設定に加え、Originヘッダーの検証やCSRFトークンの発行・検証を自前で行う必要がある。あわせて、XSS対策の多層防御としてContent Security Policy(CSP)ヘッダーを設定し、許可されていないスクリプトの実行を制限しておくと安心だ。
依存ライブラリの定期的な棚卸し
Next.js本体やjose、zod、認証ライブラリなど、API Routeを支える依存関係に脆弱性が見つかることは珍しくない。npm auditやDependabotのようなツールを使い、最低でも月1回は依存関係の脆弱性情報を確認し、重大な指摘には速やかに対応する体制を作っておく。
Next.js API Routeセキュリティ チェックリスト
- 認証が必要なエンドポイントで、Middlewareとハンドラ内の二重チェックを行っているか
- Access Tokenの有効期限を30分以下に設定しているか
- トークンをHttpOnly・Secure・SameSite付きCookieに保存しているか
- すべての公開APIにレート制限(IPまたはユーザー単位)を設定しているか
- 制限超過時に429と
Retry-Afterヘッダーを返しているか - CORSの許可オリジンをホワイトリストで明示し、認証系APIで
*を使っていないか - OPTIONSプリフライトを正しく処理しているか
- すべてのユーザー入力をサーバー側でスキーマ検証(Zod等)しているか
- DBアクセスがORMまたはパラメータ化クエリになっているか
- 本番環境のエラーレスポンスにスタックトレースや内部情報を含めていないか
- 秘密情報を
NEXT_PUBLIC_環境変数に入れていないか - 状態変更APIにCSRF対策を実装しているか
- CSPヘッダーを設定しているか
- 依存ライブラリの脆弱性を月次で確認しているか
まとめ
Next.jsのAPI RoutesやRoute Handlersは、フロントエンドと同じプロジェクトで手軽にバックエンド機能を実装できる強力な仕組みだが、その手軽さゆえに「認証・認可」「レート制限」「CORS」「入力検証」「エラーハンドリング」「シークレット管理」といった基本的な防御を後回しにしてしまいがちだ。これらはいずれもNext.jsが自動でやってくれるものではなく、開発者が明示的に実装する必要がある領域である。まずは本記事のチェックリストを使って自社のAPIエンドポイントを棚卸しし、リスクの高い項目から優先的に対策を進めることをおすすめする。セキュリティ対策は一度実装して終わりではなく、依存ライブラリの更新やアクセスログの監視を含めた継続的な運用があってはじめて機能する。

コメント