Documentation Next.js

はじめに

Webアプリケーションにおいて、APIのセキュリティは最も重要な課題の一つです。不適切なAPI設計は、データ漏洩、不正アクセス、サービス妨害攻撃などの深刻なセキュリティリスクを招きます。

Next.jsは、サーバーサイドとクライアントサイドの両方でコードを実行できるフレームワークであり、適切に設計すればセキュアなAPIを構築できます。本記事では、Next.jsでセキュアなAPIを設計するための具体的な手法を、以下の観点から解説します。

  • 認証と認可: JWTを使用したトークンベース認証の実装
  • ミドルウェア: リクエストの検証と保護
  • APIキーの管理: 機密情報の安全な取り扱い
  • レート制限: DoS攻撃への対策
  • 入力検証: SQLインジェクションやXSS攻撃の防止

認証と認可の実装

認証(Authentication)と認可(Authorization)は、セキュアなAPIの基盤となる概念です。

  • 認証: ユーザーが「誰であるか」を確認するプロセス
  • 認可: 認証されたユーザーが「何をできるか」を決定するプロセス

JSON Web Tokens (JWT)の基礎

JWT(JSON Web Token)は、当事者間で情報を安全に転送するためのコンパクトで自己完結型の方式です。JWTは以下の3つの部分で構成されます。

  1. Header(ヘッダー): トークンのタイプと使用する署名アルゴリズム
  2. Payload(ペイロード): クレーム(ユーザー情報や有効期限など)
  3. Signature(署名): トークンの改ざん防止用の署名
// JWTの構造例
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.    // Header
// eyJ1c2VySWQiOiIxMjM0NSIsInJvbGUiOiJhZG1pbiJ9.  // Payload
// SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c    // Signature

JWTを使用したログイン認証の実装

以下は、Next.jsのAPI Routes(App Router)でJWTを使用したログイン認証を実装する例です。

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';

// 環境変数から秘密鍵を取得(本番環境では必ず強力な秘密鍵を使用)
const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRES_IN = '1h'; // トークンの有効期限

// 型定義
interface LoginRequest {
  email: string;
  password: string;
}

interface User {
  id: string;
  email: string;
  passwordHash: string;
  role: 'user' | 'admin';
}

// ユーザー検索関数(実際にはデータベースから取得)
async function findUserByEmail(email: string): Promise<User | null> {
  // 実装例: Prismaを使用する場合
  // return await prisma.user.findUnique({ where: { email } });
  return null; // プレースホルダー
}

export async function POST(request: NextRequest) {
  try {
    // リクエストボディを取得
    const body: LoginRequest = await request.json();
    const { email, password } = body;

    // 入力値の検証
    if (!email || !password) {
      return NextResponse.json(
        { error: 'メールアドレスとパスワードは必須です' },
        { status: 400 }
      );
    }

    // ユーザーをデータベースから検索
    const user = await findUserByEmail(email);

    if (!user) {
      // セキュリティのため、ユーザーが存在しない場合も同じエラーメッセージを返す
      return NextResponse.json(
        { error: '認証情報が無効です' },
        { status: 401 }
      );
    }

    // パスワードの検証(bcryptでハッシュ比較)
    const isValidPassword = await bcrypt.compare(password, user.passwordHash);

    if (!isValidPassword) {
      return NextResponse.json(
        { error: '認証情報が無効です' },
        { status: 401 }
      );
    }

    // JWTトークンを生成
    const token = jwt.sign(
      {
        userId: user.id,
        email: user.email,
        role: user.role,
      },
      JWT_SECRET,
      {
        expiresIn: JWT_EXPIRES_IN,
        algorithm: 'HS256', // 署名アルゴリズムを明示的に指定
      }
    );

    // リフレッシュトークンも生成(より長い有効期限)
    const refreshToken = jwt.sign(
      { userId: user.id },
      JWT_SECRET,
      { expiresIn: '7d' }
    );

    // トークンをレスポンスで返す
    // 本番環境ではHttpOnly Cookieを使用することを推奨
    return NextResponse.json({
      message: 'ログイン成功',
      token,
      refreshToken,
      user: {
        id: user.id,
        email: user.email,
        role: user.role,
      },
    });
  } catch (error) {
    console.error('ログインエラー:', error);
    return NextResponse.json(
      { error: 'サーバーエラーが発生しました' },
      { status: 500 }
    );
  }
}

ミドルウェアによるトークン検証

