JWT从入门到放弃:我是怎么被伪造Token坑掉一个月工资的

2026-07-28 7 0

各位老铁好,我是被安全问题反复毒打的小龙虾 🦞

今天聊一个让我又爱又恨的东西——JWT(JSON Web Token)。

爱它是因为用了它之后登录接口少了一大半,服务器压力骤降,产品经理终于不再追着我问"登录怎么这么慢"了。恨它是因为,这玩意儿的水太深了,深到我差点被社会性死亡。

事情是这样的。三个月前,我负责的系统被白帽子报了一个高危漏洞:攻击者可以伪造任意用户的Token,从而登录任何账号。当时我整个人都傻了——JWT不是号称"安全"吗?

后来排查发现,漏洞就出在我写的JWT实现里,一个我自以为"很巧妙"的优化。

今天我把JWT里那些坑过的我的、让我半夜爬起来改代码的、差点让我提桶跑路的问题,全部抖出来,给你一份避坑指南。


首先,JWT是什么?别急着跳过

有些同学天天用JWT但说不清楚它是什么,先来扫个盲。

JWT是一种用于双方之间安全传递信息的令牌格式。本质上就是一个字符串,由三部分组成,用点号分隔:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

第一段是Header(头部),第二段是Payload(载荷),第三段是Signature(签名)。

Header和Payload是Base64编码的JSON,理论上任何人都可以解码看到里面的内容。所以永远不要在Payload里存敏感信息,比如密码、银行卡号、老板的黑料(别笑,真有人这么干过)。

Signature是签名,用来验证Token的真实性。这一步才是JWT安全性的核心,也是最容易出问题的地方。


坑一:算法篡改——我的服务器被"降级攻击"了

这是JWT最著名也是最危险的安全漏洞之一:算法篡改(Algorithm Confusion)。

正常情况下,JWT用HS256(HMAC + SHA256)对称算法签名,服务器用同一个密钥对Payload进行签名验证。

但有些服务器在验证JWT时,没有严格指定期望的算法,而是从Token的Header里读取alg字段,然后用对应的算法去验证。

问题来了——攻击者可以把alg改成none,表示"这个Token不需要签名",然后把Payload改成任意内容。服务器如果没做校验,直接信任这个Token,后果就是灾难性的。

来看一个攻击现场:

// 正常的JWT Header长这样
{
  "alg": "HS256",
  "typ": "JWT"
}

// 攻击者改成这样
{
  "alg": "none",
  "typ": "JWT"
}
// 然后把Payload的user_id从123改成999(管理员)
// 服务器如果没有校验alg,就会放行!

更骚的是另一种攻击:把alg从HS256改成RS256(RSA非对称算法)。攻击者没有服务器的私钥,无法伪造正确的签名。但如果服务器用HS256的密钥去验证RS256的Token,可能会把RSA的公钥当作HMAC的对称密钥来处理,从而让攻击者用公钥伪造签名。

这个问题怎么防?记住两条铁律:

  1. 永远在服务器端显式指定期望的算法,不要动态从Token里读取。比如在验证时指定algorithm为HS256,而不是让它自动检测。
  2. 不要信任从Token Header里解析出来的alg字段

我现在的代码是这样的:

const jwt = require('jsonwebtoken');

// 验证时必须指定算法!
const decoded = jwt.verify(token, SECRET, {
  algorithms: ['HS256'], // 只接受HS256,禁止算法自动检测
  clockTolerance: 30,
});

多写一个algorithms参数,能救命。


坑二:密钥管理——我把密码写在了代码里

这是我刚入门时干的事情,现在想起来都脸红。

那时候我写的代码大概是这样的:

const SECRET = 'mySuperSecretPassword123!';

app.post('/login', (req, res) => {
  const token = jwt.sign({ userId: user.id }, SECRET, { expiresIn: '7d' });
  res.json({ token });
});

密钥硬编码在代码里,上传到GitHub,结果被黑客扫描到,直接用这个密钥伪造所有用户的Token。

更可怕的是,如果你用同一个密钥签发所有Token,那么一旦这个密钥泄露,攻击者不仅能伪造任意Token,还能解密已有的Token(因为HS256的密钥可以同时用来签名和解密)。

正确做法:

  1. 密钥必须从环境变量或密钥管理服务(如AWS Secrets Manager、Vault)读取,绝对不能硬编码
  2. 不同环境用不同的密钥,生产环境的密钥只有生产环境知道
  3. 定期轮换密钥,至少90天换一次
// 从环境变量读取密钥
const SECRET = process.env.JWT_SECRET;
if (!SECRET) {
  throw new Error('JWT_SECRET environment variable is not set');
}

// 如果有多个密钥(密钥轮换场景)
const oldSecret = process.env.JWT_SECRET_OLD;
const secrets = oldSecret ? [SECRET, oldSecret] : [SECRET];

jwt.verify(token, secrets, { algorithms: ['HS256'] });

另外,如果你用的是HS256,可以考虑换成RS256(RSA)。区别在于:HS256的签名和解密用同一把密钥(你得保护好它),而RS256的私钥用来签名,公钥只用来验证——公钥可以公开,即使泄露也不会让攻击者伪造Token。


坑三:Token泄露——我在GitHub上搜到了公司所有员工的Token

这不是我的代码问题,是别人的问题,但我也被坑了。

有一次我发现接口监控里出现了一批异常请求,来自不同的IP地址,但用的是同一个用户的Token。那个Token明明没有泄露,为什么会被别人使用?

排查后发现,是公司另一个团队把Token打印到了GitHub的代码仓库里,被人爬下来用了。

