用 Claude Code 安全实现 JWT 认证:Claims、Cookie、轮换与密钥
用Claude Code实现JWT认证,覆盖claim设计、Cookie、刷新令牌轮换、吊销、密钥轮换和安全提示。
JWT 认证很容易被低估。示例代码通常只有几行:签名一个 payload,把 token 返回给前端,再在 middleware 中验证。真正上线后,风险来自细节:aud 没验证、刷新令牌长期有效、把 token 放进 localStorage、或者轮换密钥时直接删掉旧公钥。
这篇文章把 JWT 基础讲给初学者听,再整理成 Claude Code 可以执行的实现说明。我们会覆盖 claim 设计、签名和加密的区别、Cookie 与 session 的放置、refresh token rotation、吊销、密钥轮换、常见安全失败,以及安全的 Claude Code prompt。登录系统的整体设计可继续阅读认证实现指南,Cookie 细节看Cookie 管理,权限边界看RBAC 实现。
实现时请以原始资料为准:RFC 7519定义 JWT,RFC 8725说明 JWT 安全最佳实践,RFC 9700讨论刷新令牌重放和轮换,OWASP JWT Cheat Sheet总结常见风险,MDN Set-Cookie解释 Cookie 属性,jose提供 Node 生态的标准实现,Claude Code settings用于确认权限边界。
初学者先理解 JWT 是什么
JWT 是由三段组成的字符串:header.payload.signature。header 记录 token 类型和签名算法,payload 放 claim,signature 用来发现篡改。claim 可以理解成“关于这个 token 的声明”:sub是用户 ID,iss是签发者,aud是使用方,exp是过期时间,jti是 token ID。
关键点是:常见 JWT 是签名,不是加密。签名能证明 payload 没被改过,但不能隐藏 payload。任何拿到 token 的人都可以解码看到里面的内容。因此不要把邮箱之外的敏感信息、地址、API key、账单数据、内部备注放进去。需要保密时,应改用服务端 session 查询;只有在确实需要 payload 保密时才考虑 JWE。
给 Claude Code 的第一条指令应该锁定这个模型。
实现 JWT 认证。
规则:
- JWT payload 是签名内容,不是加密内容,不得放 secret。
- access token 不超过15分钟。
- refresh token 不超过7天。
- 验证 iss、aud、sub、exp、iat、jti。
- 拒绝 alg none 和任何非预期算法。
- 实现 refresh token rotation 和 reuse detection。
- 审查 Cookie、CSRF、XSS、吊销、密钥轮换。
claim 设计要保持最小
不要把 JWT 当成用户资料缓存。access token 只放 API 入口真正需要的最小信息:稳定用户 ID、session ID、tenant ID、粗粒度角色和 token ID。套餐、冻结状态、细粒度权限、余额、使用量这些会变化的字段,应在服务端通过数据库或缓存重新确认。
| claim | 用途 | 注意点 |
|---|---|---|
sub | 用户 ID | 使用内部稳定 ID,不要用邮箱 |
iss | 签发者 | 固定为认证服务地址 |
aud | 使用方 | 防止其他 API 的 token 被误用 |
exp | 过期时间 | access token 要短 |
jti | token ID | 用于吊销、审计、重放检测 |
sid | session ID | 用于设备退出和 token family |
role | 粗角色 | 细权限仍要服务端再查 |
最常见的错误是把plan: "pro"或disabled: false写进 JWT。套餐和禁用状态会变,token 却会在过期前继续携带旧值。认证回答“这个人是谁”,授权回答“现在是否允许这个操作”。两者不要混在一个 token payload 里。
可复制运行的 TypeScript 示例
下面的 demo 使用 jose 完成 access token 签发、验证、refresh token 哈希存储、轮换和复用检测。生产环境请把内存 Map 换成 Redis 或数据库,并加入 HTTPS、rate limit、审计日志和 CSRF 防护。
mkdir jwt-lab
cd jwt-lab
npm init -y
npm install jose
npm install -D tsx typescript @types/node
// auth-demo.ts
import { createHash, createSecretKey, randomUUID } from "node:crypto";
import { SignJWT, jwtVerify } from "jose";
const ISSUER = "https://auth.example.com";
const AUDIENCE = "claudecodelab-api";
const ACCESS_TTL = "15m";
const REFRESH_TTL_SECONDS = 60 * 60 * 24 * 7;
const accessKey = createSecretKey(
Buffer.from(
process.env.JWT_ACCESS_SECRET ??
"dev-only-secret-change-me-32-bytes-minimum"
)
);
const refreshKey = createSecretKey(
Buffer.from(
process.env.JWT_REFRESH_SECRET ??
"dev-only-refresh-secret-change-me-32-bytes"
)
);
type Role = "admin" | "user" | "viewer";
type User = { id: string; role: Role; tenantId: string };
type VerifiedAccess = {
userId: string;
role: Role;
tenantId: string;
sessionId: string;
tokenId: string;
};
type RefreshRecord = {
userId: string;
sessionId: string;
tokenHash: string;
expiresAt: number;
revokedAt?: number;
};
const refreshStore = new Map<string, RefreshRecord>();
const revokedAccessTokenIds = new Set<string>();
function sha256(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function assertRole(value: unknown): asserts value is Role {
if (!["admin", "user", "viewer"].includes(String(value))) {
throw new Error("invalid role claim");
}
}
async function signAccessToken(user: User, sessionId: string) {
const tokenId = randomUUID();
return new SignJWT({ role: user.role, tid: user.tenantId, sid: sessionId })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime(ACCESS_TTL)
.setJti(tokenId)
.sign(accessKey);
}
async function verifyAccessToken(token: string): Promise<VerifiedAccess> {
const { payload } = await jwtVerify(token, accessKey, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
assertRole(payload.role);
if (
typeof payload.sub !== "string" ||
typeof payload.tid !== "string" ||
typeof payload.sid !== "string" ||
typeof payload.jti !== "string"
) {
throw new Error("missing required claim");
}
if (revokedAccessTokenIds.has(payload.jti)) {
throw new Error("access token revoked");
}
return {
userId: payload.sub,
role: payload.role,
tenantId: payload.tid,
sessionId: payload.sid,
tokenId: payload.jti,
};
}
async function signRefreshToken(user: User, sessionId: string) {
const tokenId = randomUUID();
const token = await new SignJWT({ sid: sessionId, kind: "refresh" })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuer(ISSUER)
.setAudience("claudecodelab-refresh")
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime("7d")
.setJti(tokenId)
.sign(refreshKey);
refreshStore.set(tokenId, {
userId: user.id,
sessionId,
tokenHash: sha256(token),
expiresAt: Date.now() + REFRESH_TTL_SECONDS * 1000,
});
return token;
}
async function rotateRefreshToken(refreshToken: string, user: User) {
const { payload } = await jwtVerify(refreshToken, refreshKey, {
issuer: ISSUER,
audience: "claudecodelab-refresh",
algorithms: ["HS256"],
});
if (
typeof payload.jti !== "string" ||
typeof payload.sid !== "string" ||
typeof payload.sub !== "string"
) {
throw new Error("invalid refresh token claims");
}
const record = refreshStore.get(payload.jti);
const presentedHash = sha256(refreshToken);
if (!record || record.revokedAt || record.tokenHash !== presentedHash) {
for (const item of refreshStore.values()) {
if (item.sessionId === payload.sid) item.revokedAt = Date.now();
}
throw new Error("refresh token reuse detected");
}
if (record.expiresAt < Date.now()) {
throw new Error("refresh token expired");
}
record.revokedAt = Date.now();
return {
accessToken: await signAccessToken(user, payload.sid),
refreshToken: await signRefreshToken(user, payload.sid),
};
}
async function main() {
const user: User = {
id: "user_123",
role: "admin",
tenantId: "tenant_a",
};
const sessionId = randomUUID();
const accessToken = await signAccessToken(user, sessionId);
const refreshToken = await signRefreshToken(user, sessionId);
const verified = await verifyAccessToken(accessToken);
const rotated = await rotateRefreshToken(refreshToken, user);
console.log({ verified, rotatedRefreshLength: rotated.refreshToken.length });
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
npx tsx auth-demo.ts
这个例子的重点不是 HS256 本身,而是验证步骤和刷新令牌处理。刷新令牌只存哈希,使用一次就吊销旧记录,再签发新 token。如果旧 token 再次出现,就把同一个sid下的 token family 全部吊销。
Cookie、session 与存放位置
浏览器场景下,refresh token 通常放在HttpOnly、Secure、SameSite Cookie 中。HttpOnly表示 JavaScript 不能直接读取该 Cookie,可以降低 XSS 后 token 被直接偷走的概率。但 Cookie 会自动随请求发送,因此刷新、退出、修改资料等状态变更接口仍然需要 CSRF 防护。
const refreshCookieOptions = {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/api/auth/refresh",
maxAge: 60 * 60 * 24 * 7,
};
const clearRefreshCookieOptions = {
...refreshCookieOptions,
maxAge: 0,
};
access token 可以放在内存中,也可以通过 BFF 或 Next.js Route Handler 让服务端代理 API,从而不把 access token 暴露给浏览器。localStorage 很方便,但一旦发生 XSS,bearer token 会被读取;不要把长期有效的 token 放在那里。
吊销与密钥轮换
JWT 自身会在exp之前保持有效,所以生产环境需要额外控制。退出登录要吊销 refresh 记录;密码修改、账号停用、可疑登录要吊销该用户所有 session;高风险操作可以检查jti吊销列表。这样会牺牲一点“完全无状态”,但换来可控的安全边界。
密钥轮换要有重叠期。HS256 简单,但验证方也要知道同一个 secret。服务变多后,更适合用 RS256 或 ES256,并通过 JWKS 发布公钥。新旧公钥同时保留一段时间,等旧 access token 过期后再删旧钥。
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://auth.example.com/.well-known/jwks.json")
);
export async function verifyWithRotatingKeys(token: string) {
return jwtVerify(token, JWKS, {
issuer: "https://auth.example.com",
audience: "claudecodelab-api",
algorithms: ["RS256", "ES256"],
});
}
{
"rotationPlan": {
"step1": "生成新密钥并发布到JWKS",
"step2": "用新的kid签发新token",
"step3": "旧access token过期前保留旧公钥",
"step4": "检查日志后删除旧公钥"
}
}
真实用例与安全提示
flowchart LR
Login["登录"] --> Access["短期access token"]
Login --> Refresh["HttpOnly refresh cookie"]
Access --> API["API验证iss/aud/exp/jti"]
Refresh --> Rotate["刷新时轮换"]
Rotate --> Store["DB/Redis保存hash和sid"]
Store --> Reuse["检测复用则吊销token family"]
用例一是 SaaS 管理后台。JWT 可以携带tenantId辅助路由和审计,但数据库查询仍要带 tenant 条件,不能只信 claim。管理员操作、计费状态、账号冻结都要查服务端最新状态。
用例二是付费内容或课程站点。access token 要短,refresh token 静默刷新,避免读者学习到一半被踢出。若站点还有广告、Analytics、购买 CTA,认证设计要和Web 安全响应头及 Cookie 同意一起考虑。
用例三是移动或桌面应用。通常不用浏览器 Cookie,而是使用系统安全存储。仍要保留sid,方便设备丢失后单独吊销,并把 refresh token 复用记录成安全事件。
用例四是微服务。不要把对称签名 secret 分发到所有服务。优先考虑公钥验证、API gateway 或 token exchange。每个服务都必须检查aud。
给 Claude Code 的实现 prompt 可以这样写:
请在这个仓库中设计并实现 JWT 认证。
编辑前先输出表格:
- 框架、用户模型、session/cookie代码、auth middleware
- 现有授权检查、CSRF、CSP、rate limit
- 当前token存放位置和XSS/CSRF风险
实现规则:
- 使用jose,不要改回jsonwebtoken。
- access token 15分钟,refresh token 7天。
- 验证iss、aud、sub、exp、iat、jti、sid。
- refresh token只保存hash,必须rotation。
- 检测到复用时吊销同sid的token family。
- 不输出secret、.env或生产token。
- 最后给出测试或curl验证证据。
常见失败、验证与 CTA
常见失败包括:未固定算法、payload 放隐私数据、没验证aud/iss、refresh token 可重复使用、logout 只清浏览器 Cookie、密钥轮换时立即删除旧公钥、把生产 secret 粘贴给 Claude Code。对应措施是算法白名单、最小 claim、服务端 session 记录、rotation、sid吊销、JWKS 重叠期和 secret 脱敏。
curl -i -X POST https://example.com/api/auth/login \
-H "content-type: application/json" \
-d '{"email":"demo@example.com","password":"correct horse"}'
npm test -- --runInBand auth
上线前要测试过期 token、篡改签名、错误 audience、已吊销jti、重复使用 refresh token、退出登录、密码修改和账号停用。还要确认 refresh Cookie 具有HttpOnly、Secure、合理的SameSite和尽量窄的path。
如果要把认证流程标准化,个人可以先用免费 Claude Code cheatsheet固定验证习惯;需要可复用 prompt 和模板时看ClaudeCodeLab products;团队要把 JWT、RBAC、Cookie、审计日志、CI gate 一起落地时,可使用Claude Code training and consultation。
我在为本文验证示例时,最大的收获是先写 claim 表,再写签名代码。这样提前发现了aud遗漏、默认 tsx 路径下 top-level await 不稳定、以及 refresh token 明文保存的风险。JWT 认证不是只把 token 签出来,而是把最小 claim、验证、存储、轮换、吊销和密钥管理放进同一个可审查流程。
免费 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 与咨询路径都要可审查。