言語を切り替える
テーマを切り替える

Next.js API 認証とセキュリティ:JWT からレート制限まで完全実践ガイド

スマートフォンが震えました。クラウドプロバイダーからの請求通知——7,800 ドルです。

目をこすり、見間違いだと思いました。先月の請求は 120 ドルだけ。詳細を開くと、API 呼び出し回数は 1,800 万回。普段は 1 日数百回の個人プロジェクトです。

原因は、対策ゼロの API エンドポイントをクローラーに見つけられ、丸 3 日間叩き続けられたことでした。このとき初めて、API セキュリティは「あればいい」ではないと身をもって知りました。

本記事では、ここ数年で踏んだ地雷と検証した対策を体系的にまとめます。JWT 認証から CORS 設定、レート制限、入力検証まで——理論だけでなく、プロジェクトにそのまま使える実践コードを交えて解説します。

なぜ API セキュリティが重要なのか

API セキュリティに対する一般的な脅威

10.0
CVSS スコア
2025 年 12 月の React Server Components 重大脆弱性 (CVE-2025-55182)。攻撃者が任意コードを実行可能

昨年 12 月、React 公式から重大なセキュリティ勧告が出ました。CVE-2025-55182、CVSS スコアは満点の 10.0 です。

これは何を意味するか。満点です。攻撃者が特殊な HTTP リクエストを送るだけで、サーバー上で任意のコードを実行できます。React Server Components を使っていて更新を怠っていれば、実質的に丸裸の状態です。

これだけではありません。今年 3 月には認可バイパス脆弱性 (CVE-2025-29927) も発見され、スコア 9.1。リクエストヘッダーを偽装するだけでミドルウェアの認証を回避できます。認証を設定したつもりでも、スキップされてしまうのです。

日常的な脅威も無視できません。

悪質なクローラーと DDoS 攻撃。レート制限のない API は一瞬でダウンします。ログイン API が 1 秒間に 3,000 回叩かれ、ブルートフォースでサーバーが落ちた事例も見ました。

データ漏洩。権限管理が不十分で、ユーザー A がユーザー B の注文情報を見れてしまう。ニュースになれば、ブランドの評判は地に落ちます。

インジェクション攻撃。SQL インジェクション、XSS、コマンドインジェクション……古臭く聞こえても、今も多数のプロジェクトが被害に遭います。「React が自動エスケープしてくれる」と思っていても、API 側で検証していなければ意味がありません。

Next.js API Routes の特徴

Next.js の API Routes は従来のバックエンドと少し異なり、次の点に注意が必要です。

Serverless ファースト。Vercel にデプロイすると、各 API リクエストは独立した Serverless 関数として実行されます。自動スケールは便利ですが、ステートレス——従来のメモリ内 Session は使えず、JWT か DB Session に切り替える必要があります。

フロントエンドとの混在。コードが同じリポジトリにあるため、環境変数が誤ってクライアントに漏れやすいです。DATABASE_URL.env に入れたつもりが、バンドルに含まれ GitHub で公開されたケースも見ました。

エッジコンピューティングの制限。Edge Runtime を使う場合、一部の Node.js API が使えず、暗号化ライブラリや DB 接続の選び直しが必要です。そのときはセキュリティ設計も合わせて調整します。

要するに、Next.js API はあなたのバックエンドです。軽くて柔軟ですが、問題も起きやすい——そう理解しておきましょう。

API 認証の実践

認証方式の選び方

まず現実的な問題から。JWT と Session、どちらを使うべきでしょうか。

Next.js プロジェクトを始めた頃、私も悩みました。JWT は必須、Session のほうが安全——説は様々です。結局、シーン次第だと分かりました。

JWT が向いているケース

  • 複数サーバー(Serverless、エッジノード)にデプロイする
  • クロスドメイン認証が必要(フロントが app.com、API が api.com など)
  • Session ストアを管理したくない、できるだけシンプルにしたい

