Documentation Next.js

はじめに

Webアプリケーションにおける認証とセッション管理は、セキュリティの根幹を成す重要な要素です。本記事では、Next.jsでJWT(JSON Web Token)とクッキーを組み合わせた安全な認証システムの実装方法を解説します。

この記事を通じて、以下のことを学べます。

  • JWTとクッキーの基本概念と仕組み
  • HTTP-Onlyクッキーを使った安全なトークン管理
  • ミドルウェアによるAPIルートの保護
  • セキュリティのベストプラクティス

JWTとは

JWT(JSON Web Token) は、ユーザー情報を安全にやり取りするためのトークン形式です。サーバー側でセッション状態を保持せずに認証を行える「ステートレス認証」を実現できます。

JWTの構造

JWTは3つの部分で構成されています。

ヘッダー.ペイロード.署名
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJpYXQiOjE2MzA1MTIwMDB9.abc123...
部分説明
ヘッダーアルゴリズムとトークンタイプを指定
ペイロードユーザー情報や有効期限などのデータ
署名トークンの改ざんを検出するための署名

JWTのメリット

  • スケーラビリティ: サーバー側でセッションを保持しないため、水平スケーリングが容易
  • マイクロサービス対応: 複数のサービス間で認証情報を共有しやすい
  • クライアント側での利用: ペイロードの情報をクライアント側で利用可能(ただし機密情報は含めない)

クッキーを使ったトークン管理

JWTの保存場所として、HTTP-Onlyクッキーを使用することで、セキュリティを大幅に向上させることができます。

なぜHTTP-Onlyクッキーを使うのか

保存方法XSS攻撃CSRF攻撃推奨度
localStorage脆弱安全非推奨
sessionStorage脆弱安全非推奨
通常のクッキー脆弱脆弱非推奨
HTTP-Onlyクッキー安全対策必要推奨

HTTP-Onlyクッキーは、JavaScriptからアクセスできないため、XSS(クロスサイトスクリプティング)攻撃からトークンを保護できます。

実装:ログイン処理

まず、ユーザーがログインした際にJWTを生成し、HTTP-Onlyクッキーに保存する処理を実装します。

必要なパッケージのインストール

npm install jsonwebtoken cookie
npm install --save-dev @types/jsonwebtoken @types/cookie

ログインAPIの実装

// pages/api/auth/login.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { serialize } from 'cookie';
import jwt from 'jsonwebtoken';

// 環境変数から秘密鍵を取得(本番環境では必ず設定すること)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

// レスポンスの型定義
type ResponseData = {
  message: string;
  user?: {
    email: string;
  };
};

export default async function loginHandler(
  req: NextApiRequest,
  res: NextApiResponse<ResponseData>
) {
  // POSTメソッド以外は拒否
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method not allowed' });
  }

  const { email, password } = req.body;

  // 実際のアプリケーションではデータベースでユーザーを検証
  // この例では簡略化のためハードコードしています
  if (email !== 'user@example.com' || password !== 'password123') {
    return res.status(401).json({ message: '認証に失敗しました' });
  }

  // JWTトークンを生成
  const token = jwt.sign(
    {
      email,
      userId: '12345',  // 実際にはデータベースのユーザーID
      iat: Math.floor(Date.now() / 1000),  // 発行時刻
    },
    JWT_SECRET,
    {
      expiresIn: '1h',  // 有効期限:1時間
      algorithm: 'HS256'  // 署名アルゴリズム
    }
  );

  // HTTP-Onlyクッキーとしてトークンを設定
  const cookie = serialize('authToken', token, {
    httpOnly: true,  // JavaScriptからアクセス不可
    secure: process.env.NODE_ENV === 'production',  // 本番環境ではHTTPSのみ
    sameSite: 'strict',  // CSRF対策:同一サイトからのリクエストのみ
    path: '/',  // すべてのパスで有効
    maxAge: 60 * 60,  // 1時間(秒単位)
  });

  // レスポンスヘッダーにクッキーを設定
  res.setHeader('Set-Cookie', cookie);

  return res.status(200).json({
    message: 'ログインに成功しました',
    user: { email }
  });
}

