JWT認証(Next.js側)— HttpOnly CookieでJWTを安全に管理する
はじめに
前回はSpring BootでJWTトークンを発行するログインAPIを実装しました。今回はNext.js側でそのトークンを受け取り、HttpOnly Cookieで安全に保管して認証を維持する方法を整理します。
なぜlocalStorageではダメなのか
JWTの保管場所としてlocalStorageはよく使われますが、セキュリティリスクがあります。
| 保管場所 | XSS耐性 | CSRF耐性 | 実務での評価 |
|---|---|---|---|
| localStorage | ✗ 弱い | ✓ 強い | 非推奨 |
| HttpOnly Cookie | ✓ 強い | △ 要対策 | 推奨 |
HttpOnly CookieはJavaScriptからアクセスできないため、XSSでトークンが盗まれるリスクを防げます。
① ログインServer Action — トークンをCookieにセット
// app/login/actions.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function login(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
// Spring BootのログインAPIを叩く
const res = await fetch(`${process.env.API_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
throw new Error("ログインに失敗しました");
}
const { token } = await res.json();
// HttpOnly CookieにJWTをセット
cookies().set("auth_token", token, {
httpOnly: true, // JavaScriptからアクセス不可
secure: process.env.NODE_ENV === "production", // 本番はHTTPS必須
sameSite: "lax", // CSRF対策
maxAge: 60 * 60 * 24, // 24時間
path: "/",
});
redirect("/dashboard");
}
② ログインフォーム
// app/login/page.tsx
import { login } from "./actions";
export default function LoginPage() {
return (
<form action={login}>
<input name="email" type="email" placeholder="メールアドレス" required />
<input name="password" type="password" placeholder="パスワード" required />
<button type="submit">ログイン</button>
</form>
);
}
③ 認証済みAPIリクエスト — CookieからトークンをBearerヘッダーに
// lib/fetchWithAuth.ts
import { cookies } from "next/headers";
export async function fetchWithAuth(path: string, options?: RequestInit) {
const token = cookies().get("auth_token")?.value;
const res = await fetch(`${process.env.API_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`, // JWTをヘッダーに付与
...options?.headers,
},
});
return res;
}
// app/dashboard/page.tsx
import { fetchWithAuth } from "@/lib/fetchWithAuth";
export default async function DashboardPage() {
const res = await fetchWithAuth("/api/users");
if (!res.ok) {
// 401など認証エラーはログインへリダイレクト
redirect("/login");
}
const users = await res.json();
return (
<ul>
{users.map((user: { id: number; name: string }) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
④ ログアウト — Cookieを削除
// app/logout/actions.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function logout() {
cookies().delete("auth_token");
redirect("/login");
}
⑤ ミドルウェアで未認証リダイレクト
// middleware.ts(プロジェクトルートに配置)
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("auth_token");
// 認証が必要なパスへのアクセスをチェック
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};
まとめ
| 処理 | 実装場所 | ポイント |
|---|---|---|
| ログイン→Cookie保存 | Server Action |
cookies().set() でHttpOnly指定 |
| 認証付きAPIリクエスト | サーバー側fetch | Authorization: Bearer {token} |
| ログアウト | Server Action | cookies().delete() |
| 未認証リダイレクト | middleware.ts |
/dashboard などをガード |
次回は エラーハンドリング — Spring Boot側でエラーを返してNext.jsで表示する方法を整理します。