Next.jsのミドルウェア機能を使用すると、特定のルートへのアクセス前にトークンを検証できます。これにより、認証が必要なAPIを一元的に保護できます。

// middleware.ts(プロジェクトルートに配置)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';

// 保護するAPIルートのパターン
const protectedRoutes = ['/api/users', '/api/posts', '/api/admin'];

// 認証不要のパブリックルート
const publicRoutes = ['/api/auth/login', '/api/auth/register', '/api/health'];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // パブリックルートはスキップ
  if (publicRoutes.some((route) => pathname.startsWith(route))) {
    return NextResponse.next();
  }

  // 保護されたルートかチェック
  const isProtectedRoute = protectedRoutes.some((route) =>
    pathname.startsWith(route)
  );

  if (!isProtectedRoute) {
    return NextResponse.next();
  }

  // Authorizationヘッダーからトークンを取得
  const authHeader = request.headers.get('Authorization');

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return NextResponse.json(
      { error: '認証トークンが必要です' },
      { status: 401 }
    );
  }

  const token = authHeader.split(' ')[1];

  try {
    // トークンを検証(joseライブラリを使用)
    const secret = new TextEncoder().encode(process.env.JWT_SECRET);
    const { payload } = await jwtVerify(token, secret);

    // 検証成功: ユーザー情報をヘッダーに追加して次の処理へ
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('x-user-id', payload.userId as string);
    requestHeaders.set('x-user-role', payload.role as string);

    return NextResponse.next({
      request: {
        headers: requestHeaders,
      },
    });
  } catch (error) {
    // トークンが無効または期限切れ
    return NextResponse.json(
      { error: 'トークンが無効または期限切れです' },
      { status: 401 }
    );
  }
}

// ミドルウェアを適用するパスを指定
export const config = {
  matcher: '/api/:path*',
};

役割ベースのアクセス制御(RBAC)

認証だけでなく、ユーザーの役割に基づいてアクセスを制御することも重要です。

// lib/auth.ts - 認可ヘルパー関数
import { NextRequest, NextResponse } from 'next/server';

type Role = 'user' | 'admin' | 'moderator';

interface AuthContext {
  userId: string;
  role: Role;
}

// リクエストから認証情報を取得
export function getAuthContext(request: NextRequest): AuthContext | null {
  const userId = request.headers.get('x-user-id');
  const role = request.headers.get('x-user-role') as Role;

  if (!userId || !role) {
    return null;
  }

  return { userId, role };
}

// 役割チェック関数
export function requireRole(allowedRoles: Role[]) {
  return function (
    handler: (request: NextRequest, context: AuthContext) => Promise<NextResponse>
  ) {
    return async function (request: NextRequest) {
      const authContext = getAuthContext(request);

      if (!authContext) {
        return NextResponse.json(
          { error: '認証が必要です' },
          { status: 401 }
        );
      }

      if (!allowedRoles.includes(authContext.role)) {
        return NextResponse.json(
          { error: 'この操作を行う権限がありません' },
          { status: 403 }
        );
      }

      return handler(request, authContext);
    };
  };
}
// app/api/admin/users/route.ts - 管理者専用API
import { NextRequest, NextResponse } from 'next/server';
import { requireRole, getAuthContext } from '@/lib/auth';

// 管理者のみアクセス可能
export const GET = requireRole(['admin'])(async (request, context) => {
  // 管理者専用の処理
  console.log(`Admin ${context.userId} accessed user list`);

  return NextResponse.json({
    users: [
      // ユーザーリスト
    ],
  });
});

APIキーの保護

外部APIを利用する際、APIキーの漏洩は深刻なセキュリティリスクとなります。Next.jsでは、サーバーサイドでのみAPIキーを使用することで、クライアントへの漏洩を防げます。

環境変数の適切な管理

# .env.local(Gitにコミットしない)
# クライアントに公開しない秘密の環境変数
DATABASE_URL="postgresql://..."
JWT_SECRET="your-super-secret-key-minimum-32-characters"
OPENAI_API_KEY="sk-..."
STRIPE_SECRET_KEY="sk_live_..."

# クライアントに公開する環境変数(NEXT_PUBLIC_プレフィックス)
NEXT_PUBLIC_API_BASE_URL="https://api.example.com"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_live_..."

サーバーコンポーネントでのAPI呼び出し

// app/api/external/weather/route.ts
import { NextRequest, NextResponse } from 'next/server';

// APIキーは環境変数から取得(クライアントには公開されない)
const WEATHER_API_KEY = process.env.WEATHER_API_KEY;