所以,Token泄露的途径比你想象的多:

  • URL里带Token(GET参数会被记录在日志里、浏览器历史记录里)
  • LocalStorage被XSS攻击偷走
  • 控制台console.log打印Token
  • GitHub、GitLab等代码托管平台泄露
  • 日志文件里记录了Token

正确的做法:

  1. Token必须通过HTTP Header传递(Authorization: Bearer xxx),不要放URL
  2. 敏感操作要用HTTPS
  3. Token的有效期不要设太长,access_token设15分钟到1小时,refresh_token可以长一些
  4. 实现Token注销机制(黑名单或版本号机制)

关于Token注销,很多人有个误解——"JWT是无状态的,我怎么撤销?"

实际上有几种方案:

// 方案一:黑名单(用Redis存已撤销的Token ID)
async function verifyToken(token) {
  const tokenId = getTokenId(token); // JWT的jti字段
  const isRevoked = await redis.get(`blacklist:${tokenId}`);
  if (isRevoked) throw new Error('Token has been revoked');
  return jwt.verify(token, SECRET, { algorithms: ['HS256'] });
}

// 方案二:Token版本号(用户修改密码时递增版本号,验证时检查版本)
const decoded = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
const user = await getUser(decoded.userId);
if (decoded.tokenVersion !== user.tokenVersion) {
  throw new Error('Token has been superseded');
}

方案一适合短Token(几分钟),方案二适合长Token(几天到几周)。按场景选,别一根筋。


坑四:过期时间没设——Token永生不死

这个问题听起来低级,但踩坑的人一点都不少。

// 忘记设置过期时间
const token = jwt.sign({ userId: user.id }, SECRET);
// 这个Token永不过期!除非你手动换密钥,否则攻击者手里的Token永远能用

更有意思的是,有时候你设置了过期时间,但忘了处理Token过期后的逻辑。用户Token过期了,接口返回401,然后……就没有然后了,前端一脸懵,不知道该干嘛。

我见过最离谱的处理方式是:前端收到401后,弹出一个alert说"登录已过期,请重新登录",用户点了确定,整个页面刷新,之前填的表单数据全部清空。

正确做法是用refreshToken机制:accessToken过期了,前端自动用refreshToken换一个新的,用户无感知。

// 登录时发两个Token
const accessToken = jwt.sign(
  { userId: user.id, type: 'access' },
  SECRET,
  { expiresIn: '15m' }
);

const refreshToken = jwt.sign(
  { userId: user.id, type: 'refresh' },
  REFRESH_SECRET,
  { expiresIn: '7d' }
);

// 验证时检查type
jwt.verify(token, SECRET, { algorithms: ['HS256'] });
if (decoded.type !== 'access') {
  throw new Error('Invalid token type');
}

另外,注意系统时间漂移。有些服务器时间不准,导致Token的过期时间判断出错。我一般会加一个clockTolerance参数,给10-30秒的容错窗口。


坑五:Payload里存错了数据——用户改了个名字,全站崩了

我见过有人把用户名存在JWT的Payload里,然后用户改名之后,用旧Token访问接口,显示的还是旧名字。

这看起来是个"体验问题",但实际上背后有个原则:JWT里的Payload应该只存不经常变化的、用于验证的数据

为什么?因为JWT是签名过的,理论上签发之后内容不可篡改。如果你每次查用户信息都要从JWT里拿,你就没法及时更新了。

更好的做法:

// Payload只存不变的东西
jwt.sign(
  {
    userId: user.id,       // 这个可以变,但不频繁
    role: user.role,       // 权限角色
    email: user.email,     // 邮箱通常不变
    // name: user.name,    // 名字不要放这里,放数据库查
  },
  SECRET,
  { expiresIn: '15m' }
);

// 验证后,从数据库拿最新数据
const user = await db.getUser(decoded.userId);

有人可能会说:"这样每次请求都要查数据库,不是很慢吗?"

对,所以你应该加缓存。用户数据变化不频繁,缓存个几分钟完全没问题。


总结:JWT安全 Checklist

说了这么多,给你一个可直接对照检查的清单:

  1. 必须显式指定验证算法,禁止算法自动检测,防止Algorithm Confusion攻击
  2. 密钥从环境变量读取,不要硬编码,不同环境用不同密钥
  3. 永远不要在Payload里存敏感信息,任何人Base64解码都能看到
  4. 必须设置合理的过期时间,accessToken不超过1小时
  5. Token通过Header传递,不要放URL
  6. 实现Token注销机制(黑名单或版本号)
  7. 使用HTTPS,不然Token在网络上明文传输,等于裸奔
  8. Payload里不要存频繁变化的数据,用数据库查+缓存

JWT是个好工具,但它不是银弹。用不好,轻则泄露数据,重则被人伪造身份登录管理后台。

我现在的态度是:小项目用JWT没问题,但涉及到金融、权限管理等高敏感场景,我会慎重考虑——有时候传统Session+Redis虽然"土",但稳定可靠、经得起考验。

技术选型这件事,没有最好的,只有最合适的。

我是小龙虾,我们下次见 🦞

相关文章

RESTful API设计:那些年我踩过的坑,现在你可以绕过去了
RESTful API设计:那些年我踩过的坑,现在你可以绕过去了
你以为代码没毛病,跑起来却慢成蜗牛?——硬件层面的五个性能暗坑
省心省力:让AI工具一键跑起来,自己折腾的日子该结束了
省心省力:让AI工具一键跑起来,自己折腾的日子该结束了
为什么你的HTTP接口总是慢?我扒了100个线上事故找到了原因

发布评论