JWT(JSON Web Token / jwt decoder / jwt online / decode jwt)不是加密,是编码 —— payload 用 base64url 一解码就明文,所以永远不要把密码 / PII / 银行卡 / 手机号放 JWT payload。本文 8 个真实 JWT 安全 foot-guns + 5 个伪造攻击场景 + 7 天护身法,看完至少能避坑 90%。
30 秒总览
- JWT 不是加密是编码 —— 很多开发者以为 JWT “安全”是因为 base64url 编码看起来乱码,但任何人都能一秒解码得到明文 payload。JWT payload = 明文,只签名,没加密
- JWT 标准 4 大风险:
alg=none攻击 / HS256 与 RS256 公钥混淆 /exp不校验导致过期 token 通过 / 签名剥离某些库 fallback 接受 - JWT 部署 4 大 foot-guns:JWT secret 硬编码 Git 几小时内被扫 / kid 注入任意文件当密钥 / 没有黑名单登出后旧 token 仍有效 / 长过期时间(7 天)改密码后 token 不失效
- 5 个真实攻击场景:手机号明文泄露 / alg=none 伪造 / HS256 公钥签 / 签名剥离通过验证 / 改密码后旧 token 仍有效
- 7 天护身法(每个 ≤ 30 分钟):强制算法白名单(永远 verify exp) / JWT secret 走环境变量 + .gitignore / kid 严格校验格式(白名单字符) / 签名必验证(none 拒绝) / 短过期时间(15 分钟)+ refresh token rotation / 黑名单 + 数据库存 revoke 状态 / 永远不在 JWT payload 放 PII
- 用 Piick JWT 解码器 本地解码可疑 token 检查 header / payload / exp / 签名 —— 100% 本地不上传,debug 时不污染你的 audit log
8 个真实 JWT Foot-Guns
Foot-Gun 1:JWT 不是加密是编码
症状:你把用户的手机号 / 邮箱 / 银行卡后四位 / 身份证 hash 放 JWT payload,以为”反正 token 看起来是乱码,别人看不懂”。直到某天 payload 被泄露,base64url 一解码 → 明文手机号 + 邮箱 + 身份证 hash 全部曝光,GDPR 合规罚款起步 ¥50 万。
为什么:JWT 三大段 header.payload.signature,只有 signature 是加密的(header 和 payload 用 HMAC / RSA 签名,用于防篡改),但 payload 本身是用 base64url 编码的明文。任何拿到 token 的人(包括前端日志 / CDN / 反代 / 浏览器 DevTools)都能 atob(token.split('.')[1]) 一秒解码得到 JSON 原文。JWT 不是加密,是”防篡改的明文”。
修复:
- 永远不要把 PII(个人可识别信息)放 JWT payload —— 不放手机号 / 邮箱 / 身份证 / 银行卡 / 住址 / 密码 hash
- JWT payload 只放”非敏感的标识” —— user_id(数据库 UUID,不是可推导的数字)、role(角色,不暴露组织结构)、exp / iat / nbf(时间戳)
- 真正敏感的字段:放在后端数据库,JWT 只放 user_id,需要时再查 DB
- 如果真的需要在 token 里传敏感数据:用 JWE(JSON Web Encryption)—— 但 99% 场景不需要
真实场景:某 SaaS 公司把用户手机号直接放 JWT payload(为了”省一次数据库查询”),3 个月后 JWT secret 泄露,200 万用户的手机号通过解码 payload 一键拿到,GDPR + 中国《个人信息保护法》双重合规罚款 ¥800 万。
Foot-Gun 2:alg=none 攻击
症状:你的 JWT 库是早期的(jwt-go 早期版本 / jsonwebtoken 4.x 之前 / python-jose 旧版),某个攻击者构造 header = {"alg": "none"} + 任意 payload,伪造一个 admin token,你服务端库接受,攻击者直接获得管理员权限。
为什么:JWT 早期标准 RFC 7519 没有强制 server 端校验 alg 字段,某些库看到 alg: "none" 就直接信任 payload 不验签(因为 “none” = “不签名”,库就跳过验证步骤)。这是 JWT 历史上最经典的攻击,2015 年 Auth0 等多个库中招。
修复:
- 强制算法白名单(永远指定允许的算法): jsonwebtoken.verify with algorithms whitelist: [‘HS256’]
- 永远拒绝
alg: "none"(即使 RFC 兼容) - 升级到最新 JWT 库版本(修复已知 alg=none 漏洞)
- 代码 review 检查 verify 调用 —— 没有指定 algorithms 参数就是漏洞
真实场景:2015 年 Auth0 公开报告:Node.js jsonwebtoken 库 4.2.2 之前的版本默认接受 alg: "none",任何接受 token 的服务端只要没升级就处于攻击之下。修复后所有现代库都强制要求指定 algorithms。
Foot-Gun 3:HS256 与 RS256 公钥混淆攻击
症状:你用 RS256(RSA 非对称),公钥放在前端 / 网关用来 verify token,但某个开发者写了 verify 函数,看到 header alg: "HS256" 就用同一个公钥当 HMAC secret 去验签。攻击者拿到公钥(本来就是公开的)→ 用公钥当 HMAC secret 签一个 HS256 token → 服务端用同一个公钥”验证”通过 → 伪造 admin token 绕过认证。
为什么:RS256 用 RSA 私钥签,公钥验;HS256 用同一个 secret 既签又验。如果服务端代码混淆”拿到一个 key 就用”,攻击者可以用任何一种 key 伪造 token。这是 RS256 部署的最经典漏洞。
修复:
- 永远强制 header 中的 alg 与配置的算法一致 —— 算法在配置里写死(RS256 服务永远 verify 期望 alg=RS256)
- secret / public key 严格分开命名(避免混淆): const JWT_PRIVATE_KEY = …; // 只用于签 const JWT_PUBLIC_KEY = …; // 只用于验
- 代码 review 检查 verify 函数 —— 强制 verify 的算法列表(白名单),不接受 token 自报的 alg
- 用成熟 JWT 库(PyJWT / jsonwebtoken / java-jwt)而不要自己实现 verify
真实场景:2018 年某银行 JWT 网关服务用 RS256,但 verify 函数实现 bug:jwt.verify(token, publicKey) 没指定 algorithms,攻击者用公钥当 HMAC secret 签 HS256 token 通过验证,伪造 1000 万美元转账。
Foot-Gun 4:exp 字段不校验或漏校验
症状:你的 JWT 有 exp 字段(过期时间),以为会自动校验,但服务端某些边缘代码路径里 token 验证只检查 signature 不检查 exp(比如某条 fallback 路径),过期 token 仍被接受,session 永久不失效。用户改了密码 / 被禁用 / 登出,token 还能用到 exp 到期。
为什么:JWT 标准 RFC 7519 § 4.1.4 规定 exp 是 MUST validate,但很多库默认 verify 时不强制校验 exp(为了兼容某些特殊场景比如”永久 token”)。如果 verify 调用没显式传 ignoreExpiration: false 或者代码里手动 verify 而不是用库,exp 就被跳过。
修复:
- 永远显式 verify exp(不要靠默认值): jwt.verify with algorithms: [‘HS256’] and ignoreExpiration: false
- 短过期时间(access token 15 分钟,refresh token 7-30 天)
- server 时钟用 NTP 同步(避免时钟偏差导致 token 提前过期 / 延后过期)
- 客户端不要相信本地时间 —— 用
Date.now()/time.time()跟 server 时间算 exp 差
真实场景:某 SaaS 服务 access token 过期时间 7 天,某用户改密码后 attacker 拿旧 token,仍能访问该用户所有资源 7 天。缩短到 15 分钟 + 配合 refresh token rotation 是行业标准。
Foot-Gun 5:JWT secret / private key 硬编码 Git 泄露
症状:你在代码里写 const JWT_SECRET = "my-super-secret-key" + commit + push 到 GitHub(即使是 private repo,员工离职 / 账号被盗 / 内部 leak)。GitHub secret scanning 30 秒内扫到,JWT secret 跟 API key 一样泄露,攻击者用这个 secret 签任意用户任意 payload 的 token,整个系统沦陷。
为什么:Git 历史永不删除(git filter-branch 也只是改分支指针,旧 commit 对象还在 .git/objects/ 30 天)。JWT secret 跟 API key 在泄露后是同等级灾难 —— 攻击者拿到 secret = 整个身份认证系统沦陷 = 任意伪造用户身份。
修复:
- JWT secret 走环境变量 + .gitignore: const JWT_SECRET = process.env.JWT_SECRET
- 永远不要写死到代码里
- CI 用 OIDC / 短期 token,不存长期 secret
- JWT secret 跟 API key 一样轮换 + 监控 —— batch-24 api-key-leak-prevention 的 7 天护身法完全适用
真实场景:某 fintech 公司 jwt-secret 写在 config/default.json 提交到 GitHub,GitHub secret scanning 30 秒发邮件 + 给攻击者 web hook,1 小时内 200 万用户 JWT 被重新签发,用户余额被任意篡改。修复方案:把 secret 移到 Vault / AWS Secrets Manager + CI OIDC。
Foot-Gun 6:kid (key id) 注入攻击
症状:你用 RS256 多密钥架构,header 里 kid 字段指定用哪个公钥,服务端 getKey(header.kid) 根据 kid 查公钥表。攻击者构造 kid: "../../etc/passwd" 或 kid: "' OR 1=1--",某些 JWT 库直接 fs.readFileSync(getKey(kid)) 或拼接 SQL 查询,导致任意文件读 / SQL 注入。
为什么:kid 字段本意是”用哪个 key 验签”,但库在实现 getKey 时常常把 kid 当成文件名 / SQL 参数 / URL 路径处理,没有白名单字符校验。攻击者利用 kid 做路径遍历(LFI / RCE)或 SQL 注入。
修复:
- kid 严格白名单字符(只允许
[A-Za-z0-9_-]):validate kid matches whitelist regex (alphanumeric + dash + underscore, 1-32 chars)
- kid 值必须在已知 key 集合里(Map.has(kid) 检查)
- 不要把 kid 当文件名 / SQL 参数 / URL 路径处理
- 用成熟 JWT 库(自己实现 getKey 容易出 bug)
真实场景:Node.js jsonwebtoken + 某些插件支持 jwk header 字段,攻击者构造 jwk: { kty: "oct", k: "BASE64-ENCODED-ATTACKER-PUBLIC-KEY (look up in attacker's jwk parameter)" },让服务端用攻击者提供的 key 验签 = 必然通过。修复:拒绝 token 自带的 jwk / jku / x5u 等字段,只用服务端配置的 key。
Foot-Gun 7:签名剥离攻击
症状:你写 verify 函数时,如果某个边界条件(比如 header.alg 不匹配白名单)没处理,某些库会 fallback 接受无 signature 的 token(header.payload. 后跟空字符串)。攻击者修改 payload(比如改 role 为 admin)→ 删掉 signature → 某些 fallback 路径跳过验签 → 伪造 token 通过验证。
为什么:JWT 的格式是 header.payload.signature,如果只传 header.payload.,库解析时 signature 段是空,某些库实现 if (!signature) return payload(因为 “没签名 = 不用验签”),绕过整个签名验证。
修复:
- 永远 verify 完整 3 段(
header.payload.signature必须都有内容) - 拒绝 signature 为空的 token(即使 RFC 兼容)
- 永远用成熟库(不要自己 split + verify)
- 测试用例:构造
header.payload.提交,应该被拒绝
真实场景:某 JWT 库实现 bug:signature 为空时直接 return payload,攻击者抓到一个合法 token → 修改 payload(role → admin)→ 删 signature → 通过验证 → 拿到 admin 权限。修复:verify 前检查 token.split(’.‘).length === 3 且第三段非空。
Foot-Gun 8:JWT 撤销列表不存在
症状:用户改了密码 / 被管理员禁用 / 主动登出,旧 token 仍然有效到 exp 到期。access token 7 天有效期意味着 改密码后 7 天内 attacker 用旧 token 仍能访问。没有黑名单 / revoke 机制 = JWT 一旦签发就管不到。
为什么:JWT 设计是 stateless(无状态)—— 服务端不存储 token,只验证 signature。好处是水平扩展容易,坏处是撤销困难。如果想”立即吊销这个 token”,必须有黑名单或 refresh token 机制。
修复:
- 短 access token 过期时间 + refresh token rotation: access token: 15 分钟过期 refresh token: 7 天过期,每次使用后重新生成新的(rotation,旧 refresh token 失效)
- refresh token 存数据库 + 黑名单(登出时把 refresh token 标记为 revoked)
- 改密码时撤销所有 refresh token(强制重新登录)
- Redis 黑名单(高频访问场景用 Redis 缓存 revoked token,TTL = token 剩余有效期)
真实场景:某 SaaS 用户改密码后 attacker 用偷来的 token 仍能访问 7 天 → 用户邮箱被改 / 信用卡被加 → 客服投诉才被发现。修复:access token 15 分钟 + refresh token rotation + 改密码撤销所有 refresh token。
5 个真实攻击场景
场景 1:手机号明文泄露
症状:某 SaaS API 返回用户 JWT 包含 {user_id: 123, phone: "13812345678", email: "user@example.com"},前端用这个 token 显示”已登录”状态。3 个月后 JWT secret 泄露,所有 token 被解码,200 万用户的手机号 + 邮箱一锅端。
修复:
- JWT payload 只放 user_id(UUID,不是可推导的数字)
- 前端要显示手机号时,用 user_id 调后端 API 查(不靠 token 解码)
- 永远不把 PII 放 JWT payload
场景 2:alg=none 伪造 token
症状:某早期 Node.js 服务用 jsonwebtoken@4.2.1,verify 没指定 algorithms,攻击者构造 header = {"alg": "none", "typ": "JWT"} + payload {user_id: 1, role: "admin"} + signature 留空,直接伪造 admin token 通过验证。
修复:
- 强制 algorithms 白名单:
jwt.verify(token, secret, { algorithms: ['HS256'] }) - 升级到 jsonwebtoken 最新版
场景 3:HS256 公钥混淆攻击
症状:某银行 JWT 服务用 RS256(私钥签,公钥验),公钥放在 API 网关,verify 函数 jwt.verify(token, publicKey) 没指定 algorithms。攻击者用公钥当 HMAC secret 签 HS256 token,服务端用公钥”验证”,通过 → 伪造 admin token 转账 1000 万美元。
修复:
- verify 强制 algorithms 白名单:
jwt.verify(token, publicKey, { algorithms: ['RS256'] }) - 私钥 / 公钥严格命名分离(避免 getKey 混淆)
场景 4:exp 不校验,过期 token 仍被接受
症状:某服务 JWT 设了 exp,但 verify 库默认 ignoreExpiration: true(为了兼容”永久 token”),所有过期 token 都能通过。用户改密码后 attacker 用旧 token 仍能用 30 天。
修复:
- 显式 verify exp:
jwt.verify(token, secret, { algorithms: ['HS256'], ignoreExpiration: false }) - 永远不要靠默认值
场景 5:签名剥离攻击
症状:某 JWT 库 verify 实现 bug:signature 为空时直接 return payload。攻击者抓合法 token → 修改 payload → 删 signature → header.payload. 提交,绕过验签。
修复:
- verify 前检查 token.split(’.‘).length === 3 且第三段非空
- 永远用成熟 JWT 库(自己 split + verify 容易出 bug)
- 测试用例:
token = "header.payload."应该被拒绝
7 天护身法
第 1 天:强制算法白名单
5 分钟。代码 review 检查所有 JWT verify 调用,确保指定 algorithms 白名单:
- Node.js jsonwebtoken:
jwt.verify(token, secret, { algorithms: ['HS256'] }) - Python PyJWT:
jwt.decode(token, secret, algorithms=['HS256']) - Java java-jwt:
Jwts.parser().verifyWith(key).build().parseSignedClaims(token) - 永远拒绝 alg=none
效果:alg=none 攻击 + HS256/RS256 混淆攻击全部堵死。
第 2 天:JWT secret 走环境变量
30 分钟。把所有 JWT secret / private key 从代码 / config 文件移到环境变量:
- 创建
.env.example(占位,无真实 secret,提交) .gitignore加.env- 代码改成
const JWT_SECRET = process.env.JWT_SECRET - CI 用 GitHub Actions secrets + OIDC
效果:Git 泄露 JWT secret 风险归零。
第 3 天:永远 verify exp + 短过期时间
1 小时。代码 review 所有 verify 调用,确保:
ignoreExpiration: false显式指定- access token 过期时间 ≤ 15 分钟
- refresh token 过期时间 ≤ 7 天
- refresh token rotation(每次使用后重新生成)
效果:token 泄露后最长 15 分钟可访问,而不是 7 天。
第 4 天:kid 严格白名单
30 分钟。代码 review getKey 函数,确保:
- kid 值必须匹配
^[A-Za-z0-9_-]{1,32}$ - kid 值必须在已知 key 集合里
- 不用 kid 当文件名 / SQL 参数 / URL 路径
- 拒绝 token 自带的 jwk / jku / x5u 字段(用服务端配置的 key)
效果:kid 注入攻击堵死。
第 5 天:签名必验证
15 分钟。代码 review verify 前置检查:
- token.split(’.‘).length === 3
- 第三段(签名)非空
- library 版本是最新的(没有已知签名剥离漏洞)
- 测试用例:
token = "header.payload."应该被拒绝
效果:签名剥离攻击堵死。
第 6 天:refresh token 撤销机制
半天。实现 refresh token rotation + 黑名单:
- 数据库存 refresh token(关联 user_id + device + revoked 状态)
- 每次使用 refresh token 后:生成新的,旧的标记为 revoked
- 检测到 revoked 的 refresh token 被使用 → 吊销该用户所有 token(怀疑 token 被盗)
- 改密码时:撤销所有 refresh token,强制重新登录
- Redis 缓存 revoked 状态(高频访问场景)
效果:登出 / 改密码 / 禁用账号后旧 token 立即失效。
第 7 天:JWT payload 审计
1 小时。代码 review 所有 JWT sign 调用,确保 payload 只包含非敏感标识:
user_id(UUID,不是可推导数字)role(角色,不暴露组织结构)exp / iat / nbf(时间戳)- 不放:手机号 / 邮箱 / 身份证 / 银行卡 / 密码 hash / 住址
效果:即使 token 泄露,PII 也不会曝光。
工具选型决策
选项 A:商业 JWT 服务(Auth0 / Clerk / Firebase Auth / AWS Cognito)
适合:中大型团队 / 多产品统一认证 / 严格合规(金融 / 医疗)。
优点:成熟安全 / OAuth 2.0 + OIDC 标准 / 内置 refresh token rotation / 内置黑名单 / 合规审计。缺点:价格高(Auth0 MAU > 1000 起 $240/月)、vendor lock-in、自托管难。
选项 B:自己用成熟 JWT 库实现(PyJWT / jsonwebtoken / java-jwt)
适合:个人 / 小团队 / startup / MVP。
优点:免费 / 灵活 / 完全可控 / 标准兼容。缺点:自己实现 revoke / rotation(容易出 bug)、需要自己 7 天护身法全部落实、没有内置审计。
选项 C:Piick JWT 解码器
适合:任何 debug / 审计 / 学习场景。
100% 本地不上传,特别适合 debug 时检查可疑 token / 审计 JWT payload / 学习 JWT 结构:
- 解码 header + payload(base64url 一键解码,显示明文 JSON)
- 解析 exp / iat / nbf / aud / iss / sub(显示过期倒计时,标红已过期)
- 检查 signature 是否存在 + 算法(提示 alg=none 警告)
- kid / jku / x5u 安全检查(提示可疑字段)
- 不存任何 token(本地解码,关闭页面即销毁)
决策树:
- 中大型 / 多产品 / 合规 → 选项 A(Auth0 / Clerk)
- 个人 / 小团队 / MVP → 选项 B(自己用成熟库 + 7 天护身法)
- 任何 debug / 审计 → 选项 C(Piick 本地解码)
推荐做法
- 永远强制 JWT 算法白名单(
algorithms: ['HS256']或['RS256']),永远拒绝alg=none - 永远 verify exp(显式
ignoreExpiration: false),access token ≤ 15 分钟 - 永远不在 JWT payload 放 PII(不放手机号 / 邮箱 / 身份证 / 银行卡)
- 永远把 JWT secret 走环境变量 + .gitignore,永远不硬编码到 Git
- 永远用成熟 JWT 库(PyJWT / jsonwebtoken / java-jwt),不自己实现 verify
- 永远用 refresh token rotation + 黑名单,access token ≤ 15 分钟 + refresh token ≤ 7 天 + rotation
- 永远 kid 白名单字符(只允许
[A-Za-z0-9_-]),拒绝 token 自带的 jwk / jku / x5u - 永远测试签名剥离攻击:
token = "header.payload."必须被拒绝 - 用 Piick JWT 解码器 本地解码可疑 token 检查 header / payload / exp / 签名,不消耗 token 不上传日志(数据 100% 本地)、Piick JSON 格式化工具 beautify JWT payload 的 JSON 结构验证 schema、Piick 正则表达式测试器 写正则验证 JWT claim 格式(iat 是数字、exp 是数字、sub 是 UUID)、Piick URL 编码解码器 检查 OAuth callback URL 是否在 query string 含 token、Piick Cron 生成器 生成 JWT 轮换调度 — 5 件套覆盖 JWT 上线 / debug / 安全所有 dev 场景,数据 100% 本地不上传
JWT 不是加密是编码,签名只能防篡改不能保密。真正的”JWT 安全” = 强制算法白名单 + verify exp + 不放 PII + secret 走环境变量 + kid 白名单 + 签名必验证 + refresh token rotation + 黑名单。把 Piick JWT 解码器 收藏在书签栏,debug 时检查可疑 token 1 分钟走完,日常 dev 不再担心 JWT 泄露。