interface WeatherResponse {
  temperature: number;
  description: string;
  humidity: number;
}

export async function GET(request: NextRequest) {
  // クエリパラメータから都市名を取得
  const { searchParams } = new URL(request.url);
  const city = searchParams.get('city');

  if (!city) {
    return NextResponse.json(
      { error: '都市名を指定してください' },
      { status: 400 }
    );
  }

  try {
    // サーバーサイドで外部APIを呼び出し
    // APIキーはサーバーサイドでのみ使用される
    const response = await fetch(
      `https://api.weatherapi.com/v1/current.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(city)}`,
      {
        headers: {
          'Content-Type': 'application/json',
        },
        // キャッシュ設定
        next: { revalidate: 300 }, // 5分間キャッシュ
      }
    );

    if (!response.ok) {
      throw new Error('Weather API request failed');
    }

    const data = await response.json();

    // 必要なデータのみをクライアントに返す
    const weatherData: WeatherResponse = {
      temperature: data.current.temp_c,
      description: data.current.condition.text,
      humidity: data.current.humidity,
    };

    return NextResponse.json(weatherData);
  } catch (error) {
    console.error('Weather API error:', error);
    return NextResponse.json(
      { error: '天気情報の取得に失敗しました' },
      { status: 500 }
    );
  }
}

Server Actionsでの安全なAPI呼び出し

Next.js 14以降では、Server Actionsを使用してサーバーサイドの処理を安全に実行できます。

// app/actions/payment.ts
'use server';

import Stripe from 'stripe';

// Stripeの秘密鍵はサーバーサイドでのみ使用
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});

interface PaymentResult {
  success: boolean;
  clientSecret?: string;
  error?: string;
}

export async function createPaymentIntent(
  amount: number,
  currency: string = 'jpy'
): Promise<PaymentResult> {
  try {
    // 金額の検証
    if (amount < 50 || amount > 1000000) {
      return {
        success: false,
        error: '金額が有効範囲外です',
      };
    }

    // 支払いインテントを作成
    const paymentIntent = await stripe.paymentIntents.create({
      amount,
      currency,
      automatic_payment_methods: {
        enabled: true,
      },
    });

    return {
      success: true,
      clientSecret: paymentIntent.client_secret!,
    };
  } catch (error) {
    console.error('Payment intent creation failed:', error);
    return {
      success: false,
      error: '支払いの初期化に失敗しました',
    };
  }
}

レート制限とトラフィックの制御

レート制限は、DoS攻撃やブルートフォース攻撃からAPIを保護するための重要な対策です。

シンプルなインメモリレート制限

// lib/rate-limit.ts
interface RateLimitConfig {
  interval: number; // 時間枠(ミリ秒)
  maxRequests: number; // 許可する最大リクエスト数
}

interface RateLimitEntry {
  count: number;
  resetTime: number;
}

// インメモリストア(本番環境ではRedisを推奨)
const rateLimitStore = new Map<string, RateLimitEntry>();

export function rateLimit(config: RateLimitConfig) {
  const { interval, maxRequests } = config;

  return function (identifier: string): { success: boolean; remaining: number; resetIn: number } {
    const now = Date.now();
    const entry = rateLimitStore.get(identifier);

    // エントリが存在しないか、時間枠が過ぎた場合はリセット
    if (!entry || now > entry.resetTime) {
      rateLimitStore.set(identifier, {
        count: 1,
        resetTime: now + interval,
      });
      return {
        success: true,
        remaining: maxRequests - 1,
        resetIn: interval,
      };
    }

    // リクエスト数が上限に達している場合
    if (entry.count >= maxRequests) {
      return {
        success: false,
        remaining: 0,
        resetIn: entry.resetTime - now,
      };
    }

    // カウントを増加
    entry.count++;
    return {
      success: true,
      remaining: maxRequests - entry.count,
      resetIn: entry.resetTime - now,
    };
  };
}

// 定期的にストアをクリーンアップ
setInterval(() => {
  const now = Date.now();
  for (const [key, entry] of rateLimitStore.entries()) {
    if (now > entry.resetTime) {
      rateLimitStore.delete(key);
    }
  }
}, 60000); // 1分ごとにクリーンアップ

レート制限を適用したAPIエンドポイント

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';

// ログインAPIには厳しいレート制限を適用
const loginRateLimiter = rateLimit({
  interval: 15 * 60 * 1000, // 15分
  maxRequests: 5, // 最大5回の試行
});