JWT の本質は、ユーザー情報をトークンにエンコードし、サーバー側で状態を持たないこと。リクエストごとにトークンを送ればよく、水平スケールが非常に楽です。

Session が向いているケース

  • サーバー側から能動的に制御したい(強制ログアウト、権限のリアルタイム変更)
  • セキュリティ要件が極めて高く、クライアントにユーザー情報を持たせたくない
  • Redis や DB があり、Session 管理が負担にならない

Session は状態がサーバー側にあり、クライアントは Session ID だけ。特定ユーザーを無効化したければ Session を削除すればよい——JWT ではこれができません。

私の選択基準:小規模・個人プロジェクトなら JWT で手早く。エンタープライズで細かい制御が必要なら Session。迷ったらまずどちらか一つで始め、必要になったら切り替えれば十分です。

JWT 認証の完全実装

JWT を選んだと仮定して、Next.js での実装を見ていきましょう。

ステップ 1:トークンの生成と検証

まずライブラリをインストールします。

npm install jose

jsonwebtoken ではなく jose なのは、Edge Runtime に対応しているからです。Web 標準の実装で、どの環境でも動きます。

lib/auth.ts を作成:

import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET || 'your-secret-key-at-least-32-characters'
);

export async function createToken(payload: { userId: string }) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m') // 15 分で期限切れ
    .sign(secret);
}

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload;
  } catch {
    return null;
  }
}

15m の有効期限に注目してください。7 日、30 日に設定する人も多いですが、トークンが漏れたときの被害が大きくなります。短期 Access Token + 長期 Refresh Token が正攻法です。

ステップ 2:トークンの保存——localStorage は使わない

ここが重要です。多くのチュートリアルが localStorage への保存を教えますが、XSS が来れば一発で盗まれます。

正解は HttpOnly Cookie です。

ログイン API でトークンを返すとき:

// app/api/login/route.ts
import { NextResponse } from 'next/server';
import { createToken } from '@/lib/auth';

export async function POST(request: Request) {
  // ユーザー名・パスワード検証...

  const token = await createToken({ userId: user.id });

  const response = NextResponse.json({ success: true });
  response.cookies.set('token', token, {
    httpOnly: true,    // JS から読めない(XSS 対策)
    secure: true,      // HTTPS のみ
    sameSite: 'lax',   // CSRF 対策
    maxAge: 900,       // 15 分(トークン有効期限と一致)
  });

  return response;
}

HttpOnly が鍵です。JavaScript からは一切読めないため、XSS でも Cookie を盗めません。

ステップ 3:ミドルウェアで API を保護

トークンができたら、ログイン必須の API をどう守るか。Next.js の Middleware を使います。

middleware.ts を作成:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from './lib/auth';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value;

  if (!token) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }

  const payload = await verifyToken(token);
  if (!payload) {
    return NextResponse.json(
      { error: 'Invalid token' },
      { status: 401 }
    );
  }

  // 検証成功——ユーザー情報を API に渡す
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-user-id', payload.userId as string);

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

export const config = {
  matcher: '/api/protected/:path*',
};

