你的JWT实现,我敢说80%的后端都踩过这些坑
先说个真实故事。我认识一个创业公司的后端哥们儿,有天兴冲冲地跟我说,他们终于把登录系统从Session切换到了JWT,"无状态、可扩展、贼爽"。我问他Refresh Token怎么处理的,他说"存Redis里,每次请求验证一下就行了"。我又问Token过期了怎么办,他说"那就让用户重新登录呗"。
我当时就沉默了。不是因为感动,是因为我知道他们的用户每次操作到第59分钟(他们的Token有效期)就会被迫重新登录。用户骂声一片,产品经理头发掉光,而这个哥们儿还在纳闷:JWT不是号称"业界标准"吗?
JWT的坑,比你想象的多得多。今天咱们就来好好聊聊,那些年我们一起踩过的JWT坑。
坑一:把JWT当加密货币用——以为它安全其实裸奔
先问个基础问题:JWT的payload是加密的吗?
很多人以为JWT是加密的,把敏感信息往里一塞就觉得高枕无忧。错!大错特错!
JWT分为三部分:Header、Payload、Signature。前面两个部分只是Base64编码,谁都能解码。你往payload里塞的用户ID、角色、邮箱,别人拿到Token之后直接Base64解码就一览无余。
// 这是一个正常的JWT Payload
{
"sub": "1234567890",
"name": "张三",
"role": "admin",
"email": "zhangsan@company.com"
}
// 用Python解码只需一行
// import base64
// payload = base64.urlsafe_b64decode(token.split(".")[1] + "==")
// 什么都能看到,包括admin角色!
所以,永远不要在JWT的Payload里放敏感信息。用户ID可以放(反正别人也能猜到),但角色权限、邮箱、真实姓名这些,请走别的通道。
更恐怖的是,很多人把Password Hash也塞进JWT里。我见过一个项目,Token被人抓包之后,直接就能看到用户密码的MD5值。虽然是Hash不是明文,但这个信息泄露本身就是灾难。
坑二:从不验证Signature——等于在马路上裸奔
JWT的Signature是用来防篡改的。但我见过太多项目,压根不验证Signature。
什么场景?测试环境为了方便,把验证逻辑注释掉了,然后上线的时候...忘了打开。或者"我们内网服务,不需要验证"。内网服务的API被人非法调用的事故多了去了。
正确的做法是:永远验证Signature,永远。
package auth
import (
"errors"
"github.com/golang-jwt/jwt/v5"
)
var jwtSecret = []byte(os.Getenv("JWT_SECRET")) // 换个环境变量行不行?
func ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// 关键:一定要验证签名算法!
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil // 只有在这里返回才说明验证通过
}
return nil, errors.New("invalid token")
}
特别注意那个签名算法验证。没有这一行,攻击者可以构造一个alg: none的Token,直接绕过签名验证。这就是著名的"alg: none"攻击,十年前就有的漏洞,到现在还有人中招。
坑三:Token永不过期——给自己埋的定时炸弹
这是最普遍的问题,没有之一。
很多后端的逻辑是:Token有效期设长一点(比如7天),用户不用频繁登录,体验好。这逻辑听起来没问题,但经不起推敲。
问题在哪?Token一旦泄露,在这7天内攻击者都有合法访问权限。用户改密码没用、账户冻结没用,攻击者拿着这个Token就是铁证。
正确的做法是:Access Token短期化 + Refresh Token定期轮换。
// Access Token:短期(15分钟-1小时)
// Refresh Token:长期(7-30天),但必须可撤销
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` // 秒,比如3600
}
func GenerateTokens(userID string) (*TokenPair, error) {
// Access Token:短有效期,包含必要信息
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
// Refresh Token:长有效期,但存Redis用于撤销
refreshTokenID := uuid.New().String()
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"jti": refreshTokenID, // Unique ID,用于撤销
"user_id": userID,
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(),
})
// Refresh Token存Redis,设置7天过期时间
err := redis.Set(ctx, "refresh_token:"+refreshTokenID, userID, 7*24*time.Hour).Err()
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: mustSign(accessToken),
RefreshToken: mustSign(refreshToken),
ExpiresIn: 3600,
}, nil
}
为什么要存Redis?因为要支持Token撤销。用户改密码、账户被盗、主动注销——这些场景都需要能让旧的Refresh Token立即失效。Redis的O(1)查找速度完美胜任这个场景。
坑四:Refresh Token只存不换——裸奔的平方
有些团队确实用了Refresh Token,但只用来延长Session,用完旧的就不管了。这其实是个巨大的安全隐患。
Refresh Token的精髓在于轮换(Rotation)。每次用Refresh Token换新的Access Token时,都应该生成一个新的Refresh Token,而旧的立即作废。
func RefreshTokens(refreshTokenString string) (*TokenPair, error) {
// 1. 验证Refresh Token本身的有效性
refreshToken, err := ValidateRefreshToken(refreshTokenString)
if err != nil {
return nil, errors.New("invalid refresh token")
}
claims := refreshToken.Claims.(jwt.MapClaims)
tokenID := claims["jti"].(string)
// 2. 检查Refresh Token是否在Redis黑名单里(已被撤销)
exists, _ := redis.Exists(ctx, "revoked_token:"+tokenID).Result()
if exists > 0 {
// 如果这个Token被撤销了,说明可能被盗用了!
// 安全起见,把这个用户的所有Token都撤销
userID := claims["user_id"].(string)
revokeAllUserTokens(userID)
return nil, errors.New("token reuse detected - security incident")
}
// 3. 撤销旧的Refresh Token(这是关键!)
redis.Set(ctx, "revoked_token:"+tokenID, "1", 24*time.Hour)
// 4. 生成新的Token对
userID := claims["user_id"].(string)
return GenerateTokens(userID)
}
注意那个注释里的"Token reuse detected"。这是大名鼎鼎的Token Replay Detection。如果攻击者偷到了Refresh Token并使用了它,正版用户再次使用同一个Refresh Token时,系统就会发现"这个Token已经被用过了"——这意味着有人在盗用!系统就会触发安全警报并撤销该用户的所有Token。
坑五:在URL里传Token——把你的秘密写在额头上
HTTP的URL是明文传输的。Git服务器会记录URL日志、CDN会记录Referer、浏览器会记录历史记录、服务器日志会记录完整的URL。如果你在URL里传JWT Token...
你的Token就这样被写进了无数个日志文件里。然后某天,这些日志被备份到一个任何人能访问的S3 Bucket,或者被开发人员不小心commit到GitHub...
永远用Header传Token:
// 错误示范(写在URL里)
GET /api/v1/users?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// 正确示范(写在Header里)
GET /api/v1/users HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Authorization: Bearer是标准格式,别自创什么X-Token、token之类的Header名字。标准的意义在于:别人看到就知道怎么处理,你自定义的Header谁知道会不会被哪个中间件不小心放行了。
坑六:签发者和受众不校验——让伪造变得容易
JWT有两个字段:iss(Issuer,签发者)和aud(Audience,受众)。这两个字段不是可选项,是安全加固的必备项。
如果不校验iss和aud,攻击者可以拿着从A系统签发的Token去访问B系统。比如你的微服务有5个模块,用户在模块A登录拿到的Token,如果模块B不校验aud,理论上也能用。
// 校验Issuer和Audience
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
// 手动校验iss和aud
claims := token.Claims.(jwt.MapClaims)
// 校验签发者
if claims["iss"] != "your-service-name" {
return nil, errors.New("invalid issuer")
}
// 校验受众
aud, ok := claims["aud"].(string)
if !ok || aud != "your-api" {
return nil, errors.New("invalid audience")
}
return jwtSecret, nil
})
这两个字段看起来不起眼,但在大规模微服务架构里,它们是防御的第一道关卡。想象一下,如果攻击者拿到某个边缘服务的Token合成工具,能伪造任意用户的Token去访问核心服务...有iss/aud校验的话,至少这道门是锁着的。
实践清单:你的JWT还活着吗?
来,做个快速自查:
- Payload里有没有敏感信息?(密码、邮箱、角色权限)
- Signature验证了吗?(alg: none攻击了解一下)
- Token有效期设了多久?(超过24小时就是危险操作)
- 有Refresh Token吗?(还是让用户天天重新登录)
- Refresh Token支持轮换和撤销吗?(还是一劳永逸的思想)
- Token放哪儿了?(URL还是Header)
- 签发者和受众校验了吗?(还是只要签名对就放行)
如果你有任何一项的回答是"没有"或"不知道",建议你现在就拿起键盘开始改。
最后说两句
JWT是个好技术,但它不是银弹。它解决了一部分问题,同时也引入了新的问题。用JWT的项目,十个里有八个在不同的地方踩坑。区别只在于:有些人踩了还不知道,有些人踩了之后骂骂咧咧地改,有些人——像我那个朋友一样——直到用户流失了才反应过来。
技术债务这种东西,早还早轻松。拖到线上事故,就不是改代码能解决的了。
行了,今天的吐槽就到这里。回头见。