はじめに
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 }
});
}
クッキーオプションの詳細
| オプション | 値 | 説明 |
|---|---|---|
httpOnly | true | クライアント側のJavaScriptからアクセス不可 |
secure | true(本番) | HTTPS接続時のみクッキーを送信 |
sameSite | 'strict' | 同一サイトからのリクエストのみクッキーを送信 |
path | '/' | クッキーが有効なパス |
maxAge | 3600 | クッキーの有効期限(秒) |
実装:認証ミドルウェア
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: '認証に失敗しました' });
攻撃者にヒントを与えないよう、エラーメッセージは一般的な内容にしましょう。
クッキー属性のまとめ
| 属性 | 推奨値 | 目的 |
|---|---|---|
httpOnly | true | XSS攻撃からの保護 |
secure | true(本番) | 中間者攻撃からの保護 |
sameSite | 'strict' | CSRF攻撃からの保護 |
path | '/' | クッキーの有効範囲を制限 |
maxAge | 3600等 | セッションの自動期限切れ |
まとめ
Next.jsでJWTとHTTP-Onlyクッキーを組み合わせた認証システムを実装することで、セキュアで拡張性の高いセッション管理を実現できます。
重要なポイントをまとめます。
- HTTP-Onlyクッキーを使用 - XSS攻撃からトークンを保護
- 適切なクッキー属性を設定 - secure、sameSiteで追加の保護層を構築
- ミドルウェアで認証を一元管理 - 保護されたルートへのアクセス制御
- トークンの有効期限を短く設定 - 漏洩時のリスクを最小化
- 秘密鍵を安全に管理 - 環境変数を使用し、十分な長さを確保
これらのベストプラクティスを活用して、安全なWebアプリケーションを構築しましょう。