JWT 安全实践:为什么不要在前端验签
JWT 安全实践:为什么不要在前端验签
JSON Web Token(JWT)是过去十年里最流行的无状态鉴权方案。Auth0 在 2024 年的开发者调查里,超过 74% 的新项目 选了 JWT 而不是传统 session cookie。然而它的普及也带来一类高频反模式:把 JWT 的签名验证放在前端 JavaScript 里。
这篇文章想讲清楚三件事:JWT 到底是什么、为什么前端验签等于不验,以及后端验签有哪些常见的实现陷阱。
JWT 的三段结构
一个 JWT 其实就是用两个 . 分隔的三段 Base64URL 编码字符串:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyXzQyIiwiZXhwIjoxNzYyOTk5OTk5fQ
.5dSXjJ8m_DwQy0dZJ8X-rEwL4Cqk7jM_JqK_RkVxGyU
| 段位 | 名称 | 解码后内容 | 是否加密 |
|---|---|---|---|
| 1 | Header | {"alg":"HS256","typ":"JWT"} | 否,仅 Base64URL |
| 2 | Payload | {"sub":"user_42","exp":1762999999} | 否,仅 Base64URL |
| 3 | Signature | HMAC-SHA256(secret, header.payload) 后再编码 | — |
注意 payload 是明文——任何人拿到 token 都能直接解码看到里面的 claim。这是 JWT 设计的核心:客户端能读,但只有服务端能验证它没被篡改。把私密信息(密码、信用卡号、内部 user ID 之外的画像)写进 JWT payload 是另一个常见错误。
可以用 JWT 解码工具 直接粘贴上面的 token 看到这个结构——这种 客户端解码 是完全合理的用法,问题出在 客户端验签 上。
为什么前端验签等于不验
假设你的前端代码这样写(伪代码):
import { jwtVerify } from "jose";
const SECRET = "my-app-secret-2026"; // ❌ 致命问题
async function isLoggedIn(token) {
try {
await jwtVerify(token, new TextEncoder().encode(SECRET));
return true;
} catch {
return false;
}
}
这段代码看起来很合理——拿到 token、用 secret 验签、通过就放行。但它有两个根本性问题:
问题 1:secret 必须打包进 JS bundle。任何浏览器用户右键"查看页面源代码"或扒一下 webpack chunk 就能拿到。secret 不再是 secret——攻击者可以自己签发任意 JWT,伪装成任意用户。HMAC 算法的安全性完全依赖 secret 保密,公开 secret 等于完全没有签名。
问题 2:客户端校验本身没有任何意义。攻击者根本不会通过你的前端走鉴权流程——他直接给 API 发请求就行。如果 API 不验签、只信任前端的判断,那 API 就是完全开放的。前端验签只能防止 友善用户 看到本不该看到的 UI,不能防止 任何 真正的攻击。
正确的认知:JWT 验签是后端 API 的责任。前端拿到 JWT 后只应该做两件事:
- 存起来(localStorage / cookie / memory,各有取舍)
- 每次请求带上(
Authorization: Bearer <token>)
如果前端想知道"用户是不是登录了"或"token 还有几分钟过期",解码 payload 读 exp claim 就够了——不需要也不应该验签。
// ✅ 前端只解码不验签:判断 UI 状态用
function decodePayload(token) {
const [, payload] = token.split(".");
// base64url 解码:替换字符 + 补 padding
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
}
const { exp, sub } = decodePayload(token);
if (exp * 1000 < Date.now()) refreshToken();
后端验签的 5 个常见陷阱
那"正确的后端验签"长什么样?大部分主流库(jose / pyjwt / golang-jwt)的默认 API 都帮你处理了基础情况,但有几个细节它们不会强制:
1. 必须显式声明允许的 algorithm
CVE-2015-9235 是 JWT 历史上最著名的漏洞:库默认接受 alg: none,攻击者把 header 改成 {"alg":"none"}、签名段留空,居然就过了。jose 等现代库已经默认禁掉 none,但 算法白名单 仍然是必须的:
# ❌ 不指定 algorithms 会接受任何算法
jwt.decode(token, secret)
# ✅ 显式锁死算法
jwt.decode(token, secret, algorithms=["HS256"])
更隐蔽的是 HS / RS 混淆:服务端用 RSA 公钥验签,攻击者把 alg 从 RS256 改成 HS256,然后 用公钥本身 当 HMAC secret 签 token——某些库会用 secret 参数当作 HMAC key 校验通过。永远只允许你期望的那一种算法。
2. 时钟偏移要有容差
exp(过期时间)和 nbf(生效时间)的校验对系统时钟敏感。生产环境多台机器时钟偏移 1–2 秒是常态,硬比较会误杀刚签发的 token:
parser := jwt.NewParser(jwt.WithLeeway(30 * time.Second))
30 秒是一个常见的 leeway——既容忍 NTP 漂移,又不至于让重放攻击窗口太大。
3. kid 字段不要直接当文件路径
kid(key ID)用来支持密钥轮换:JWKS 端点暴露多个公钥,token header 的 kid 告诉服务端用哪个验签。绝不要 把 kid 直接拼到文件路径或 SQL 里:
# ❌ 路径遍历漏洞
key = open(f"/etc/keys/{header['kid']}.pem").read()
# ✅ 白名单查表
key = JWKS_BY_KID.get(header["kid"])
if key is None:
raise InvalidToken("unknown kid")
历史上有库 CVE 就是因为 kid 解析时被注入 ../../../dev/null 拿到空 key 然后 HMAC 验签通过。
4. iss / aud claim 要校验
iss(签发方)和 aud(接收方)经常被忽略。多服务的微服务架构里,同一个 secret 签的 token 可能被用在不应该的服务上——A 服务签的"管理员"token 被拿去访问 B 服务的 API 就是经典的 confused deputy 漏洞。
jwt.decode(token, secret,
algorithms=["HS256"],
issuer="https://auth.toolkit.best",
audience="api.toolkit.best")
5. 黑名单 / 短 TTL 必须二选一
JWT 的"无状态"是一把双刃剑:签发后服务端不再保存状态——这意味着 没法主动让一个 token 失效。用户改密码 / 注销 / 被封禁时怎么办?两条主流方案:
- 短 TTL + refresh token:access token 5–15 分钟过期,配 refresh token 在服务端有状态记录,可主动撤销
- JTI 黑名单:每个 JWT 有唯一
jti,撤销时把 jti 写进 Redis;验签后再查一次黑名单
完全无状态 + 长 TTL 的组合 = "用户改了密码 24 小时内仍能用旧 token 登录",这是大部分站点不能接受的。
总结
| 角色 | 该做 | 不该做 |
|---|---|---|
| 前端 | 存 token、加 Authorization 头、解码读 exp | 验签、把 secret 写进代码、信任客户端鉴权判断 |
| 后端 | 显式 algorithm 白名单、校验 iss/aud/exp、kid 查表 | 默认接受任意算法、忽略时钟偏移、用 kid 拼路径 |
JWT 不是"更安全的 cookie",它只是"无状态的凭证容器"。安全性来自 服务端如何验签 和 secret 如何保密——把验签搬到前端就把这两件事都破坏了。
延伸阅读
- 用 JWT 解码工具 解析 token、检查 alg / exp / iss 字段
- Base64 工具 单独解码 JWT 三段中的任意一段做调试
- RFC 8725 — JWT Best Current Practices:IETF 官方安全建议,覆盖本文所有要点的规范出处