Claude Codeで認証実装を安全に進める実践ガイド|Next.jsセッション・JWT・OAuth
Claude CodeでNext.js認証を安全に実装。セッション、JWT、OAuth、CSRF、RBAC、監査ログまで実例で解説。
認証実装は「ログイン画面を作る」だけでは終わりません。パスワードの保存、セッションCookie、JWT、OAuth、CSRF、パスワードリセット、RBAC、監査ログ、テストまでが一続きです。ここを曖昧にしたままClaude Codeへ「認証を作って」と頼むと、画面は動いても、長期運用で危険な設計が残ります。
特に危ないのは、ブラウザ向けログインをJWTだけで済ませ、長い有効期限のトークンをlocalStorageへ置くパターンです。XSS、つまり悪意あるJavaScriptがページ内で動く攻撃が起きたとき、盗まれたJWTをサーバー側で止めにくくなります。Webアプリの通常ログインでは、サーバー側セッションを保存し、ブラウザには推測困難なセッションIDだけをHttpOnly Cookieで渡す方が扱いやすいです。
この記事では、Claude Codeに認証実装を任せるときの実務手順を、Next.js App Routerのコピペ可能なコードで整理します。sessionは「サーバー側でログイン状態を管理する仕組み」、JWTは「署名付きの自己完結トークン」、OAuthは「外部IDプロバイダへ本人確認を委ねる境界」、CSRFは「ログイン済みブラウザに別サイトから勝手にPOSTさせる攻撃」と読み替えてください。
公式情報は必ず原典で確認します。この記事ではNext.js Authentication guide、Next.jsのcookies API、OWASP Authentication Cheat Sheet、OWASP Password Storage Cheat Sheet、OWASP Forgot Password Cheat Sheet、MDN Secure cookie configuration、Auth.js、Claude Code公式ドキュメントを基準にします。関連して、Cookieの細部はClaude Code Cookie管理、権限設計はClaude Code RBAC実装、入力検証はClaude Code Zod validationも合わせて確認してください。
セッション、JWT、OAuthを混ぜない
最初に認証方式の役割を分けます。Claude Codeに曖昧な依頼をすると、セッションCookie、JWT、OAuthのコールバック処理が1つの大きな関数へ混ざりがちです。レビューしやすくするには「ブラウザのログイン状態」「APIクライアントの認可」「外部プロバイダからの本人確認」を別の責務として扱います。
| 方式 | 向いている用途 | 強み | 注意点 |
|---|---|---|---|
| サーバー側セッション | 管理画面、SaaSダッシュボード、会員サイト | 失効・強制ログアウト・監査がしやすい | RedisやDBなど永続ストアが必要 |
| JWT | モバイルAPI、短命のサービス間呼び出し、外部API | DB参照なしで署名検証できる | 失効が難しい。長命JWTをブラウザ保存しない |
| OAuth / OIDC | Google、GitHub、社内IdPログイン | パスワードを自社で持たずに済む | OAuthはログイン入口。自社アプリのセッション設計は別途必要 |
実務で多いユースケースは3つあります。1つ目はSaaSの管理画面です。ブラウザには短命のHttpOnlyセッションCookieだけを持たせ、支払い設定やメール変更では再認証を求めます。2つ目は会員向けコンテンツや教材販売です。無料記事、購入済みPDF、管理者編集画面の境界をRBACで分け、監査ログに「誰が何をしたか」を残します。3つ目は社内ツールやB2B連携です。ログインはGoogle WorkspaceやEntra IDなどのOAuth/OIDCに任せても、アプリ内のテナント境界、ロール、セッション失効は自前で確認します。
JWTを使う場合も、アクセストークンは短命にします。リフレッシュトークンを使うなら、ローテーション、盗難検知、DB側での失効を設計します。Claude Codeには「JWTを作って」ではなく、「ブラウザセッションはCookie、外部APIだけ短命JWT、更新トークンはDBにハッシュ保存」といった境界を渡してください。
Claude Codeに渡す前提
実装前に、Claude Codeへ次のような条件を渡します。ポイントは、コード生成より先に攻撃面と検証方法を書かせることです。
Next.js App Routerで認証を実装してください。
条件:
- ブラウザログインはサーバー側セッション + HttpOnly Cookieを使う
- JWTは外部API向けの短命トークン用途に限定し、localStorageへ保存しない
- パスワードはbcryptまたはArgon2idでハッシュ化し、平文保存しない
- 入力はZodで検証し、ログイン失敗メッセージでユーザー存在を漏らさない
- CookieはSecure、HttpOnly、SameSite、Path、Max-Ageを明示する
- 状態変更APIはOriginチェックとCSRFトークンを確認する
- OAuthはAuth.jsなどのライブラリ利用を優先し、自前実装しない
- RBAC、パスワードリセット、監査ログ、テストケースを含める
- 最後に落とし穴と検証コマンドを出す
この指示なら、Claude Codeは「ログインAPIだけ」ではなく、周辺の安全装置まで考えます。harnessという言葉を使うなら、ここでは「エージェントが安全に作業するための足場」です。足場には、プロンプト、公式リンク、テスト、レビュー観点、CLAUDE.mdの規約を含めます。認証実装ほど、この足場の有無で品質差が出ます。
コピーして動かせるNext.js認証の最小構成
ここからは最小のデモです。ローカルで試すなら、Next.jsプロジェクトに次を入れます。
npm install zod bcryptjs
npm install -D vitest typescript @types/node
.env.localには32文字以上の秘密値を置きます。秘密値、つまりSESSION_SECRETはCookie内のセッションIDへ署名するための鍵です。Gitへコミットせず、Vercel、Cloudflare、AWSなどの環境変数ストアで管理します。
SESSION_SECRET="replace-with-at-least-32-random-characters"
まずlib/auth/password.tsです。OWASPはArgon2idを第一候補として挙げています。Node.jsでネイティブ依存を許容できる環境ならargon2や@node-rs/argon2を検討してください。ここではコピペしやすさのためにbcryptjsを使います。bcryptは72バイト制限があるため、長いパスフレーズやUnicode文字を扱う仕様では、事前検証と移行方針を決めます。
import bcrypt from "bcryptjs";
import { z } from "zod";
export const passwordSchema = z.string().min(12).max(128);
export async function hashPassword(password: string) {
const parsed = passwordSchema.parse(password);
return bcrypt.hash(parsed, 12);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}
次にlib/auth/session.tsです。デモなのでメモリ上のMapへ保存します。本番ではRedis、PostgreSQL、DynamoDBなど、再起動や複数インスタンスに耐えるストアへ置き換えてください。Cookie名は本番で__Host-session、ローカル開発やテストではdev-sessionにしています。__Host- prefixはSecure、Path=/、Domainなしをブラウザに要求するため、本番のセッションCookieに向いています。
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { z } from "zod";
const env = z
.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
SESSION_SECRET: z.string().min(32),
})
.parse(process.env);
export type Role = "user" | "admin";
type SessionRecord = {
userId: string;
role: Role;
csrfToken: string;
expiresAt: number;
};
declare global {
var demoSessions: Map<string, SessionRecord> | undefined;
}
const sessions = globalThis.demoSessions ?? new Map<string, SessionRecord>();
globalThis.demoSessions = sessions;
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;
export const SESSION_COOKIE_NAME =
env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export const sessionCookieOptions = {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
};
function signSessionId(sessionId: string) {
return createHmac("sha256", env.SESSION_SECRET)
.update(sessionId)
.digest("base64url");
}
function safeEqual(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
export function createSession(userId: string, role: Role = "user") {
const sessionId = randomBytes(32).toString("base64url");
const token = `${sessionId}.${signSessionId(sessionId)}`;
const csrfToken = randomBytes(32).toString("base64url");
sessions.set(sessionId, {
userId,
role,
csrfToken,
expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
});
return { token, csrfToken };
}
export function getSession(token?: string) {
if (!token) return null;
const [sessionId, signature] = token.split(".");
if (!sessionId || !signature || !safeEqual(signature, signSessionId(sessionId))) return null;
const session = sessions.get(sessionId);
if (!session || session.expiresAt < Date.now()) {
sessions.delete(sessionId);
return null;
}
return { id: sessionId, ...session };
}
export function destroySession(token?: string) {
const sessionId = token?.split(".")[0];
if (sessionId) sessions.delete(sessionId);
}
export function assertSameOrigin(request: Request) {
const origin = request.headers.get("origin");
if (!origin) return;
if (origin !== new URL(request.url).origin) throw new Error("Bad origin");
}
export function assertCsrf(request: Request, session: { csrfToken: string }) {
const submitted = request.headers.get("x-csrf-token");
if (!submitted || submitted !== session.csrfToken) throw new Error("Bad CSRF token");
}
ログインRoute Handlerはapp/api/login/route.tsです。loginInputSchemaをexportしているのは、テストで同じ入力契約を検証するためです。ユーザー検索はデモ用ですが、実務ではDBからpasswordHashとロールを取得します。
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import {
SESSION_COOKIE_NAME,
createSession,
sessionCookieOptions,
} from "@/lib/auth/session";
export const runtime = "nodejs";
export const loginInputSchema = z.object({
email: z.string().trim().toLowerCase().email(),
password: z.string().min(12).max(128),
});
async function findUserByEmail(email: string) {
if (email !== "masa@example.com") return null;
return {
id: "user_123",
role: "admin" as const,
passwordHash: await hashPassword("correct-horse-battery-staple"),
};
}
export async function POST(request: NextRequest) {
const parsed = loginInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const user = await findUserByEmail(parsed.data.email);
const passwordOk = user
? await verifyPassword(parsed.data.password, user.passwordHash)
: false;
if (!user || !passwordOk) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const session = createSession(user.id, user.role);
const response = NextResponse.json({ ok: true, csrfToken: session.csrfToken });
response.cookies.set({
name: SESSION_COOKIE_NAME,
value: session.token,
...sessionCookieOptions,
});
response.cookies.set({
name: "csrf-token",
value: session.csrfToken,
secure: sessionCookieOptions.secure,
sameSite: "lax",
path: "/",
maxAge: sessionCookieOptions.maxAge,
});
return response;
}
保護ページへの導線はmiddleware.tsで守ります。ただし、ここはUX用の入口制御です。Cookieの存在だけで管理者権限を許可してはいけません。実際のデータ読み書きでは、Route HandlerやServer Action側でgetSession、CSRF、RBACを再確認します。
import { NextRequest, NextResponse } from "next/server";
const SESSION_COOKIE_NAME =
process.env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export function middleware(request: NextRequest) {
const hasSession = request.cookies.has(SESSION_COOKIE_NAME);
const pathname = request.nextUrl.pathname;
if (!hasSession && (pathname.startsWith("/dashboard") || pathname.startsWith("/admin"))) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};
状態変更APIの例です。app/api/admin/settings/route.tsのような管理APIでは、セッション、ロール、Origin、CSRFを一緒に見ます。RBACは「ログイン済みならOK」ではなく「その人が、この操作をしてよいか」を確認する仕組みです。
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import {
SESSION_COOKIE_NAME,
assertCsrf,
assertSameOrigin,
getSession,
} from "@/lib/auth/session";
const settingsSchema = z.object({
displayName: z.string().min(1).max(80),
});
export async function POST(request: NextRequest) {
const session = getSession(request.cookies.get(SESSION_COOKIE_NAME)?.value);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
try {
assertSameOrigin(request);
assertCsrf(request, session);
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = settingsSchema.safeParse(await request.json());
if (!body.success) return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
console.info("audit.auth.settings_update", {
actorId: session.userId,
action: "settings.update",
result: "success",
at: new Date().toISOString(),
});
return NextResponse.json({ ok: true });
}
最後にtest/auth.test.tsです。Cookie属性、パスワード検証、セッション失効、Zod入力、CSRFを小さく確認します。
import { beforeAll, describe, expect, it } from "vitest";
beforeAll(() => {
process.env.NODE_ENV = "test";
process.env.SESSION_SECRET = "test-secret-value-with-more-than-32-characters";
});
describe("auth primitives", () => {
it("hashes and verifies passwords", async () => {
const { hashPassword, verifyPassword } = await import("../lib/auth/password");
const hash = await hashPassword("correct-horse-battery-staple");
await expect(verifyPassword("correct-horse-battery-staple", hash)).resolves.toBe(true);
await expect(verifyPassword("wrong-password", hash)).resolves.toBe(false);
});
it("creates and destroys a signed server-side session", async () => {
const { createSession, destroySession, getSession } = await import("../lib/auth/session");
const session = createSession("user_123", "admin");
expect(getSession(session.token)?.role).toBe("admin");
destroySession(session.token);
expect(getSession(session.token)).toBeNull();
});
it("keeps cookie options explicit", async () => {
const { sessionCookieOptions } = await import("../lib/auth/session");
expect(sessionCookieOptions.httpOnly).toBe(true);
expect(sessionCookieOptions.sameSite).toBe("lax");
expect(sessionCookieOptions.path).toBe("/");
expect(sessionCookieOptions.maxAge).toBeGreaterThan(0);
});
it("validates login input with Zod", async () => {
const { loginInputSchema } = await import("../app/api/login/route");
expect(loginInputSchema.safeParse({ email: "bad", password: "short" }).success).toBe(false);
expect(
loginInputSchema.safeParse({
email: "masa@example.com",
password: "correct-horse-battery-staple",
}).success
).toBe(true);
});
it("checks CSRF token and origin for state-changing requests", async () => {
const { assertCsrf, assertSameOrigin, createSession, getSession } = await import(
"../lib/auth/session"
);
const created = createSession("user_123", "admin");
const session = getSession(created.token);
if (!session) throw new Error("missing session");
const request = new Request("https://example.com/api/admin/settings", {
method: "POST",
headers: {
origin: "https://example.com",
"x-csrf-token": created.csrfToken,
},
});
expect(() => assertSameOrigin(request)).not.toThrow();
expect(() => assertCsrf(request, session)).not.toThrow();
});
});
パスワードリセットとOAuthの境界
パスワードリセットは、ログインより事故が起きやすい機能です。OWASPは、存在するメールアドレスと存在しないメールアドレスで同じメッセージを返すこと、リセットトークンを暗号学的に安全な乱数で作ること、十分短い期限を設定すること、単回使用にすること、パスワード変更後に通常ログインへ戻すことを推奨しています。Claude Codeには「メールを送る処理」だけでなく、「トークンはDBにハッシュ保存」「使用済みにする」「既存セッションを無効化する選択肢を出す」まで指示します。
OAuthは自前実装しない方が安全です。GoogleやGitHubログインが必要なら、Auth.jsのようなライブラリを使い、プロバイダから返ったユーザーを自社DBのユーザーへリンクします。その後は、この記事のような自社セッションCookieを発行します。OAuthのアクセストークンをそのまま自社アプリのログインCookieとして使うと、スコープ、失効、監査の責務が混ざります。
JWTはAPI向けに便利ですが、万能ではありません。管理画面の権限変更、退職者の即時無効化、パスワード変更後の全セッション失効が必要なら、サーバー側セッションの方がレビューしやすいです。JWTを使うなら、短い有効期限、aud、iss、exp、鍵ローテーション、漏えい時の対応をClaude Codeに明示してください。
落とし穴と監査ログ
具体的な落とし穴を公開前に潰します。localStorageに長命JWTを置く、CookieにSecureやHttpOnlyがない、ログイン失敗で「メールアドレスが存在しません」と返す、SHA-256だけでパスワードを保存する、パスワードリセットトークンを平文DB保存する、middlewareだけでRBACを済ませる、監査ログにパスワードやトークンを出す。どれもClaude Codeが悪いというより、依頼とレビュー観点が足りないと起きる失敗です。
監査ログは「デバッグログ」ではありません。誰が、いつ、何を、どの結果で行ったかを残します。ただし、パスワード、セッションID、リセットトークン、OAuthアクセストークンは絶対に出しません。IPアドレスやUser-Agentも、地域のプライバシー要件に合わせて保存期間とマスキング方針を決めます。
RBACでは、adminの一点突破を避けます。user:manage、billing:read、article:updateのようにpermissionを分け、APIごとにdeny by default、つまり明示的な許可がなければ拒否します。テナント型SaaSでは、ロールより先にtenantIdを確認します。他社テナントの請求書を読めてしまう事故は、ログイン機能ではなく認可機能の不足です。
収益化導線と運用
認証は収益化にも直結します。会員記事、教材販売、Gumroadの購入導線、法人向け相談フォームは、ログイン状態と権限境界が曖昧だと信用を失います。まず無料チートシートでClaude Codeの確認手順を固定し、テンプレートや実務教材が必要なら教材・テンプレート一覧へ進んでください。チームで認証、RBAC、監査ログ、CIテストまで整えるならClaude Code研修・導入相談で実リポジトリ前提に設計できます。
Masaがこの型で試した結果、一番効果があったのは「ログインRoute Handlerを作る」ことではなく、セッション、CSRF、RBAC、監査ログ、テストを同じ作業単位にしたことでした。以前はCookie属性の確認と権限テストが後回しになり、公開直前にmiddlewareだけで管理画面を守っていることに気づきました。今回のように公式リンク、Zodスキーマ、Cookie設定、失敗ケース、テストを先にClaude Codeへ渡すと、実装差分は小さく、レビューの会話も具体的になります。
まとめ
Claude Codeで認証を実装するなら、最初に方式の境界を決めてください。ブラウザはサーバー側セッションと安全なCookie、APIは短命JWT、外部ログインはOAuth/OIDCライブラリ、状態変更はCSRFとOrigin確認、権限はRBAC、重要操作は監査ログです。
動くログイン画面だけならすぐ作れます。しかし、公開できる認証は、失効、リセット、権限、秘密情報、ログ、テストまでそろって初めて成立します。Claude Codeにはその全体像を渡し、公式ドキュメントとテストで必ず確認してください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。