先说结论
如果你今天要在 Node 18+ 上选一个 JWT 库,我的答案是 jose。理由有三条:
- 纯异步,不阻塞事件循环。
jsonwebtoken 的 jwt.sign() 是同步的,在大量请求场景下会拖慢整个进程。jose 全异步。
- 零运行时依赖。
jose 只用 Web Crypto API(浏览器和 Node 18+ 都有),不需要 native 模块、不需要编译。
- 现代场景友好。密钥轮换、JWK/JWKS 支持、ES256 等椭圆曲线算法,都是生产必需但
jsonwebtoken 支持得不好。
如果你的环境是 Node 14 及以下,或者你需要同步 API,那 jsonwebtoken 仍是合理选择。但 Node 18 已经 EOL 很久了,新项目应该用 jose。
为什么 jose 值得看一眼
JWT 在 Node 生态里老牌选手是 jsonwebtoken——文档多、Stack Overflow 答案多、用过的人多。但它有几个让人不太舒服的地方:
同步签名。jwt.sign() 是同步方法。在 Node 单线程模型下,任何同步的重计算都会卡事件循环。一秒 1000 个登录请求,每个签名 1ms,事件循环就被卡 1 秒——其他请求全排队。
依赖 native 模块。jsonwebtoken 底层用了 C++ 模块,部署时需要编译。在 Docker 镜像、CI、Serverless 环境里都会带来麻烦。
JWKS 不支持。生产系统经常需要密钥轮换和自动公钥分发,jsonwebtoken 对此支持有限。
jose 把这些都解决了——基于 Web Crypto API,纯 JS,全异步,原生支持 JWKS。名字也起得巧:JOSE 是 IETF 的 RFC 前缀(JSON Object Signing and Encryption),库名就是规范名。
安装
一个命令,没有依赖。package.json 里只多了一行。
快速上手
签发 token
1 2 3 4 5 6 7 8 9
| import { SignJWT } from 'jose';
const secret = new TextEncoder().encode('your-secret-key');
const token = await new SignJWT({ sub: 'alice', role: 'user' }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('2h') .sign(secret);
|
注意是 await——所有方法都是异步的。这是 jose 的核心设计。
验证 token
1 2 3 4
| import { jwtVerify } from 'jose';
const { payload } = await jwtVerify(token, secret);
|
验证失败会抛异常,调用方用 try/catch 处理。
解码(不验证)
1 2 3
| import { decodeJwt } from 'jose';
const payload = decodeJwt(token);
|
decodeJwt 只是 Base64 解码,不做签名验证——别在生产环境用它当验证用。
为什么是全异步
这是 jose 最大的卖点,也是最容易被忽略的设计。
问题:同步签名会卡事件循环
Node 是单线程事件循环。任何同步的重计算都会卡住整个进程。jsonwebtoken 的 jwt.sign() 是同步的,在大量请求场景下:
1 2 3 4 5 6 7
| app.post('/login', (req, res) => { const token = jwt.sign(payload, secret, { expiresIn: '2h' }); res.json({ token }); });
|
如果一秒来 1000 个登录请求,每个签名要 1ms,那事件循环会被卡 1 秒——其他请求全排队。
jose 的解决方案
1 2 3 4 5 6 7
| app.post('/login', async (req, res) => { const token = await new SignJWT(payload) .setExpirationTime('2h') .sign(secret); res.json({ token }); });
|
await 让出事件循环,其他请求可以并行处理。1000 个登录请求不会互相阻塞。
但 jose 也提供同步方法(慎用)
1 2 3 4 5 6
| import { SignJWT } from 'jose';
const token = new SignJWT(payload) .setExpirationTime('2h') .signSync(secret);
|
signSync 和 jwtVerify 的同步版本是有的,但官方明确警告”会在事件循环里做同步计算,可能拖慢进程”。生产代码别用。
三种签名方式
jose 支持三种签发方式,分别用于不同场景。
1. 紧凑格式(默认)
1 2 3 4 5 6
| import { SignJWT } from 'jose';
const token = await new SignJWT({ sub: 'alice' }) .setProtectedHeader({ alg: 'HS256' }) .sign(secret);
|
这就是我们熟悉的三段式 JWT。
2. JSON 序列化格式
1 2 3 4
| const token = await new SignJWT({ sub: 'alice' }) .setProtectedHeader({ alg: 'HS256' }) .signJSON(secret);
|
格式是人类可读的 JSON,便于日志打印。但体积更大,传输效率低。
3. 带额外签名的格式
1 2 3 4 5 6
| const result = await new SignJWT({ sub: 'alice' }) .setProtectedHeader({ alg: 'HS256' }) .signWithSecret(secret, { additionalSignedHeaders: { kid: 'key-2026-01' } });
|
主要用于密钥轮换场景——在 header 里标记用了哪个密钥。
算法选择:HS256 还是 RS256
这是 jose 最容易讲错的地方。
HS256:对称加密,教学和原型用
1 2 3 4 5 6 7 8 9 10 11
| import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode('your-secret');
const token = await new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .sign(secret);
await jwtVerify(token, secret);
|
同一个密钥签发和验证。简单,但有一个致命问题:
任何拿到这个密钥的服务都能签 token。
如果 auth 服务和 hello 服务共享同一个密钥,hello 被攻陷 = auth 可以被伪造 token。在生产系统里这是不可接受的。
RS256:非对称加密,生产推荐
1 2 3 4 5 6 7 8 9 10 11 12
| import { SignJWT, jwtVerify, exportKey } from 'jose';
const { publicKey, privateKey } = await generateKeyPair('RS256');
const token = await new SignJWT(payload) .setProtectedHeader({ alg: 'RS256' }) .sign(privateKey);
await jwtVerify(token, publicKey);
|
私钥只在 auth 服务里,其他服务只拿公钥验证。即使 hello 被攻陷,攻击者也拿不到私钥,签不出有效 token。
ES256:椭圆曲线,更快更短
1 2 3 4 5
| const { publicKey, privateKey } = await generateKeyPair('ES256');
const token = await new SignJWT(payload) .setProtectedHeader({ alg: 'ES256' }) .sign(privateKey);
|
签名比 RS256 短,速度快,是 WebAuthn 的标准算法。新项目推荐 ES256。
算法对比
| 算法 |
密钥类型 |
签名长度 |
速度 |
推荐场景 |
| HS256 |
对称 |
32 字节 |
快 |
教学、原型、单服务 |
| RS256 |
非对称 |
256 字节 |
中 |
多服务、生产 |
| ES256 |
非对称 |
64 字节 |
快 |
新项目、移动端 |
| EdDSA |
非对称 |
64 字节 |
最快 |
高性能、WebAuthn |
经验法则:单服务/教学用 HS256,多服务生产用 RS256 或 ES256。
密钥管理:jose 的杀手特性
1. 从环境变量加载密钥
1 2 3 4 5 6 7 8 9 10 11 12
| import { importKey } from 'jose';
const privateKeyPem = process.env.JWT_PRIVATE_KEY; const privateKey = await importKey(privateKeyPem, 'RS256');
const secretB64 = process.env.JWT_SECRET; const secret = await importKey( new TextEncoder().encode(secretB64), 'HS256' );
|
2. JWKS(JSON Web Key Set)
这是 jose 最强大的特性之一。
场景:你有 10 个服务,每个都要验证 JWT。怎么把公钥分发给 10 个服务?
JWKS 方案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| app.get('/.well-known/jwks.json', async (req, res) => { const keys = await getPublicKeys(); res.json({ keys }); });
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
app.use(async (req, res) => { const { payload } = await jwtVerify(req.token, JWKS); req.user = payload.sub; });
|
好处:
- 公钥自动更新——auth 服务换了密钥,JWKS 端点返回新的
- 10 个服务不用手动同步密钥
- 标准协议(RFC 7517),所有 JWT 库都支持
3. 密钥轮换
生产系统需要定期换密钥。jose 支持多密钥验证:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { importKey } from 'jose';
const currentKey = await importKey(process.env.CURRENT_KEY, 'RS256'); const previousKey = await importKey(process.env.PREVIOUS_KEY, 'RS256');
async function verifyWithRotation(token) { try { return await jwtVerify(token, currentKey); } catch { return await jwtVerify(token, previousKey); } }
|
更优雅的方案是用 JWKS——auth 服务在 JWKS 里同时返回新旧公钥,客户端自动按 kid 选择。
4. 用密钥标识符(kid)
1 2 3 4 5
| import { SignJWT } from 'jose';
const token = await new SignJWT(payload) .setProtectedHeader({ alg: 'ES256', kid: '2026-09' }) .sign(privateKey);
|
kid(Key ID)告诉验证方”用哪个密钥验我”。JWKS 里每个密钥都有对应的 kid,验证方按 kid 找到对应公钥。
一个多服务场景
假设你在做一个多服务系统:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Web 端 │ │ iOS 端 │ │ Admin │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ └───────┬───────┴───────────────┘ │ ┌─────┴─────┐ │ Auth │ ← 唯一持有私钥 │ Service │ └─────┬─────┘ │ ┌────────┼────────┐ │ │ │ ┌───┴───┐ ┌──┴───┐ ┌──┴───┐ │ User │ │ Order│ │ Notify│ ← 都持有公钥 │ Svc │ │ Svc │ │ Svc │ └───────┘ └──────┘ └──────┘
|
部署步骤:
- Auth 服务生成 ES256 密钥对
- Auth 服务把私钥存进 KMS(AWS Secrets Manager 等),永远不进代码
- Auth 服务暴露
/.well-known/jwks.json 端点,返回公钥
- User / Order / Notify 服务启动时拉 JWKS,缓存公钥
- 客户端登录 Auth 拿到 token
- 客户端调 User 服务,带 token
- User 服务用缓存的公钥验 token,不需要问 Auth
轮换密钥时:
- Auth 生成新密钥对
- JWKS 端点同时返回新旧公钥(带不同
kid)
- 客户端用新密钥签新 token
- 旧 token 还能验(用旧公钥)
- 旧 token 全部过期后,从 JWKS 移除旧公钥
整个过程不影响任何客户端——这是 JWKS + 密钥轮换的核心价值。
jose 的常见坑
坑 1:把 decodeJwt 当验证用
1 2 3 4 5 6
| const payload = decodeJwt(token); if (payload.sub === 'alice') { }
await jwtVerify(token, secret);
|
decodeJwt 在调试时有用(看 token 内容),但永远不要在生产代码里用它当验证。
坑 2:忘记处理过期时间
1 2 3 4 5 6 7 8
| const token = await new SignJWT(payload) .sign(secret);
const token = await new SignJWT(payload) .setExpirationTime('15m') .sign(secret);
|
JWT 一旦签发无法撤销,只能等过期。15 分钟 + refresh token 是常见方案。
坑 3:用 HS256 在生产
生产系统用 RS256 或 ES256,私钥只在 auth 服务里。
坑 4:payload 里放敏感信息
1 2 3 4 5 6 7 8 9 10 11 12 13
|
const token = await new SignJWT({ email: 'alice@example.com', ssn: '123-45-6789', passwordHash: '...' }).sign(secret);
const token = await new SignJWT({ sub: 'alice', role: 'user' }).sign(secret);
|
payload 是 Base64 编码,不是加密。任何人都能 echo <token> | cut -d. -f2 | base64 -d 看到内容。
坑 5:Node 版本太低
1 2
| node -v v16.20.0 ← jose 需要 v18+
|
jose 用了 globalThis.crypto 和 Web Crypto API,这些是 Node 18 才稳定的。Node 16 及以下会报 crypto.subtle is not available。
坑 6:忘记 await
1 2 3 4 5 6 7
| const token = new SignJWT(payload).sign(secret);
console.log(token);
const token = await new SignJWT(payload).sign(secret);
|
jose 的 API 设计很一致——所有加密操作都是 Promise。这是它和 jsonwebtoken 最大的差异,也是新手最容易踩的坑。
和 jsonwebtoken 对比
| 维度 |
jose |
jsonwebtoken |
| 同步/异步 |
全异步 |
同步为主 |
| 依赖 |
零依赖 |
依赖 semver、native 模块 |
| Node 要求 |
v18+ |
v0.10+ |
| 浏览器支持 |
是(基于 Web Crypto) |
部分(需要 polyfill) |
| RS256/ES256 |
原生支持 |
部分支持 |
| JWKS |
原生支持 |
不支持 |
| 密钥轮换 |
多密钥验证 |
手动处理 |
| 性能 |
异步,不阻塞 |
同步,可能阻塞 |
| 文档 |
新,但完整 |
老,多示例 |
| 社区 |
增长中 |
成熟 |
什么时候选 jsonwebtoken:
- 项目是 Node 14 及以下
- 需要同步 API(比如命令行工具)
- 团队已经熟悉,迁移成本大
什么时候选 jose:
- 新项目
- Node 18+
- 多服务架构
- 需要 JWKS / 密钥轮换
- 想要现代 API 设计
完整示例:多服务 JWT 认证
把上面的知识串起来,一个生产可用的最小实现。
密钥生成(一次性)
1 2 3 4 5 6 7 8 9 10
| import { generateKeyPair, exportKey } from 'jose'; import { writeFile } from 'node:fs/promises';
const { publicKey, privateKey } = await generateKeyPair('ES256');
await writeFile('keys/public.pem', await exportKey(publicKey, 'spki', { format: 'pem' })); await writeFile('keys/private.pem', await exportKey(privateKey, 'pkcs8', { format: 'pem' }));
console.log('密钥已生成');
|
Auth 服务(签发 token)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| import { SignJWT, importKey, exportKey } from 'jose'; import { readFile } from 'node:fs/promises'; import express from 'express';
const app = express(); app.use(express.json());
const privateKeyPem = await readFile('keys/private.pem', 'utf8'); const privateKey = await importKey(privateKeyPem, 'ES256');
app.post('/login', async (req, res) => { const { username, password } = req.body;
const token = await new SignJWT({ sub: username, role: 'user' }) .setProtectedHeader({ alg: 'ES256', kid: '2026-09' }) .setIssuedAt() .setExpirationTime('15m') .sign(privateKey);
res.json({ token }); });
app.get('/.well-known/jwks.json', async (req, res) => { const publicKeyPem = await readFile('keys/public.pem', 'utf8'); const publicKey = await importKey(publicKeyPem, 'ES256'); const jwks = await exportKey(publicKey, 'jwk', { format: 'jwk' }); res.json({ keys: [{ ...jwks, kid: '2026-09' }] }); });
|
业务服务(验证 token)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import { jwtVerify, createRemoteJWKSet } from 'jose'; import express from 'express';
const app = express();
const JWKS = createRemoteJWKSet( new URL('https://auth.example.com/.well-known/jwks.json') );
app.use(async (req, res, next) => { const auth = req.headers.authorization; if (!auth?.startsWith('Bearer ')) return res.status(401).end();
const token = auth.slice(7); try { const { payload } = await jwtVerify(token, JWKS); req.user = payload.sub; req.role = payload.role; next(); } catch { res.status(401).end(); } });
|
客户端(调用业务服务)
1 2 3 4 5 6 7
| const token = (await login('alice')).token;
const res = await fetch('https://user.example.com/profile', { headers: { 'Authorization': `Bearer ${token}` } }); const profile = await res.json();
|
整个流程:
- 客户端登录 auth 服务,拿到 ES256 签发的 token
- 客户端调 user 服务,带 token
- User 服务从 JWKS 拉公钥,验证 token
- 验证通过,业务逻辑执行
没有任何服务知道私钥,除了 auth。其他服务只拿公钥。这是生产系统的标准模式。
还没讲的部分
JWT 只是认证的一半。还有几个话题这篇文章里没碰:
- Refresh Token:access token 15 分钟过期,怎么续期?
- OAuth 2.0 / OIDC:第三方登录怎么接入?
- Session vs JWT:什么时候用 session 反而更好?
- 密钥管理:KMS、Vault、Age 这些工具实际怎么用?
这些我都还没真正跑通过。等你写完了再回过头看 jose,可能会发现”哦原来这个 API 是为了那个场景设计的”——这种”先有结论再补知识”的学习路径,我觉得挺有效的。
参考