クッキーオプションの詳細

オプション説明
httpOnlytrueクライアント側のJavaScriptからアクセス不可
securetrue(本番)HTTPS接続時のみクッキーを送信
sameSite'strict'同一サイトからのリクエストのみクッキーを送信
path'/'クッキーが有効なパス
maxAge3600クッキーの有効期限(秒)

実装:認証ミドルウェア

APIルートを保護するために、JWTを検証するミドルウェアを作成します。

// lib/auth/middleware.ts
import type { NextApiRequest, NextApiResponse, NextApiHandler } from 'next';
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

// デコードされたトークンの型定義
export interface DecodedToken {
  email: string;
  userId: string;
  iat: number;
  exp: number;
}

// リクエストにユーザー情報を追加するための型拡張
export interface AuthenticatedRequest extends NextApiRequest {
  user: DecodedToken;
}

// 認証ミドルウェア
export function withAuth(handler: NextApiHandler) {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    // クッキーからトークンを取得
    const token = req.cookies.authToken;

    // トークンが存在しない場合
    if (!token) {
      return res.status(401).json({
        message: '認証が必要です',
        error: 'NO_TOKEN'
      });
    }

    try {
      // トークンを検証してデコード
      const decoded = jwt.verify(token, JWT_SECRET) as DecodedToken;

      // リクエストオブジェクトにユーザー情報を追加
      (req as AuthenticatedRequest).user = decoded;

      // 次のハンドラーを実行
      return handler(req, res);
    } catch (error) {
      // トークンが無効または期限切れの場合
      if (error instanceof jwt.TokenExpiredError) {
        return res.status(401).json({
          message: 'トークンの有効期限が切れています',
          error: 'TOKEN_EXPIRED'
        });
      }

      if (error instanceof jwt.JsonWebTokenError) {
        return res.status(401).json({
          message: '無効なトークンです',
          error: 'INVALID_TOKEN'
        });
      }

      return res.status(500).json({
        message: 'サーバーエラーが発生しました',
        error: 'SERVER_ERROR'
      });
    }
  };
}

保護されたAPIルートの例

// pages/api/user/profile.ts
import type { NextApiResponse } from 'next';
import { withAuth, AuthenticatedRequest } from '@/lib/auth/middleware';

type ProfileResponse = {
  user?: {
    email: string;
    userId: string;
  };
  message?: string;
};

async function profileHandler(
  req: AuthenticatedRequest,
  res: NextApiResponse<ProfileResponse>
) {
  // ミドルウェアで認証済みのユーザー情報を取得
  const { email, userId } = req.user;

  // 実際のアプリケーションではデータベースから詳細情報を取得
  return res.status(200).json({
    user: {
      email,
      userId,
    }
  });
}

// withAuthミドルウェアでラップして認証を必須にする
export default withAuth(profileHandler as any);

実装:ログアウト処理

ログアウト時は、クッキーを削除(無効化)します。

// pages/api/auth/logout.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { serialize } from 'cookie';

export default function logoutHandler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // クッキーを即座に期限切れにして削除
  const cookie = serialize('authToken', '', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/',
    maxAge: 0,  // 即座に期限切れ
  });

  res.setHeader('Set-Cookie', cookie);

  return res.status(200).json({ message: 'ログアウトしました' });
}

App Router(Next.js 13以降)での実装

Next.js 13以降のApp Routerを使用する場合は、以下のように実装します。

Route Handlerでのログイン実装

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

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