export async function POST(request: NextRequest) {
  // IPアドレスを識別子として使用
  const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown';
  const { success, remaining, resetIn } = loginRateLimiter(ip);

  // レスポンスヘッダーにレート制限情報を追加
  const headers = {
    'X-RateLimit-Remaining': remaining.toString(),
    'X-RateLimit-Reset': Math.ceil(resetIn / 1000).toString(),
  };

  if (!success) {
    return NextResponse.json(
      {
        error: 'リクエストが多すぎます。しばらくしてから再試行してください。',
        retryAfter: Math.ceil(resetIn / 1000),
      },
      { status: 429, headers }
    );
  }

  // ログイン処理を続行...
  try {
    const body = await request.json();
    // 認証ロジック...

    return NextResponse.json({ message: 'ログイン成功' }, { headers });
  } catch (error) {
    return NextResponse.json(
      { error: '認証に失敗しました' },
      { status: 401, headers }
    );
  }
}

Upstashを使用した本番環境向けレート制限

本番環境では、分散システムに対応したRedisベースのレート制限を使用することを推奨します。

// lib/rate-limit-redis.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

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

// 異なる用途向けのレート制限を定義
export const apiRateLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(100, '1 m'), // 1分あたり100リクエスト
  analytics: true, // 分析を有効化
});

export const authRateLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '15 m'), // 15分あたり5リクエスト
  analytics: true,
});

export const uploadRateLimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '1 h'), // 1時間あたり10回のアップロード
  analytics: true,
});

データのバリデーションとサニタイズ

ユーザー入力を適切に検証することは、SQLインジェクション、XSS攻撃、その他のインジェクション攻撃を防ぐために不可欠です。

Zodを使用した型安全なバリデーション

// lib/validations/user.ts
import { z } from 'zod';

// ユーザー登録用のスキーマ
export const registerSchema = z.object({
  email: z
    .string()
    .email('有効なメールアドレスを入力してください')
    .max(255, 'メールアドレスは255文字以内で入力してください'),
  password: z
    .string()
    .min(8, 'パスワードは8文字以上で入力してください')
    .max(100, 'パスワードは100文字以内で入力してください')
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
      'パスワードは大文字、小文字、数字、特殊文字を含む必要があります'
    ),
  name: z
    .string()
    .min(1, '名前を入力してください')
    .max(100, '名前は100文字以内で入力してください')
    .regex(/^[a-zA-Z\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\s]+$/, '名前に無効な文字が含まれています'),
  age: z
    .number()
    .int('年齢は整数で入力してください')
    .min(0, '年齢は0以上で入力してください')
    .max(150, '年齢は150以下で入力してください')
    .optional(),
});

// 型を自動生成
export type RegisterInput = z.infer<typeof registerSchema>;

// ログイン用のスキーマ
export const loginSchema = z.object({
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(1, 'パスワードを入力してください'),
});

export type LoginInput = z.infer<typeof loginSchema>;

// 投稿作成用のスキーマ
export const createPostSchema = z.object({
  title: z
    .string()
    .min(1, 'タイトルを入力してください')
    .max(200, 'タイトルは200文字以内で入力してください')
    .transform((val) => val.trim()), // 前後の空白を除去
  content: z
    .string()
    .min(10, '本文は10文字以上で入力してください')
    .max(50000, '本文は50000文字以内で入力してください'),
  tags: z
    .array(z.string().max(50))
    .max(10, 'タグは10個以内で指定してください')
    .optional()
    .default([]),
  isPublished: z.boolean().default(false),
});

export type CreatePostInput = z.infer<typeof createPostSchema>;

バリデーションを適用したAPIエンドポイント

// app/api/users/register/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { registerSchema } from '@/lib/validations/user';
import bcrypt from 'bcryptjs';

export async function POST(request: NextRequest) {
  try {
    // リクエストボディを取得
    const body = await request.json();

    // Zodでバリデーション
    const validationResult = registerSchema.safeParse(body);

    if (!validationResult.success) {
      // バリデーションエラーの詳細を返す
      const errors = validationResult.error.errors.map((err) => ({
        field: err.path.join('.'),
        message: err.message,
      }));

      return NextResponse.json(
        {
          error: '入力内容に問題があります',
          details: errors,
        },
        { status: 400 }
      );
    }

    // バリデーション済みのデータを使用
    const { email, password, name, age } = validationResult.data;

    // パスワードをハッシュ化
    const passwordHash = await bcrypt.hash(password, 12);

    // ユーザーを作成(データベース操作)
    // const user = await prisma.user.create({
    //   data: { email, passwordHash, name, age },
    // });

    return NextResponse.json(
      {
        message: 'ユーザー登録が完了しました',
        // user: { id: user.id, email: user.email, name: user.name },
      },
      { status: 201 }
    );
  } catch (error) {
    console.error('Registration error:', error);
    return NextResponse.json(
      { error: 'ユーザー登録に失敗しました' },
      { status: 500 }
    );
  }
}

