用 jose 正确处理 JWT:签发、验签、过期与密钥管理
用 jose 正确处理 JWT:签发、验签、过期与密钥管理
上一篇把密码这一段讲清楚了:
用户注册时用 bcryptjs 存密码哈希,登录时再做密码校验。
但密码校验通过,只解决了"这次登录是不是本人"。
接下来还有一个问题:
服务端要怎么记住这个用户已经登录过了?
传统做法是 session。
现在很多 Node.js 项目会选择 token,最常见的就是 JWT。
这一篇就专门讲这部分:
- JWT 到底是什么
jose这个库在做什么- token 怎么签发
- token 怎么正确验证
exp、iss、aud、sub这些字段分别代表什么- HS256 和 RS256 该怎么选
- 实战里最容易踩哪些坑
这一篇的目标不是"会调 API",而是把 JWT 在登录系统里的边界讲清楚。
1. 为什么登录成功后还需要一个 token
先看最基本的登录过程:
- 用户提交账号和密码
- 服务端验证密码
- 验证通过
到这一步,服务端已经知道这个用户是谁了。
但 HTTP 请求本身是无状态的。下一次请求再来时,服务端默认并不知道这是不是刚才那个用户。
所以系统需要一种方式,把"这个用户已经通过认证"这件事传递给后续请求。
这时就有两条常见路线:
1.1 session
服务端保存一份会话记录,客户端只保存一个 session id。
后续请求带着这个 id,服务端去会话存储里查。
1.2 token
服务端签发一个 token 给客户端。
客户端后续每次请求都带上这个 token。
服务端验证 token 合法性后,就能识别用户身份。
JWT 就是后一种方案里最常见的 token 形式之一。
2. JWT 到底是什么
JWT 全称是 “JSON Web Token”。
它是一种标准化的 token 表达格式,用来在多方之间传递一组 claims,也就是"声明"。
这些声明通常包括:
- 用户是谁
- token 给谁用
- 是谁签发的
- 什么时候签发
- 什么时候过期
JWT 最常见的用途之一,就是登录后的身份凭证。
登录成功后,服务端签发一个 JWT。
客户端把它带到后续请求里。
服务端只要验证这个 JWT 没有被篡改、没有过期、用途也正确,就能继续处理请求。
3. 先分清 JWT、JWS、JWE 和 jose
很多文章一上来就说 JWT,但没有把相关概念分开,这会导致后面越看越乱。
3.1 JWT
JWT 是一种 token 格式。
它定义了 token 里怎么表达 claims。
3.2 JWS
JWS 是 “JSON Web Signature”。
它表示这个内容是"签名保护过的"。
在登录系统里,最常见的是:
JWT 的内容,经过 JWS 方式签名。
也就是平时大家口头上说的"JWT 验签"。
3.3 JWE
JWE 是 “JSON Web Encryption”。
它表示这个内容是"加密保护过的"。
这点很重要:
大多数登录场景里,用的是签名 JWT,不是加密 JWT。
也就是说,token 里的内容往往可以被解码看到,但不能被伪造和篡改。
3.4 jose
jose 是一个 JavaScript 生态里很重要的 JOSE 标准库。
它覆盖的范围不只是 JWT,还包括:
- JWS
- JWE
- JWK
- JWT 签发与验证
- 密钥导入导出
- 对称密钥和非对称密钥处理
对我们这个系列来说,当前最常用的能力是两块:
- 签发 JWT
- 验证 JWT
4. JWT 长什么样
一个 JWT 通常是三段,用点分隔:
header.payload.signature
看起来像这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoidXNlciIsImlhdCI6MTcxMDAwMDAwMCwiZXhwIjoxNzEwMDAwOTAwfQ.xxxxx
这三段分别代表:
4.1 Header
头部,说明这是什么 token,使用什么算法签名。
常见内容:
{
"alg": "HS256",
"typ": "JWT"
}
4.2 Payload
载荷,也就是 claims。
这里通常会放用户标识和一些元信息,比如:
{
"sub": "123",
"role": "user",
"iat": 1710000000,
"exp": 1710000900
}
4.3 Signature
签名部分。
它的作用是保证前两段没有被篡改。
5. JWT 里的内容能不能被别人看到
能。
这件事必须反复强调:
JWT 默认不是保密容器。
常见登录场景里,JWT 只是签名,不是加密。
所以任何拿到 token 的人,都可以把前两段做 Base64URL 解码,看到 Header 和 Payload。
这意味着:
- 不要在 JWT 里放密码
- 不要放身份证号、银行卡号这类高敏感信息
- 不要把完整用户对象一股脑塞进去
- 不要以为"别人看不懂那串字符串"
JWT 解决的是"可信传递",不是"秘密存储"。
6. 登录场景里,JWT 到底承载什么信息
一个比较健康的 access token,通常只放最小必要信息。
例如:
sub: 用户 idrole: 用户角色iss: 签发者aud: 接收方iat: 签发时间exp: 过期时间jti: token 唯一 id
一个常见思路是:
- token 里放"识别身份所需的最小集合"
- 更详细的用户资料,到数据库或用户服务里再查
这样做有几个好处:
- token 更短
- 变更更灵活
- 敏感信息暴露面更小
- 权限收敛更清楚
7. 这些标准字段分别是什么意思
这一部分最好一次记清楚,因为它们会反复出现。
7.1 sub
subject,主题,一般表示"这个 token 代表谁"。
在登录场景里,通常放用户 id。
例如:
{
"sub": "user_123"
}
建议把它当字符串来处理。
7.2 iss
issuer,签发者。
表示这个 token 是谁发的。
例如:
{
"iss": "https://api.example.com"
}
服务端验证 token 时,最好检查它是不是来自预期签发方。
7.3 aud
audience,受众。
表示这个 token 是给谁用的。
例如:
{
"aud": "my-app"
}
如果你的系统里有多个前端、多个服务、多个资源域,aud 很有价值。
7.4 iat
issued at,签发时间。
表示 token 是什么时候生成的。
7.5 exp
expiration time,过期时间。
这是最重要的字段之一。
它决定这个 token 在什么时间点后失效。
没有过期时间的 access token 往往很危险。
7.6 nbf
not before,表示这个 token 在某个时间之前不能使用。
有些业务场景会用到,但普通登录系统里没有前几个字段常见。
7.7 jti
JWT ID,token 唯一标识。
如果你后面要做撤销、黑名单、审计日志,jti 会很有用。
8. jose 安装与基本使用
先安装:
npm install jose
在 Node.js 里,jose 的使用风格和很多老库不太一样。
它更贴近标准,也更明确地区分了:
- 签发
- 验证
- 密钥类型
- 算法类型
9. 先用 HS256 写一个最小签发示例
HS256 是对称签名算法。
意思是:
- 签发用同一个 secret
- 验证也用同一个 secret
这很适合单体服务、内部服务数量不多、密钥管理边界很清晰的场景。
9.1 签发 token
import { SignJWT } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function signAccessToken(user: { id: string; role: string }) {
return await new SignJWT({ role: user.role })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(user.id)
.setIssuer("https://api.example.com")
.setAudience("my-app")
.setIssuedAt()
.setExpirationTime("15m")
.setJti(crypto.randomUUID())
.sign(secret);
}
这里做了几件事:
- payload 里放了
role sub用用户 idiss说明签发者是谁aud说明 token 给谁用iat自动设置签发时间exp设置 15 分钟过期jti用于唯一标识本次 token
9.2 为什么把用户 id 放在 sub 而不是随便自定义一个字段
因为 sub 本来就是标准语义里的"主体"。
这样别的服务、别的开发者接手代码时,一眼就知道 token 代表谁。
10. 怎么验证一个 JWT
验证一个 token,不是把它 decode 一下看看内容对不对。
真正重要的是:
- 签名是否有效
- 是否过期
iss是否是预期签发者aud是否是预期接收方- 必要时还要检查
sub、nbf、jti
10.1 最小验证示例
import { jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function verifyAccessToken(token: string) {
const { payload, protectedHeader } = await jwtVerify(token, secret, {
issuer: "https://api.example.com",
audience: "my-app",
});
return {
payload,
protectedHeader,
};
}
jwtVerify() 成功时,说明:
- token 格式有效
- 签名对得上
- 没过期
iss和aud也通过了校验
11. 为什么"只解码,不验签"是严重错误
这是最危险也最常见的误用之一。
有些代码会这么写:
const payload = JSON.parse(
Buffer.from(token.split(".")[1], "base64").toString("utf8")
);
或者只是调用某些库的 decode 功能,然后直接相信里面的数据。
这相当于什么?
相当于你收到一张任何人都能自己打印的通行证,只看上面写了什么,却不检查是不是官方签发的。
攻击者完全可以:
- 自己构造 payload
- 把
role改成admin - 把
sub改成别人的用户 id
如果服务端只是 decode 而不验签,那整套权限模型就是空的。
所以必须记住:
decode 只能用于调试和观察内容,不能用于信任身份。
真正用于鉴权的一定是 verify。
12. 一个 Express 鉴权中间件怎么写
把前面的验证逻辑接到 HTTP 请求里,最常见的就是 Bearer Token。
客户端请求头通常长这样:
Authorization: Bearer <token>
12.1 中间件示例
import type { Request, Response, NextFunction } from "express";
import { jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export type AuthenticatedUser = {
id: string;
role?: string;
};
declare global {
namespace Express {
interface Request {
user?: AuthenticatedUser;
}
}
}
export async function authMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ message: "未提供有效的认证信息" });
}
const token = authHeader.slice("Bearer ".length).trim();
const { payload } = await jwtVerify(token, secret, {
issuer: "https://api.example.com",
audience: "my-app",
});
req.user = {
id: String(payload.sub),
role: typeof payload.role === "string" ? payload.role : undefined,
};
next();
} catch (error) {
return res.status(401).json({ message: "token 无效或已过期" });
}
}
12.2 在路由里使用
import express from "express";
import { authMiddleware } from "./auth-middleware";
const app = express();
app.get("/me", authMiddleware, async (req, res) => {
return res.json({
id: req.user?.id,
role: req.user?.role,
});
});
这就是最小可用的 JWT 鉴权中间件。
13. jwtVerify() 到底帮你做了什么
这一点很多人没有细看。
jwtVerify() 不只是把 token 拆开。它实际上完成了好几层工作:
13.1 解析 token 结构
检查三段格式是否正确。
13.2 校验签名
确认 token 的签名确实由预期密钥生成。
这一步决定了你能不能相信 payload。
13.3 校验时间相关字段
包括是否过期、是否未到生效时间等。
13.4 校验上下文约束
比如:
- 发行者是不是对的
- 受众是不是对的
这也是为什么 jwtVerify() 比"自己拆字符串"高了不止一个层级。
14. Access Token 该设计多长有效期
这个没有唯一标准,但有一个很稳定的原则:
access token 尽量短。
常见思路是:
- 5 分钟
- 15 分钟
- 30 分钟
而不是几天、几个月。
原因很简单。
JWT 一旦发出去,如果没有额外撤销机制,它通常会一直有效到过期时间。
所以 access token 越长,一旦泄漏,攻击者可利用的窗口就越长。
这也是后面常常要引入 refresh token 的原因。
access token 负责短时访问,refresh token 负责续期。这个会在后面的安全加固篇单独展开。
15. JWT 里到底该放什么,不该放什么
这是设计 token 时最容易失控的一步。
15.1 适合放的内容
- 用户 id
- 角色
- 权限版本号
- 会话 id
- 组织 id
- 标准时间字段
iss、aud、jti
15.2 不适合放的内容
- 明文密码
- 手机号、身份证号等高敏感信息
- 完整用户对象
- 大量业务配置
- 很容易变动的数据
- 服务端本来就能实时查到的数据全集
15.3 为什么不要把完整用户对象放进去
因为这会带来几个问题:
- token 变长
- 每次请求都带着冗余数据
- 用户信息更新后,旧 token 里的内容会过时
- 暴露面更大
一个好用的经验是:
JWT 里放"识别和校验当前请求所需的最小事实"。
16. HS256 和 RS256 该怎么选
这一段很关键,因为它决定了你的密钥管理模型。
16.1 HS256:对称签名
特点:
- 签发和验证都用同一个 secret
- 实现简单
- 配置少
- 单服务场景很顺手
适合:
- 单体应用
- 小规模内部系统
- 只有一个后端服务负责签发和验证
- 密钥边界清晰,部署链路简单
注意点:
- 所有能验签的服务,也天然拥有签发能力
- secret 一旦泄漏,攻击者可以直接伪造 token
16.2 RS256:非对称签名
特点:
- 私钥签发
- 公钥验证
- 验证方不需要持有私钥
适合:
- 多服务架构
- API 网关 + 多业务服务
- 第三方需要验证你签发的 token
- 希望签发权和验证权分离
优势在于:
- 只有持有私钥的服务才能签发
- 其他服务只拿公钥就能验签
- 密钥职责边界更清楚
代价是:
- 配置更复杂
- 密钥管理更重
- 你要处理私钥、公钥、轮换等问题
17. 用 jose 写一个 RS256 例子
为了理解差异,先看一个最小示例。
17.1 生成密钥对
import { generateKeyPair } from "jose";
const { publicKey, privateKey } = await generateKeyPair("RS256");
这个写法适合演示。
实际生产环境里,密钥通常不会在进程启动时临时生成,而是来自:
- 安全密钥管理系统
- 配置中心
- 受控文件
- 云平台 KMS 或 Secret Manager
17.2 用私钥签发
import { SignJWT } from "jose";
export async function signAccessTokenWithRS256(
user: { id: string; role: string },
privateKey: CryptoKey
) {
return await new SignJWT({ role: user.role })
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setSubject(user.id)
.setIssuer("https://api.example.com")
.setAudience("my-app")
.setIssuedAt()
.setExpirationTime("15m")
.setJti(crypto.randomUUID())
.sign(privateKey);
}
17.3 用公钥验证
import { jwtVerify } from "jose";
export async function verifyAccessTokenWithRS256(
token: string,
publicKey: CryptoKey
) {
const { payload } = await jwtVerify(token, publicKey, {
issuer: "https://api.example.com",
audience: "my-app",
});
return payload;
}
这里最重要的区别是:
- 能签发的人很少,只能持有私钥
- 能验证的人可以很多,只需要公钥
18. 生产里不要把 secret 写死在代码里
很多示例代码会直接写:
const secret = new TextEncoder().encode("my-super-secret");
演示可以,生产不行。
正确方向应该是:
const secretValue = process.env.JWT_SECRET;
if (!secretValue) {
throw new Error("JWT_SECRET is missing");
}
const secret = new TextEncoder().encode(secretValue);
原因很简单:
- 密钥不能进代码仓库
- 密钥不能和业务代码生命周期强绑定
- 不同环境需要不同密钥
- 后面你还要做轮换
19. 密钥要多强才算够
对于 HS256 这类共享 secret 的场景,密钥强度非常重要。
不要使用这类值:
"123456""secret""jwt-secret""myapp"
这些太弱了。
更好的方向是:
- 使用足够长的随机字符串
- 来自密码学安全随机源
- 不在多个环境复用
- 不在多个项目复用
JWT 是否安全,算法只是表面的一层。
真正决定上限的是密钥质量和密钥管理方式。
20. 什么是 kid,为什么它和密钥轮换有关
kid 是 key id,放在 JWT header 里,用来标记"这是哪把密钥签出来的"。
例如:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2025-01"
}
它的作用是:
- 当系统里有多把密钥时,验证方能知道该用哪把
- 做密钥轮换时,不需要立刻让所有旧 token 失效
典型轮换流程是:
- 新 token 用新密钥签发
- 验证方同时信任新旧公钥或 secret 一段时间
- 等旧 token 自然过期后,再移除旧密钥
没有 kid 的系统也能轮换,但会更笨重,尤其在多密钥并存时。
21. 验证 token 时,不要只看过期时间
很多人觉得验签只要看两件事:
- 签名对不对
- 是否过期
这还不够。
至少还要考虑:
21.1 iss
确认这个 token 真的是你预期的签发者发的。
否则别的系统签出来的 token,理论上也可能误入你的验证流程。
21.2 aud
确认这个 token 是发给当前系统的。
否则一个给 A 系统用的 token,可能被 B 系统误接收。
21.3 sub
有些接口里,你可能还需要确认 sub 是否存在、格式是否正确。
21.4 jti
如果系统支持撤销机制,验证后还需要检查 jti 有没有进黑名单,或者对应 session 是否还有效。
所以真正的 token 验证,往往是:
- 密码学层的验证
- 协议字段层的验证
- 业务上下文层的验证
三层一起完成。
22. 常见错误一:把权限完全信任给 token
有些系统会把所有权限信息都塞进 token,然后每次请求都只看 token 里的 role 或 permissions。
这有两个问题。
22.1 权限可能已经变了
比如某个管理员被降权了,但旧 token 还没过期。
如果系统只信 token,不查任何后端状态,那么旧权限会持续到 token 过期。
22.2 细粒度授权通常不止看角色
很多权限判断其实是资源级别的,比如:
- 只能看自己的订单
- 只能编辑自己组织下的项目
- 只有资源 owner 能删除文件
这些都不是一个 role=admin 能概括的。
更稳妥的方式是:
- token 里放粗粒度身份事实
- 具体操作权限在服务端按资源上下文继续判断
JWT 解决的是"你是谁",不是所有业务里的"你能做什么"。
23. 常见错误二:token 生命周期过长
例如把 access token 设置成:
- 7 天
- 30 天
- 永不过期
这通常都不理想。
原因是:
- 一旦 token 泄漏,风险窗口很长
- 无状态 token 天生不容易撤销
- 你会越来越依赖"它别泄漏",而不是假设"总会有泄漏场景"
短 access token + 后续续期机制,通常是更稳的思路。
24. 常见错误三:用同一个 secret 到处复用
比如:
- 开发环境和生产环境同一个 secret
- 多个系统共享同一个 secret
- 多年不轮换
这会导致:
- 泄漏影响范围扩大
- 环境隔离失效
- 排查和迁移成本升高
比较健康的实践是:
- 环境隔离
- 项目隔离
- 定期轮换
- 密钥权限最小化
25. 常见错误四:不处理时钟偏差
分布式系统里,不同机器的时间不一定完全一致。
当你严格校验 iat、nbf、exp 时,少量时钟偏差可能导致边界问题。
jose 支持相关时间校验选项。
工程上常见做法是保持机器时间同步,并在必要时设置少量 clockTolerance。
但这个容忍值不能太大。
它是为了吸收少量时钟误差,不是为了放松安全边界。
26. 常见错误五:把 JWT 当数据库
很多团队在设计上会慢慢滑向一种模式:
- 用户资料全塞 token
- 权限全塞 token
- 当前组织、当前项目、设置项、展示偏好全塞 token
最后 token 变得越来越大,状态越来越难管理。
JWT 不是数据库,也不是缓存全集。
它更像是一张经过签名的、短生命周期的身份声明。
越克制,后面越轻松。
27. 一个完整的登录后签发示例
把上一篇的密码校验和这一篇的 token 签发串起来,大概会长这样。
import bcrypt from "bcryptjs";
import { SignJWT } from "jose";
type UserRecord = {
id: string;
email: string;
passwordHash: string;
role: string;
};
const users: UserRecord[] = [];
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function login(email: string, password: string) {
const user = users.find((u) => u.email === email);
if (!user) {
throw new Error("INVALID_CREDENTIALS");
}
const matched = await bcrypt.compare(password, user.passwordHash);
if (!matched) {
throw new Error("INVALID_CREDENTIALS");
}
const accessToken = await new SignJWT({ role: user.role })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(user.id)
.setIssuer("https://api.example.com")
.setAudience("my-app")
.setIssuedAt()
.setExpirationTime("15m")
.setJti(crypto.randomUUID())
.sign(secret);
return {
accessToken,
user: {
id: user.id,
email: user.email,
role: user.role,
},
};
}
这个流程里:
bcryptjs负责确认密码正确jose负责签发访问凭证
两者职责非常清楚。
28. 一个完整的受保护接口示例
import express from "express";
import { jwtVerify } from "jose";
const app = express();
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
app.get("/me", async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ message: "未登录" });
}
const token = authHeader.slice("Bearer ".length).trim();
const { payload } = await jwtVerify(token, secret, {
issuer: "https://api.example.com",
audience: "my-app",
});
return res.status(200).json({
id: payload.sub,
role: payload.role,
});
} catch (error) {
return res.status(401).json({ message: "token 无效或已过期" });
}
});
这个例子很小,但已经体现了 JWT 鉴权最关键的几件事:
- 从请求头读取 token
- 用
jwtVerify()验证 - 检查
iss和aud - 成功后再信任 payload 里的身份信息
29. 这一篇最该记住的几件事
把重点压缩一下,就是下面这些。
29.1 JWT 是身份声明,不是秘密容器
默认情况下,payload 可以被看到。
不要往里放敏感数据。
29.2 jose 最关键的能力是签发和验证
SignJWT用来签发jwtVerify用来验证
29.3 decode 不等于 verify
能看到内容,不等于可以信任内容。
鉴权必须走验签。
29.4 验证时不只看签名和过期
还要检查:
issaud- 业务上需要的其他约束
29.5 HS256 和 RS256 的差别,本质是密钥模型差别
- HS256:简单,共享 secret
- RS256:签发和验证分离,更适合多服务
29.6 JWT 不是完整会话管理方案
它不自动解决:
- 注销
- 撤销
- refresh token
- 多设备会话
- 黑名单
- 权限实时变化
这些会在后面的文章里继续展开。
30. 下一篇该进入哪里
到这里,系列里的两块核心积木已经齐了:
bcryptjs负责密码校验jose负责 token 签发与验证
下一篇就该把它们真正拼起来,写一个完整可跑的 Node.js 示例项目:
- 用户注册
- 用户登录
- 签发 access token
- 鉴权中间件
/me受保护接口- 基础角色权限控制
那一篇开始,整个"注册 -> 登录 -> 带 token 访问接口"的链路会第一次完整落地。
后记
2026年5月7日于上海,在claude opus 4.6辅助下完成。
更多推荐


所有评论(0)