これで /api/protected/* 以下は自動的に保護されます。API 側でユーザー情報を取得:

// app/api/protected/profile/route.ts
import { headers } from 'next/headers';

export async function GET() {
  const headersList = await headers();
  const userId = headersList.get('x-user-id');

  // DB からユーザー情報を取得...
}

ステップ 4:トークンリフレッシュ

15 分で切れると、ユーザーは頻繁にログインし直す必要がある?ここで Refresh Token を導入します。

Access Token は短期(15 分)、Refresh Token は長期(30 日)。Access Token が期限切れになったら Refresh Token で新しいものを取得し、再ログインは不要です。

実装はやや複雑ですが、考え方はこうです。next-auth など既存ソリューションにはこの仕組みが組み込まれています。

NextAuth.js で素早く統合

正直、上記を全部自分で書くと原理は分かりますが、工数は大きいです。素早く始めたいなら NextAuth.js(現在は Auth.js)をそのまま使いましょう。

このライブラリの強みは、セキュリティのデフォルト設定が揃っていることです。

  • CSRF 保護を自動処理
  • Session の署名と暗号化
  • JWT と DB Session の両対応
  • Google、GitHub などの OAuth がすぐ使える

インストール:

npm install next-auth

app/api/auth/[...nextauth]/route.ts を作成:

import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        // ユーザー名・パスワード検証...
        if (user) {
          return { id: user.id, email: user.email };
        }
        return null;
      }
    })
  ],
  session: {
    strategy: 'jwt',  // Serverless 向けに JWT
    maxAge: 30 * 24 * 60 * 60, // 30 日
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.userId = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      session.userId = token.userId;
      return session;
    }
  }
});

export { handler as GET, handler as POST };

API でログイン状態を確認:

import { getServerSession } from 'next-auth';

export async function GET() {
  const session = await getServerSession();

  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // ログイン済み——処理を続行...
}

これだけです。NextAuth がトークン管理と Session 更新を肩代わりしてくれます。

CORS 設定の詳細

CORS の本質とよくある問題

CORS(Cross-Origin Resource Sharing)は多くの開発者を悩ませます。開発環境では問題ないのに、デプロイするとエラーが出る——

Access to fetch at 'https://api.example.com' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

要するに、ブラウザには安全ポリシーがあります。サイト A のページからサイト B のリソースに自由にはアクセスできません。app.com のページから api.com の API を呼ぶとき、ブラウザは先に api.com に「app.com からのリクエストを許可しますか?」と確認します。API が明示的に「許可する」と返さないと、リクエストは通りません。

開発環境でエラーが出ない理由

Next.js 開発時はフロントエンドと API が同じ localhost:3000 にあり、同源のため CORS は発生しません。デプロイ後、フロントが Vercel、API が別サーバーになるとクロスオリジンになります。

Preflight リクエストとは

POST でカスタムヘッダー(Authorization など)を付けると、ブラウザは先に OPTIONS で確認します。これが Preflight です。API が OPTIONS を処理していないと 404 になり、CORS が失敗します。

私も POST だけ書いて OPTIONS を忘れ、半日 CORS エラーと格闘したことがあります。

Next.js で CORS を設定する 3 つの方法

方法 1:next.config.js でグローバル設定

すべての API で同じオリジンを許可する場合に向いています。

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
          { key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
        ],
      },
    ];
  },
};

メリットは一度の設定で全体に効くこと。デメリットは API ごとの細かい制御が難しいことです。

方法 2:Middleware で設定

動的判定や一括処理が必要な場合に向いています。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Preflight を処理
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 200,
      headers: {
        'Access-Control-Allow-Origin': 'https://app.example.com',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    });
  }

  // 通常リクエストに CORS ヘッダーを追加
  const response = NextResponse.next();
  response.headers.set('Access-Control-Allow-Origin', 'https://app.example.com');

  return response;
}

export const config = {
  matcher: '/api/:path*',
};

request オブジェクトにアクセスできるため、送信元に応じて許可可否を動的に決められます。

方法 3:API Route 内で設定

特定 API だけ特殊な要件がある場合に使います。

// app/api/public/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const data = { message: 'Hello' };

  return NextResponse.json(data, {
    headers: {
      'Access-Control-Allow-Origin': '*', // 公開 API——全オリジン許可
    },
  });
}

export async function OPTIONS() {
  return new NextResponse(null, {
    status: 200,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  });
}

各 API で OPTIONS 処理が必要です。忘れると Preflight が通りません。

CORS セキュリティのベストプラクティス

1. ワイルドカードを乱用しない

よく見かける書き方:

'Access-Control-Allow-Origin': '*'

これは「どのサイトからでも API を叩いていい」という意味です。公開データなら問題ありませんが、ユーザー情報や敏感な操作 API では危険です。

正しくは、許可ドメインを明示します。

const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];

const origin = request.headers.get('origin');
if (origin && allowedOrigins.includes(origin)) {
  response.headers.set('Access-Control-Allow-Origin', origin);
}

2. クレデンシャル送信に注意

API が Cookie(Session 認証など)を読む必要がある場合、フロントエンドはこう書きます。

fetch('https://api.example.com', {
  credentials: 'include',
});

バックエンド側も合わせる必要があります。

response.headers.set('Access-Control-Allow-Credentials', 'true');

ただし、Access-Control-Allow-Origin* は使えません。ブラウザがこの組み合わせを拒否するため、具体的なドメインを指定してください。

3. Preflight リクエストを処理する

Authorization ヘッダーや Content-Type: application/json を含むリクエストは、ほぼ Preflight が発生します。API は OPTIONS メソッドに応答する必要があります。

共通関数を用意すると楽です。

export function corsHeaders(origin?: string) {
  return {
    'Access-Control-Allow-Origin': origin || 'https://app.example.com',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400', // Preflight 結果を 24 時間キャッシュ
  };
}

各 API で再利用できます。

API レート制限

なぜレート制限が必要か

冒頭の話に戻りましょう。API が 1,800 万回叩かれたとき、IP あたり毎分 100 回に制限していれば、被害は数十ドル程度で済んだはずです。7,800 ドル請求は避けられたでしょう。

レート制限(Rate Limiting)は、一定時間内にユーザーが API を何回呼べるかを制限する仕組みです。シンプルですが、効果は大きいです。

DDoS 対策。大量リクエストでサーバーを落とそうとしても、閾値を超えた分は拒否——サーバーは安定します。

ブルートフォース対策。ログイン API に制限がなければ、1 秒 10,000 パスワードを試せます。IP あたり毎分 5 回に制限すれば、突破難度は指数関数的に上がります。

リソース保護。DB やサードパーティ API にはコストがかかります。制限により、一人のユーザーがリソースを使い切るのを防ぎ、全員に公平なサービスを提供できます。

レート制限ソリューションの比較

Next.js での主流な選択肢は次のとおりです。

方案 1:@upstash/ratelimit + Vercel KV

私が最もよく使う構成です。Upstash は Serverless Redis で、Vercel 公式パートナー。統合も簡単です。

メリット:

  • Serverless 向き——Redis サーバーを自前管理不要
  • 固定ウィンドウ、スライディングウィンドウ、トークンバケットなど複数アルゴリズム
  • 無料枠で個人プロジェクトは十分

デメリット:

  • 大流量では有料
  • サードパーティ依存

方案 2:自前ホスト Redis

既に Redis がある、またはサードパーティを避けたい場合。

メリット:

  • 完全制御、追加コストなし
  • 複雑なロジックも自由に実装

デメリット:

  • Redis サーバーの運用が必要
  • Serverless 環境では設定が複雑

方案 3:メモリ内レート制限

Redis を入れたくない場合の簡易版。

メリット:

  • 依存ゼロ、数行で実装
  • 開発環境や小規模プロジェクト向け

デメリット:

  • Serverless ではインスタンスごとにメモリが分離され、制限が効かない
  • サーバー再起動でカウントがリセット

私のおすすめ:個人プロジェクト・Serverless なら Upstash。エンタープライズで自前サーバーなら Redis。デモやローカル開発ならメモリで妥協。

実践コード例

Upstash を例に、手順を追います。

ステップ 1:インストールと設定

npm install @upstash/ratelimit @upstash/redis

Upstash で Redis データベースを作成し、UPSTASH_REDIS_REST_URLUPSTASH_REDIS_REST_TOKEN.env に設定:

UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token

ステップ 2:レートリミッターを作成

lib/rate-limit.ts

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// Redis クライアント
const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// スライディングウィンドウ:10 秒間に最大 10 回
export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
});

slidingWindow(10, '10 s') は 10 秒間に 10 回まで。固定ウィンドウより滑らかで、境界での突発的なスパイクを防げます。

ステップ 3:API で使用

// app/api/protected/route.ts
import { NextResponse } from 'next/server';
import { ratelimit } from '@/lib/rate-limit';

export async function GET(request: Request) {
  // ユーザー IP を取得
  const ip = request.headers.get('x-forwarded-for') || 'unknown';

  // レート制限チェック
  const { success, limit, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      {
        error: 'Too many requests',
        limit,
        remaining,
        reset: new Date(reset),
      },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      }
    );
  }

  // 制限通過——通常処理
  return NextResponse.json({ data: 'Success' });
}

ここでは IP を識別子に使っています。ログイン済みなら userId も使えます。

const identifier = session?.userId || ip;
const { success } = await ratelimit.limit(identifier);

ログインユーザーはユーザー単位、未ログインは IP 単位——より精密な制御が可能です。

ステップ 4:Middleware で全体制限

各 API に書くのが面倒なら、Middleware で一括処理:

// middleware.ts
import { ratelimit } from '@/lib/rate-limit';

export async function middleware(request: NextRequest) {
  const ip = request.ip || 'unknown';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

すべての API が自動的に保護されます。

入力検証と防御

入力検証が第一の防衛線である理由

「ユーザー入力を決して信頼するな」——セキュリティの鉄則です。

フロントエンドのフォーム検証?開発者ツールで簡単にバイパスできます。本当の防御はサーバー側にあります。

SQL インジェクション。入力欄に '; DROP TABLE users; -- と書かれ、SQL を直接連結すれば DB は壊れます。ORM が主流でも、生 SQL を使う場面は残っています。

XSS 攻撃<script>alert('hacked')</script> が DB に保存され、他ユーザーがページを開くとスクリプトが実行され、Cookie が盗まれます。React は自動エスケープしますが、dangerouslySetInnerHTML を使えば同様に危険です。

DoS 攻撃。10 MB の JSON を送れば Serverless 関数のメモリが溢れます。超長文字列で正規表現が暴走することもあります。

入力検証で大半の初歩的攻撃を防げます。検証なしでは、他の対策も穴だらけです。

Zod による型安全な検証

Zod は TypeScript 製の検証ライブラリで、型システムと相性抜群です。

インストール:

npm install zod

基本用法

スキーマを定義:

import { z } from 'zod';

const userSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  age: z.number().int().min(18).max(120),
});

API で検証:

// app/api/register/route.ts
import { NextResponse } from 'next/server';
import { userSchema } from '@/lib/schemas';

export async function POST(request: Request) {
  const body = await request.json();

  // データ検証
  const result = userSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json(
      {
        error: 'Validation failed',
        details: result.error.format(),
      },
      { status: 400 }
    );
  }

  // 検証成功——型安全なデータを取得
  const { email, password, age } = result.data;

  // 処理を続行...
}

safeParse を使うこと。例外を投げずに結果を返します。parse は例外を投げるため try-catch が必要です。

手書き検証より良い理由

手書き:

if (!body.email || typeof body.email !== 'string') {
  return error;
}
if (!body.email.includes('@')) {
  return error;
}
// 延々と続く...

Zod:

z.string().email()

一行で済み、型も自動推論されます。

完全な入力検証スキーム

検証は型チェックだけではありません。ビジネスロジックと境界条件も考慮します。

1. リクエストボディ(body)の検証

const postSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().max(10000), // 長さ制限(巨大入力対策)
  tags: z.array(z.string()).max(10), // 配列長制限
  publishedAt: z.string().datetime().optional(),
});

2. クエリパラメータ(query)の検証

// app/api/posts/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);

  const querySchema = z.object({
    page: z.coerce.number().int().min(1).default(1),
    limit: z.coerce.number().int().min(1).max(100).default(20),
    sort: z.enum(['asc', 'desc']).default('desc'),
  });

  const params = querySchema.parse({
    page: searchParams.get('page'),
    limit: searchParams.get('limit'),
    sort: searchParams.get('sort'),
  });

  // params.page は必ず数値——型安全
}

z.coerce.number() は文字列を自動で数値に変換してくれます。

3. カスタム検証ルール

const passwordSchema = z.string()
  .min(8)
  .refine((val) => /[A-Z]/.test(val), 'Must contain uppercase')
  .refine((val) => /[a-z]/.test(val), 'Must contain lowercase')
  .refine((val) => /[0-9]/.test(val), 'Must contain number');

非同期検証も可能:

const emailSchema = z.string().email().refine(
  async (email) => {
    const exists = await checkEmailExists(email);
    return !exists;
  },
  'Email already taken'
);

4. エラーハンドリング

Zod のエラーは読みやすいですが、カスタマイズもできます。

if (!result.success) {
  const errors = result.error.errors.map(err => ({
    field: err.path.join('.'),
    message: err.message,
  }));

  return NextResponse.json({ errors }, { status: 400 });
}

構造化されたエラーを返せば、フロントエンドでの表示も楽です。

その他のセキュリティ対策

検証以外にも、重要な対策があります。

1. CSRF 保護

Next.js の Server Actions には CSRF 保護が組み込まれています。リクエストの OriginHost を比較し、不一致なら拒否します。

API Routes は自分で実装が必要です。NextAuth を使えば自動処理されます。手動なら CSRF トークンを使います。

// トークンを Cookie に置き、フロントがリクエストに付与、サーバーで照合

2. Content Security Policy(CSP)

next.config.js で CSP を設定し、ページが読み込めるリソースを制限:

{
  headers: [
    {
      key: 'Content-Security-Policy',
      value: "default-src 'self'; script-src 'self'; style-src 'self';",
    },
  ],
}

XSS 脆弱性があっても、悪意あるスクリプトの読み込みを防げます。

3. 環境変数の安全

Next.js の環境変数には 2 種類あります。

  • NEXT_PUBLIC_* で始まるものはフロントエンドに公開される
  • プレフィックスなしはサーバー側のみ

秘密鍵を NEXT_PUBLIC_ に絶対置かないNEXT_PUBLIC_API_KEY として API キーを公開してしまった事例があります。

4. SQL インジェクション対策

Prisma、Drizzle などの ORM はパラメータ化クエリを自動で行い、基本的に安全です。

生 SQL を書く場合はパラメータ化を使います。

// ❌ 危険
db.query(`SELECT * FROM users WHERE id = ${userId}`);

// ✅ 安全
db.query('SELECT * FROM users WHERE id = ?', [userId]);

5. 依存関係の定期更新

脆弱性は依存ライブラリからも出ます。定期的に実行:

npm audit
npm update

今年 2 月の React 脆弱性も、最新版に更新すれば修正済みです。面倒でも、更新しないほうがもっと面倒になります。

完全セキュリティチェックリスト

ここまでの内容を、プロジェクト診断用のチェックリストにまとめました。

認証セキュリティ

  • ✅ トークンは HttpOnly Cookie に保存(localStorage は使わない)
  • ✅ Access Token の有効期限 ≤ 30 分
  • ✅ Refresh Token 機構を実装
  • ✅ JWT シークレットは 32 文字以上、環境変数に保存
  • ✅ Cookie に securesameSite を設定
  • ✅ Middleware で敏感な API を保護

CORS 設定

  • ✅ 敏感な API に Access-Control-Allow-Origin: * を使わない
  • ✅ 許可ドメインを明示的に指定
  • ✅ OPTIONS Preflight を正しく処理
  • ✅ クレデンシャル送信時は Access-Control-Allow-Credentials: true
  • ✅ 本番環境で CORS 設定が効いているか検証

レート制限

  • ✅ ログイン・登録・パスワードリセットなど重要エンドポイントに厳格な制限
  • ✅ 認証済み・未認証ユーザーで制限を分ける
  • ✅ 429 ステータスと Retry-After ヘッダーを返す
  • ✅ Redis や Upstash など永続ストアを使用(Serverless でメモリ制限は無効)
  • ✅ 制限発動状況を監視し、閾値を調整

入力検証

  • ✅ すべてのユーザー入力をサーバー側で検証
  • ✅ Zod などで型安全に検証
  • ✅ 文字列・配列・オブジェクトの最大長を制限
  • ✅ メール、URL、日付などの形式を検証
  • ✅ 分かりやすい検証エラーを返す

定期監査

  • ✅ 月 1 回以上 npm audit を実行し、高危険度を修正
  • ✅ Next.js と React を最新安定版に更新
  • ✅ Next.js セキュリティ公告を購読
  • ✅ 環境変数を監査し、秘密鍵がフロントに漏れていないか確認
  • ✅ Code Review で認証・権限ロジックを重点チェック

このチェックリストを印刷して机に貼り、新規プロジェクト開始前とデプロイ前に必ず確認しましょう。

まとめ

長く書きましたが、核心は一言です。API セキュリティは一度設定すれば終わりではなく、継続的なシステム工程です

認証、CORS、レート制限、検証——全部設定するのは面倒に感じるかもしれません。でも、データ漏洩、サーバーダウン、高額請求が起きてから対処するほうが、はるかにコストが大きいです。

私の経験では、ゼロからこの一式を設定するのに半日〜 1 日かかります。一度整えれば、以降はテンプレートをコピーしてパラメータを変えるだけ。新プロジェクトなら 10 数分で済みます。何より、夜安心して眠れます。朝起きたら攻撃を受けていた——そんな心配がなくなります。

アクション提案

  1. 今すぐ既存プロジェクトを確認し、チェックリストと照合
  2. 最重要から着手——まず認証とレート制限、あとから他を追加
  3. セキュリティ情報を購読——Next.js 公式ブログ、GitHub Security Advisories など
  4. チームで共有——セキュリティは一人の仕事ではない

最後に一つ。2025 年 12 月の CVSS 10.0 の React 脆弱性は影響範囲が非常に大きいです。まだ更新していなければ、今すぐ最新版へ。セキュリティ更新の先延ばしは、本当に危険です。

API セキュリティの道は長いですが、一歩一歩は価値があります。この記事が、あなたのプロジェクトでいくつかの地雷を避け、早めに防御を整える助けになれば幸いです。

FAQ

Next.js API では JWT と Session、どちらを使うべきですか?
シーンによります。Serverless デプロイやクロスドメイン認証にはステートレスで拡張しやすい JWT が向いています。サーバー側から強制ログアウトしたい、セキュリティ要件が極めて高い場合は Session が適しています。個人プロジェクトなら JWT、エンタープライズ向けなら Session を推奨します。
なぜ JWT トークンを localStorage に保存してはいけないのですか?
localStorage は JavaScript から読み取れるため、XSS が成功するとトークンを盗まれます。HttpOnly Cookie なら JS からアクセスできず、XSS があってもトークン窃取を防げます。
開発環境では CORS エラーが出ないのに、本番で出るのはなぜですか?
開発時はフロントエンドと API が同じ localhost:3000 にあり、同源のため CORS は発生しません。本番ではドメインが分かれるため、ブラウザが CORS チェックを行います。API 側で Access-Control-Allow-Origin を正しく設定してください。
Serverless 環境でレート制限を実装するには?
@upstash/ratelimit + Vercel KV の組み合わせがおすすめです。Serverless 関数はリクエストごとに新インスタンスになるため、メモリ内のカウントは共有されません。Upstash Redis なら全インスタンスでカウントを共有でき、Serverless に最適です。
API へのブルートフォース攻撃を防ぐには?
多層防御が必要です:1) ログイン API に厳格なレート制限(例:IP あたり毎分 5 回)、2) Zod でパスワード強度を検証、3) アカウントロック(連続 5 回失敗で 30 分ロック)、4) CAPTCHA で攻撃コストを上げる。

8分で読めます · 公開日: 2026年1月5日 · 更新日: 2026年6月8日

関連記事

コメント

GitHubアカウントでログインしてコメントできます