4、令牌签发:生成令牌的最佳实践,签名算法选择(HS256 vs RS256)
令牌签发这一步,说白了就是把用户身份「封印」进一个字符串里。封印得好不好,直接决定了系统的安全底线。我见过不少团队在签名算法上踩坑,今天咱们就把这事聊透。
4.1 生成令牌的核心原则
令牌不是随便拼个字符串就完事的。我个人习惯遵循三个原则:
- 不可伪造:没有密钥的人无法生成合法令牌
- 不可篡改:令牌内容一旦被改,签名校验必须失败
- 可验证:接收方能快速确认令牌是否由信任方签发
嗯,这三点听起来简单,但实现起来细节很多。我曾经在一个项目里看到有人直接用 Base64 编码用户信息当令牌——那跟把密码写在便利贴上贴电脑屏幕没区别。
4.2 签名算法:HS256 与 RS256 的抉择
这是令牌签发里最核心的决策点。HS256 和 RS256 都是 JWT 标准里定义的签名算法,但它们的数学原理和安全模型完全不同。
| 对比维度 | HS256 (HMAC-SHA256) | RS256 (RSA-SHA256) |
|---|---|---|
| 算法类型 | 对称签名 | 非对称签名 |
| 密钥数量 | 1 个共享密钥 | 私钥签名 + 公钥验证 |
| 签名速度 | 快(微秒级) | 慢(毫秒级) |
| 验证速度 | 快(微秒级) | 快(微秒级) |
| 密钥分发 | 需安全通道传输 | 公钥可公开分发 |
| 适用场景 | 单体应用、内部服务 | 微服务、第三方集成 |
核心结论:HS256 适合单一信任域,RS256 适合多信任域。选错了,后面改起来很痛苦。
4.2.1 HS256:简单但危险
HS256 用同一个密钥既签名又验证。你想想看,这意味着所有需要验证令牌的服务都得持有这个密钥。一旦密钥泄露,攻击者就能签发任意令牌。
我在项目中遇到过这样的情况:一个微服务架构,所有服务共享同一个 HS256 密钥。后来有个边缘服务被攻破,密钥被偷,整个系统的令牌信任体系瞬间崩塌。那次事故之后,我们花了整整两周重建所有令牌。
避坑指南:HS256 的密钥必须足够复杂(至少 256 位随机字节),且不能硬编码在代码里。我曾经见过有人把密钥写在配置文件里提交到 Git——那简直是灾难。
4.2.2 RS256:灵活但慢
RS256 用私钥签名,公钥验证。私钥只有签发服务持有,其他服务只需要公钥就能验证令牌。公钥泄露了也没事,它只能验证,不能签名。
说白了,RS256 把「签发」和「验证」两个角色彻底分开了。这在微服务架构里特别有用——你可以让认证中心统一签发,所有业务服务只验证。
不过 RS256 也有代价:签名速度比 HS256 慢一个数量级。如果你的系统每秒签发几万个令牌,RS256 可能会成为瓶颈。我记得有个高并发项目,一开始用 RS256,后来发现签名耗时太长,不得不引入缓存和预签发机制才扛住。
4.3 令牌内容的最佳实践
令牌里放什么、不放什么,这也有讲究。我总结了几条铁律:
- 放最少必要信息:只放用户 ID、角色、过期时间等核心字段
- 不放密码:这个不用解释吧?
- 不放敏感数据:身份证号、手机号、银行卡号——这些都不该进令牌
- 控制令牌大小:令牌每次请求都会携带,太大影响性能
个人经验:我习惯在令牌里放一个 jti(JWT ID)字段,用于令牌撤销和日志追踪。这个字段用 UUID 生成,全局唯一,排查问题时特别好用。
4.4 代码示例:HS256 签发令牌
// Node.js 示例:使用 HS256 签发令牌
const jwt = require('jsonwebtoken');
const secret = process.env.JWT_SECRET; // 从环境变量读取,绝不硬编码
const payload = {
sub: 'user_123456',
role: 'admin',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600, // 1小时过期
jti: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
};
const = jwt.sign(payload, secret, { algorithm: 'HS256' });
console.log('签发令牌:', );
4.5 代码示例:RS256 签发令牌
// Node.js 示例:使用 RS256 签发令牌
const jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('./private.pem', 'utf8'); // 私钥安全存储
const publicKey = fs.readFileSync('./public.pem', 'utf8'); // 公钥可分发
const payload = {
sub: 'user_123456',
role: 'admin',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
jti: 'b2c3d4e5-f6a7-8901-bcde-f12345678901'
};
const = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
console.log('签发令牌:', );
// 验证令牌(使用公钥)
const decoded = jwt.verify(, publicKey, { algorithms: ['RS256'] });
console.log('验证通过,用户ID:', decoded.sub);
4.6 令牌签发流程总览
下面这张图展示了令牌签发的完整流程,从用户认证到令牌返回:
4.7 签名算法选型决策树
如果你还在纠结选哪个,我建议按这个思路来:
- 你的系统是单体应用吗? → 选 HS256,简单够用
- 你的系统是微服务架构吗? → 选 RS256,方便密钥管理
- 你需要第三方验证令牌吗? → 必须 RS256,公钥可以公开
- 你的令牌签发量极大吗? → 考虑 HS256 + 定期轮换密钥
我的建议:除非你有非常明确的性能瓶颈,否则优先选 RS256。它带来的安全收益远大于那点性能损耗。我在生产环境里用 RS256 跑了三年,从来没出过签名相关的事故。
4.8 常见错误与避坑
- 密钥硬编码:永远从环境变量或密钥管理服务读取
- 令牌不过期:必须设置
exp字段,建议不超过 24 小时 - 算法混淆攻击:验证时明确指定算法,不要接受
alg: none - 令牌太大:避免在 payload 里放过多自定义字段
我曾经踩过的坑:有一次我忘了在验证时指定算法,结果攻击者把令牌的 alg 改成 none,直接绕过了签名验证。从那以后,我每次写验证代码都会加上 algorithms: ['RS256'] 这个参数。
令牌签发这事,说难不难,说简单也不简单。选对算法、管好密钥、控制内容,这三件事做好了,你的令牌体系就稳了八成。剩下的两成,咱们后面几章慢慢聊。