export async function POST(request: NextRequest) {
  const { email, password } = await request.json();

  // ユーザー認証(実際にはデータベースで検証)
  if (email !== 'user@example.com' || password !== 'password123') {
    return NextResponse.json(
      { message: '認証に失敗しました' },
      { status: 401 }
    );
  }

  // JWTトークンを生成
  const token = jwt.sign(
    { email, userId: '12345' },
    JWT_SECRET,
    { expiresIn: '1h' }
  );

  // レスポンスを作成
  const response = NextResponse.json(
    { message: 'ログインに成功しました', user: { email } },
    { status: 200 }
  );

  // クッキーを設定
  response.cookies.set('authToken', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/',
    maxAge: 60 * 60,  // 1時間
  });

  return response;
}

Middlewareでの認証チェック

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';

// 保護するパスのパターン
const protectedPaths = ['/api/user', '/dashboard'];

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

  // 保護対象のパスかチェック
  const isProtected = protectedPaths.some(p => path.startsWith(p));

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

  const token = request.cookies.get('authToken')?.value;

  if (!token) {
    // APIルートの場合は401を返す
    if (path.startsWith('/api/')) {
      return NextResponse.json(
        { message: '認証が必要です' },
        { status: 401 }
      );
    }
    // ページの場合はログインページにリダイレクト
    return NextResponse.redirect(new URL('/login', request.url));
  }

  try {
    // joseライブラリを使用してトークンを検証
    const secret = new TextEncoder().encode(process.env.JWT_SECRET);
    await jwtVerify(token, secret);
    return NextResponse.next();
  } catch {
    // トークンが無効な場合
    if (path.startsWith('/api/')) {
      return NextResponse.json(
        { message: '無効なトークンです' },
        { status: 401 }
      );
    }
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

export const config = {
  matcher: ['/api/user/:path*', '/dashboard/:path*'],
};

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

1. 環境変数の管理

# .env.local
JWT_SECRET=your-super-secret-key-at-least-32-characters-long
NODE_ENV=development

秘密鍵は十分な長さ(最低32文字以上)を確保し、環境変数で管理してください。

2. トークンの有効期限

// 短い有効期限を設定(推奨:15分〜1時間)
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });

アクセストークンは短めの有効期限を設定し、リフレッシュトークンと組み合わせて使用することを推奨します。

3. CSRF対策の強化

// sameSiteを'strict'または'lax'に設定
const cookie = serialize('authToken', token, {
  // ...
  sameSite: 'strict',  // 最も厳格な設定
});

4. ペイロードに機密情報を含めない

// NG: パスワードや機密情報を含める
const badToken = jwt.sign({ password: 'secret123' }, JWT_SECRET);

// OK: 必要最小限の情報のみ
const goodToken = jwt.sign({ userId: '12345', role: 'user' }, JWT_SECRET);

JWTのペイロードはBase64エンコードされているだけで、暗号化されていません。

5. エラーメッセージの適切な制御

// NG: 詳細なエラー情報を返す
return res.status(401).json({ message: 'パスワードが間違っています' });

// OK: 一般的なエラーメッセージを返す
return res.status(401).json({ message: '認証に失敗しました' });

攻撃者にヒントを与えないよう、エラーメッセージは一般的な内容にしましょう。

クッキー属性のまとめ

属性推奨値目的
httpOnlytrueXSS攻撃からの保護
securetrue(本番)中間者攻撃からの保護
sameSite'strict'CSRF攻撃からの保護
path'/'クッキーの有効範囲を制限
maxAge3600セッションの自動期限切れ

まとめ

Next.jsでJWTとHTTP-Onlyクッキーを組み合わせた認証システムを実装することで、セキュアで拡張性の高いセッション管理を実現できます。

重要なポイントをまとめます。

  1. HTTP-Onlyクッキーを使用 - XSS攻撃からトークンを保護
  2. 適切なクッキー属性を設定 - secure、sameSiteで追加の保護層を構築
  3. ミドルウェアで認証を一元管理 - 保護されたルートへのアクセス制御
  4. トークンの有効期限を短く設定 - 漏洩時のリスクを最小化
  5. 秘密鍵を安全に管理 - 環境変数を使用し、十分な長さを確保

これらのベストプラクティスを活用して、安全なWebアプリケーションを構築しましょう。

参考文献

円