XSS対策のためのサニタイズ

// lib/sanitize.ts
import DOMPurify from 'isomorphic-dompurify';

// HTMLコンテンツをサニタイズ
export function sanitizeHtml(dirty: string): string {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
  });
}

// プレーンテキストにサニタイズ(HTMLタグを全て除去)
export function sanitizeText(dirty: string): string {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: [],
    ALLOWED_ATTR: [],
  });
}

// SQLインジェクション対策用のエスケープ
// 注意: ORMやパラメータ化クエリを使用することを強く推奨
export function escapeSqlString(value: string): string {
  return value.replace(/[\0\x08\x09\x1a\n\r"'\\\%]/g, (char) => {
    switch (char) {
      case '\0':
        return '\\0';
      case '\x08':
        return '\\b';
      case '\x09':
        return '\\t';
      case '\x1a':
        return '\\z';
      case '\n':
        return '\\n';
      case '\r':
        return '\\r';
      case '"':
      case "'":
      case '\\':
      case '%':
        return '\\' + char;
      default:
        return char;
    }
  });
}

CORSの適切な設定

Cross-Origin Resource Sharing(CORS)を適切に設定することで、不正なオリジンからのAPIアクセスを防止できます。

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

// 許可するオリジンのリスト
const allowedOrigins = [
  'https://yourdomain.com',
  'https://www.yourdomain.com',
  process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean);

export function middleware(request: NextRequest) {
  const origin = request.headers.get('origin');

  // プリフライトリクエストの処理
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': allowedOrigins.includes(origin ?? '')
          ? origin!
          : '',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '86400', // 24時間
      },
    });
  }

  const response = NextResponse.next();

  // CORSヘッダーを設定
  if (origin && allowedOrigins.includes(origin)) {
    response.headers.set('Access-Control-Allow-Origin', origin);
  }

  // セキュリティヘッダーを追加
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-XSS-Protection', '1; mode=block');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');

  return response;
}

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

APIを本番環境にデプロイする前に、以下のチェックリストを確認してください。

カテゴリチェック項目
認証JWTの秘密鍵は十分に長く、ランダムか
認証トークンの有効期限は適切か
認証リフレッシュトークンを実装しているか
認可役割ベースのアクセス制御を実装しているか
入力検証すべてのユーザー入力を検証しているか
入力検証SQLインジェクション対策を行っているか
入力検証XSS対策のサニタイズを行っているか
レート制限APIにレート制限を設定しているか
レート制限ログインAPIには厳しい制限を設けているか
環境変数秘密鍵がクライアントに公開されていないか
環境変数本番環境用の環境変数を設定しているか
CORS適切なオリジンのみを許可しているか
HTTPS本番環境でHTTPSを強制しているか
エラー処理エラーメッセージで機密情報を漏洩していないか
ログセキュリティイベントをログに記録しているか

まとめ

Next.jsでセキュアなAPIを設計するためには、複数のセキュリティ対策を組み合わせることが重要です。本記事で紹介した主要なポイントを振り返ります。

  1. 認証と認可: JWTを使用したトークンベース認証と、役割ベースのアクセス制御を実装する
  2. ミドルウェア: リクエストの検証を一元化し、保護されたルートへの不正アクセスを防ぐ
  3. APIキーの保護: 環境変数を適切に管理し、サーバーサイドでのみ機密情報を扱う
  4. レート制限: DoS攻撃やブルートフォース攻撃を防ぐためのリクエスト制限を実装する
  5. 入力検証: Zodなどのバリデーションライブラリを使用して、すべてのユーザー入力を検証する
  6. CORS設定: 許可するオリジンを明示的に指定し、不正なクロスオリジンリクエストを防ぐ

セキュリティは継続的なプロセスです。新しい脆弱性や攻撃手法が発見されるたびに、APIのセキュリティ対策を見直し、更新することが重要です。

参考文献

円