我见过最常见的 JWT 误解就是这句:这个 token 我用 JWT 加密了。
它没有加密。JWT header 和 payload 是 Base64URL 编码,跟 Base64 一家人。任何拿到这串字符的人,粘贴到任何在线 JWT decoder,都能直接读出用户 ID、过期时间、权限列表。本文就是为了让你下次看到 JWT 加密四个字时,能立刻指出 — 这是签名,不是加密。
30 秒总览
- JWT (JSON Web Token) 通常由三段组成: header.payload.signature,中间用点分隔。
- header 和 payload 是 Base64URL 编码 (RFC 4648) ,可逆,完全可读。
- signature 是服务器用 secret 或公钥对前两段做的数字签名,作用是证明 token 没被改过,不证明内容保密。
- 不应该把密码、API key、token 等秘密放进 payload。要保护 secret,用真加密 (AES、libsodium、age、GPG) 。
一个真实 JWT 长什么样
我们用 JWT 解码器工具自带的 Load sample 按钮,可以生成下面这个标准 3 段 JWT。工具会在右侧分别展示三段的解码结果,以及一份安全提示: signature 未经验证。这个提示是常态,因为解码本身不会验签。
具体来说,header 解码后会包含 alg 与 typ 两个字段,alg 是签名算法,typ 通常是 JWT。payload 解码后会包含 iss、sub、aud、iat、exp、jti、role 等 claim,分别代表签发方、主体、受众、签发时间、过期时间、token ID 和角色。
5 个真实场景:JWT 用错的地方
场景 1:把用户密码塞进 payload
payload 字段示例 (用 inline 反引号包起来) : password: Hunter2 出现在 JWT payload 里。
为什么大家栽进去: 误以为服务器签名过的 token 就安全。其实任何拿到 token 的人 (浏览器扩展、客户端日志、Referer header、CDN 缓存) 都能用 jwt.io 或我们的 JWT 解码器 1 秒读出 password 字段。签名只防篡改,不防偷看。
正解: 密码永远不要进 JWT。敏感字段请用单独的加密通道 (如服务端加密、Vault) 保管,只把不敏感的 user id、role 放进 claim。
场景 2:把 API key 或数据库连接串塞进 payload
payload 字段示例: api_key: ... 出现在 claim 里。
API key 进 payload 跟 password 进 payload 是同一个错误类型。攻击者拿到任意一个用户的 JWT,就能从 payload 里直接读到后端 API key,然后用来调任意接口,不需要再走鉴权。
正解: API key、连接串、token 这些都属于机密凭据,只能放服务端。如果你要在客户端引用它,走单独的鉴权接口拿短时效 bearer token。
场景 3:把用户的隐私字段也写进 payload
payload 字段示例: email、phone、ssn_last4 这些隐私字段都塞进 claim。
JWT 设计目的就是跨服务传 claim,中间链路 (网关、日志、CDN) 经常能看到完整 token。把个人隐私字段写进 payload 等于在系统间广播这些字段,违反 GDPR / 个保法。
正解: JWT 只放最小必要的身份信息 (sub、role、exp、iss、aud) 。隐私字段让接收方拿 sub 自己去数据库查。
场景 4:把 alg 设成 none 或者不校验算法
header 字段示例: alg: none 或 signature 为空。
这是教科书级的攻击场景。攻击者把 header 里的 alg 改成 none,清空 signature,伪造一个 payload 说是 admin,服务器如果不严格校验算法就直接放行。我们的 JWT 解码器 会在 alg 为 none 或 signature 为空时显示红字警告: 生产环境通常不应接受。
正解: 服务器必须用白名单算法 (比如只接受 RS256、HS256) ,并对 alg 字段做严格比对。绝不能根据 header 里的 alg 动态选择验签算法,这是经典 CVE 模式。
场景 5:不解码就以为 exp 一定有效
payload 字段示例: exp: 1700000000 (秒) ,但库把它当毫秒使用,或服务器时间漂移。
如果服务器时间没同步、客户端时间漂移、或者 iat / exp 单位写错 (毫秒 vs 秒) ,就会出现明明 exp 没到却被拒,或者 exp 早过了却被放行的情况。
正解: 服务器永远要服务端时间,不要相信客户端 header。工具里如果看到 iat 比当前时间大很多,或者 exp 看起来像毫秒值 (13 位) 而不是秒值 (10 位) ,会用 millisecondTimestamp 警告提示。 这就是为什么用 JWT 解码器 排查 auth 问题时,先看一眼每个 claim 的 UTC + 本地时间,能省你半天排查时间。
4 个反直觉事实
事实 1:JWT 字符串比原始 JSON 长 30%-50%
Base64URL 编码后体积会增大。3 字节变 4 字符,payload 越大膨胀越多。短 claim 看着不显眼,几 KB 就能看出差别。
事实 2:signature 不能告诉你内容是否真实
签名的作用是: 服务器用 secret 签出来的,改一个字就签名对不上。它不证明: 签名者是谁、签名者是否被信任、签名密钥是否已泄露。
验签是服务器的活,不是客户端的活。客户端拿到 token 后能做的是送回服务端验签,而不是用工具验签 (这正是为什么我们的工具明确说 signature 未经验证) 。
事实 3:不同语言的库默认行为不一样
- jsonwebtoken (Node.js) : 默认接受多种算法,生产环境必须显式
algorithms: [RS256]之类的白名单。 - PyJWT (Python) : 默认 strict,显式
algorithms=参数才校验。 - java-jwt (Java) : 默认宽松,需要手动指定 algorithms。
跨语言时多看一眼对方库的默认行为,能避开一大半 CVE。
事实 4:JWT 过期 = 服务端拒绝,客户端不能解
exp claim 是给服务端看的。客户端拿到一个看起来没到 exp 的 token,也不能保证服务端会接受 — 服务端可能用了更短的有效期,或者强制 refresh,或者基于其他策略 (IP 变更、role 变更) 主动失效。
这就是为什么即便你用 JWT 解码器 看到 status 显示 valid 提示,也不代表服务端一定会放行。
推荐做法
- payload 只放最小必要 claim: sub、iss、aud、exp、iat、jti、role。密码、API key、隐私、连接串都不进。
- 服务器验签前,显式白名单算法 (例
algorithms: [RS256]) ,绝不根据 header 动态选。 - 服务器自己生成 exp,不要相信客户端的 exp 字段。客户端改完 payload 重签是不被允许的 (因为签名对不上) ,但你最好别依赖这个。
- 调试 auth 问题时,用 JWT 解码器 离线看 claim,不要把 token 贴到任何需要上传的网站。我们的工具全本地,文件不出浏览器,不会留任何日志。
- 想保护机密 — 用真加密。JWT 不解决机密问题,只解决鉴权和防篡改。机密走 AES-GCM、libsodium、age。
- JWT 不是 session。它是无状态的,但这不等于可撤销。要主动失效,服务端要维护黑名单 (denylist) 或用短时效 + refresh token。
JWT 是签名 + 编码,不是加密。需要保密,去找加密;需要鉴权,JWT 才是合适的工具。
试试我们的 JWT 解码器 — Load sample 一键生成一个标准 JWT,Decode 后立刻看到 header / payload / signature 三个 segment,以及 signature 未经验证的红字提示。如果你想进一步对比 Base64 vs 加密,看我们的 Base64 不是加密 文章和文本编码工具。