3、JWT安全实践:JWT签名算法选择、密钥管理、过期时间、刷新令牌机制
JWT(JSON Web Token)作为分布式鉴权的核心载体,其安全性直接决定了整个权限体系的可靠性。本章从签名算法、密钥管理、过期策略与刷新机制四个维度,系统讲解生产级JWT安全实践。
3.1 JWT签名算法选择
JWT支持多种签名算法,按类型可分为对称算法(HMAC)与非对称算法(RSA/ECDSA)。算法选择不当会导致令牌伪造或性能瓶颈。
| 算法 | 类型 | 密钥 | 性能 | 安全等级 | 推荐场景 |
|---|---|---|---|---|---|
| HS256 | 对称 | 单一密钥 | 高 | 中 | 内部服务间通信 |
| RS256 | 非对称 | 公私钥对 | 中 | 高 | 跨服务/跨组织鉴权 |
| ES256 | 非对称 | 公私钥对 | 高 | 高 | 移动端/物联网设备 |
| PS256 | 非对称 | 公私钥对 | 中 | 极高 | 高安全合规场景 |
安全警示:禁止使用
alg: "none" 或 alg: "HS256" 配合公钥验证,这是经典的 JWT 算法混淆攻击向量。服务端必须白名单校验 alg 字段。
3.2 密钥管理最佳实践
密钥是JWT安全的核心资产。密钥泄露意味着整个鉴权体系失效。以下是生产级密钥管理要点:
- 密钥长度:HS256至少256位(32字节),RS256至少2048位,ES256至少256位。
- 密钥轮换:定期更换密钥,保留旧密钥窗口期用于验证未过期令牌。
- 密钥存储:使用密钥管理服务(KMS/Vault),禁止硬编码或明文存储。
- 密钥隔离:不同环境(开发/测试/生产)使用独立密钥。
推荐方案:使用
kid(Key ID)字段标识密钥版本,服务端根据 kid 动态获取对应公钥,实现无缝密钥轮换。
// Go 语言 JWT 密钥轮换示例
type KeyManager struct {
keys map[string]*rsa.PublicKey
currentKid string
}
func (km *KeyManager) Verify(tokenString string) (*jwt.Token, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, errors.New("missing kid")
}
key, exists := km.keys[kid]
if !exists {
return nil, errors.New("unknown kid")
}
return key, nil
})
return token, err
}
3.3 过期时间设计策略
JWT的 exp(过期时间)声明是防止令牌长期有效被滥用的关键防线。过期时间需要平衡安全性与用户体验。
| 令牌类型 | 推荐过期时间 | 安全考量 |
|---|---|---|
| Access Token | 15分钟 - 1小时 | 短生命周期,减少泄露影响 |
| Refresh Token | 7天 - 30天 | 长生命周期,需配合存储验证 |
| 一次性令牌 | 5分钟 | 密码重置、邮箱验证等场景 |
常见陷阱:仅依赖客户端检查
exp 是不够的。服务端必须在每次请求时验证 exp、nbf(不早于)和 iat(签发时间)三个时间声明,防止篡改。
3.4 刷新令牌机制
刷新令牌(Refresh Token)机制解决了Access Token过期后用户需要重新登录的问题,同时保持了短生命周期令牌的安全性。
3.5 刷新令牌安全实现
刷新令牌的实现需要额外关注以下安全要点:
- 存储验证:服务端必须存储Refresh Token的哈希值,每次刷新时比对,防止令牌泄露后无限使用。
- 轮换策略:每次刷新时签发新的Refresh Token并废弃旧令牌,防止重放攻击。
- 撤销机制:维护黑名单或版本号,支持用户主动登出或管理员强制撤销。
- 设备绑定:将Refresh Token与设备指纹、IP地址绑定,异常时要求重新认证。
// Go 语言刷新令牌轮换实现
func (s *AuthService) RefreshToken(oldRefreshToken string) (*TokenPair, error) {
// 1. 验证旧Refresh Token
claims, err := s.validateRefreshToken(oldRefreshToken)
if err != nil {
return nil, ErrInvalidToken
}
// 2. 检查是否已被撤销
if s.isRevoked(claims.JTI) {
// 检测到重放攻击,撤销该用户所有令牌
s.revokeAllUserTokens(claims.UserID)
return nil, ErrTokenReused
}
// 3. 撤销旧令牌
s.revokeToken(claims.JTI)
// 4. 签发新令牌对
accessToken, err := s.generateAccessToken(claims.UserID)
if err != nil {
return nil, err
}
refreshToken, err := s.generateRefreshToken(claims.UserID)
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
}, nil
}
生产建议:Access Token 使用 JWT 自包含格式,无需服务端存储;Refresh Token 使用不透明字符串(随机生成),服务端存储其哈希值。这种混合架构兼顾了性能与安全性。