用 Claude Code 安全实现认证:Next.js Session、JWT 边界与 OAuth
用 Claude Code 实现安全认证:Next.js Session、JWT 边界、OAuth、CSRF、RBAC、审计日志与测试。
认证不是做一个登录表单。真正上线前,你需要一起处理密码保存、Session Cookie、JWT、OAuth 回调、CSRF、防止用户枚举、密码重置、RBAC、密钥管理、审计日志和测试。只让 Claude Code「加一个登录功能」,很容易得到能跑的页面,却留下难以回滚的安全设计。
最常见的危险捷径,是把长期有效的 JWT 放进浏览器的 localStorage。一旦出现 XSS,也就是页面里被注入恶意 JavaScript,攻击者就可以直接读走 token。对于普通 Web 应用,默认更推荐服务器端 Session:服务器保存登录状态,浏览器只拿到一个不可猜测的 Session ID,并且通过 HttpOnly Cookie 发送。
本文用 Next.js App Router 做一个可复制的最小实现。它包含 Zod 输入校验、bcrypt 密码哈希、签名 Session Cookie、middleware 导航保护、CSRF 与 Origin 检查、RBAC、审计日志和 Vitest 测试。JWT 在这里只作为短期 API 或移动端边界讨论,OAuth 则作为外部身份提供方边界,而不是替代本地权限设计。
官方资料请以原文为准: 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 docs。站内可继续读 Cookie 管理、RBAC 实现 和 Zod 校验。
先分清 Session、JWT 和 OAuth
Session 负责回答「这个浏览器是否仍然登录」。JWT 负责在短时间内证明一组签名声明。OAuth/OIDC 负责让 Google、GitHub 或公司 IdP 证明用户身份。三者不是同一个问题。
| 机制 | 适合场景 | 优点 | 需要控制的风险 |
|---|---|---|---|
| 服务器端 Session | SaaS 后台、会员区、管理画面 | 容易失效、强制登出和审计 | 需要 Redis、Postgres 或 DynamoDB 等持久化存储 |
| JWT | 移动 API、短期服务间调用、外部 API | 可无数据库验证签名 | 失效困难,不要长期保存在浏览器 |
| OAuth / OIDC | Google、GitHub、企业 SSO | 不用自己保存用户密码 | 只是登录入口,本地权限仍要检查 |
三个真实用例最能说明边界。SaaS 仪表盘用 HttpOnly Session Cookie 管理浏览器登录,修改账单和邮箱时要求再认证。付费内容网站把免费读者、已购买用户、编辑和管理员分开,并记录审计日志。企业内部工具可以用 Google Workspace 或 Entra ID 登录,但租户边界、角色、Session 失效仍由应用自己负责。
给 Claude Code 的安全提示词
认证代码生成前,先把安全边界写清楚。
为 Next.js App Router 实现认证。
要求:
- 浏览器登录使用服务器端 Session 和 HttpOnly Cookie
- JWT 仅用于短期外部 API token,不保存到 localStorage
- 密码使用 bcrypt 或 Argon2id 哈希,不保存明文
- 用 Zod 校验输入,不泄露邮箱是否存在
- Cookie 明确设置 Secure、HttpOnly、SameSite、Path、Max-Age
- 状态变更 API 检查 Origin 和 CSRF token
- OAuth 优先使用 Auth.js 等成熟库,不手写完整 OAuth
- 包含 RBAC、密码重置说明、审计日志和测试
- 最后列出坑点和验证命令
这个提示词的目的不是让 Claude Code 多写几行代码,而是限制错误设计。认证系统的质量,往往取决于你是否提前禁止了危险捷径。
可复制的 Next.js 最小实现
先安装依赖。
npm install zod bcryptjs
npm install -D vitest typescript @types/node
.env.local 放置至少 32 个字符的 SESSION_SECRET,不要提交到 Git。
SESSION_SECRET="replace-with-at-least-32-random-characters"
密码处理文件 lib/auth/password.ts。OWASP 更推荐 Argon2id;这里为了复制方便使用 bcryptjs。如果部署环境允许原生依赖,可以换成 argon2 或 @node-rs/argon2。
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);
}
Session 文件 lib/auth/session.ts。示例用内存 Map,生产环境必须替换成 Redis、PostgreSQL、DynamoDB 等共享存储。
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 a = Buffer.from(left);
const b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
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 && 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。真实项目中请从数据库读取 passwordHash 和 role。
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 只做导航保护,不是最终授权边界。
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*"] };
测试文件 test/auth.test.ts 覆盖密码、Session、Zod 和 Cookie 属性。
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 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("validates login input", async () => {
const { loginInputSchema } = await import("../app/api/login/route");
expect(loginInputSchema.safeParse({ email: "bad", password: "short" }).success).toBe(false);
});
});
密码重置、OAuth 与 JWT 边界
密码重置不要只做「发送邮件」。同一个响应文案必须同时适用于存在和不存在的邮箱,避免用户枚举。重置 token 要使用安全随机数,只在数据库保存哈希,设置短有效期,使用后立即失效。密码修改后,可以提供注销其他 Session 的选项。
OAuth 不建议手写。使用 Auth.js 之类的成熟库,让 Google、GitHub 或企业 IdP 完成身份验证。回调成功后,把 provider identity 链接到本地用户,再发放自己的 Session Cookie。不要把 provider access token 当成你的应用 Session。
JWT 适合短期 API 边界,但不适合长期浏览器登录。需要 JWT 时,明确 aud、iss、exp、密钥轮换和泄露处理。
坑点、审计日志与收益导线
发布前重点检查这些坑:长期 JWT 放在 localStorage,Cookie 缺少 Secure 或 HttpOnly,用普通 SHA-256 保存密码,重置 token 明文入库,登录失败暴露邮箱是否存在,只靠 middleware 做 RBAC,审计日志打印密码或 token。
审计日志应该记录 actor、action、result 和时间,不记录密码、Session ID、OAuth access token 或 reset token。SaaS 还要先检查 tenantId,再检查 role。一个用户可以是 A 租户管理员,但不应该读取 B 租户账单。
认证也会影响商业转化。会员内容、模板销售、Gumroad 链接、企业咨询表单和管理后台都依赖可信的账号边界。可以先用免费清单固定 Claude Code 检查流程,需要模板和教材时看产品页,团队要一起设计认证、RBAC、审计日志和 CI 测试时,可以从Claude Code 培训与咨询开始。
实际尝试后的结果
Masa 按这个方式试过后,真正有价值的不是单个登录 API,而是把 Session Cookie、CSRF、RBAC、审计日志和测试放在同一个任务里。以前很容易把 middleware 当成安全边界,实际上它只检查 Cookie 是否存在。把真正的校验放回服务器端 Route 后,代码评审更具体,管理页面的误放行风险也更低。
总结
用 Claude Code 做认证时,先定边界:浏览器使用服务器端 Session 和安全 Cookie,API 使用短期 JWT,外部登录使用 OAuth/OIDC 库,状态变更检查 CSRF 和 Origin,授权使用 RBAC,重要操作写审计日志。Claude Code 可以快速实现,但前提是你先给出这些规则。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。