先看一串东西:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxIiwibmFtZSI6ImxqbSIsImlhdCI6MTc5MTcxMDU2OCwiZXhwIjoxNzkxOTI2NTY4fQ.
iwJU5RFWL8eBAqjcXnxOduKL5dQZut8124YyMTW6rRM
这是登录成功后服务器发给你的一段 JWT。它看着像加密的乱码,其实中间那段是明文的 JSON——随便找个 JWT 解码工具贴进去,就能看到里面写着 name: "ljm"、什么时候签发、什么时候过期。
要理解 JWT,别从「它长什么样」开始,要从「它为什么存在」开始。JWT 是 HTTP 无状态这件事,逼出来的一个方案。
一、为什么需要 JWT:先看 Session 的麻烦
HTTP 天生无状态:每个请求都是独立的,服务器压根不记得你上一个请求干过什么。
可登录这件事天然是有状态的——你输密码登一次,之后每个请求服务器都得知道「这是同一个人」。怎么记住?
传统做法是 Session:
1. 你在服务器登录,服务器建一条 session 记录存起来
2. 服务器把 sessionId 塞进 Cookie 发给你
3. 你之后每个请求都带上这个 Cookie
4. 服务器拿 sessionId 去自己的存储里查:"哦,是这个人"
问题出在第 1 步和第 4 步:状态存在服务器上。这带来一串连锁反应:
- 要存东西:内存扛不住,得上 Redis,又是一套要运维的服务。
- 多机难同步:一个请求负载均衡到 B 机,结果 session 存在 A 机,用户状态没了。要么粘性会话,要么共享存储。
- 跨域/跨端别扭:Cookie 受同源策略约束,App、小程序、第三方调用都不方便带。
- 扩展受限:想横向加机器,得先解决 session 共享。
一句话:Session 把「你是谁」这张状态存在了服务器,服务器越多、端越杂,越难受。
JWT 的思路正好反过来。
二、JWT 是什么:把状态搬到客户端
JWT,全称 JSON Web Token。它的核心思想是:
别在服务器存「你是谁」,
把「你是谁」写进一张签名过的凭证里,交给客户端自己拿着。
服务器不存任何东西,收到凭证后自己验一下签名就知道真假。
这样服务器就「无状态」了——任何一台机器拿到 token,用同一个密钥验一下,都能确认你的身份,不需要共享存储。横向扩展变得轻而易举,跨域、跨端也不受 Cookie 那套限制。
这正是 JWT 解决的核心问题:在无状态的 HTTP 之上,用一张自证的凭证,恢复出「有状态」的身份识别。
三、JWT 的结构:三段,用点隔开
一个 JWT 就是三段 Base64URL 字符串,用 . 连起来:
Header . Payload . Signature
header payload signature
第一段 Header,描述签名算法:
{
"alg": "HS256",
"typ": "JWT"
}
第二段 Payload,存放声明(claims),也就是真正的数据:
{
"sub": "1",
"name": "ljm",
"role": "1",
"iat": 1791710568,
"exp": 1791926568
}
这里面分两类:
| 类型 | 例子 | 含义 |
|---|---|---|
| 标准声明 | iss | 签发者 |
sub | 主题(常见于用户 ID) | |
exp | 过期时间 | |
iat | 签发时间 | |
nbf | 生效时间(之前不可用) | |
jti | 唯一 ID(可用于防重放/黑名单) | |
aud | 接收方 | |
| 自定义声明 | name / role | 想放什么放什么 |
第三段 Signature,是拿前两段 + 密钥算出来的签名,用来防篡改:
signature = 签名算法(
base64url(header) + "." + base64url(payload),
密钥
)
这里有个必须记住的点:JWT 是「签名」不是「加密」。 前两段只是 Base64URL 编码,任何人都能解开看,只是不能改(改了签名就对不上)。所以——
绝对不要把密码、身份证号这类敏感信息放进 payload。
四、它到底怎么实现的
拆完结构,实现就没什么魔法了。签名算法最常用两种:
- HS256(对称):用同一个密钥签发、也用同一个密钥验证。简单,但验签方也能签发,适合只有自己用的场景。
- RS256(非对称):私钥签发,公钥验证。验签方拿公钥只能验、不能签,适合「一个签发方、多个验证方」。
手动实现 HS256,其实就几行
const crypto = require('crypto')
const SECRET = process.env.SECRET_KEY
const b64 = s => Buffer.from(s).toString('base64url')
function sign(payload) {
const header = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = b64(JSON.stringify({
...payload,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600, // 1 小时后过期
}))
const data = `${header}.${body}`
const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url')
return `${data}.${sig}`
}
验证就是把签名重算一遍,对上再检查过期:
function verify(token) {
const [header, body, sig] = token.split('.')
if (!header || !body || !sig) throw new Error('格式错误')
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${header}.${body}`)
.digest('base64url')
// 用恒定时间比较,防止时序攻击
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
throw new Error('签名不匹配,被篡改过')
}
const payload = JSON.parse(
Buffer.from(body, 'base64url').toString()
)
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('已过期')
}
return payload
}
实际项目里没人手写,用库就行(Node 的 jsonwebtoken、前端的 jose):
const jwt = require('jsonwebtoken')
const token = jwt.sign({ sub: '1', name: 'ljm' }, SECRET, { expiresIn: '1h' })
const payload = jwt.verify(token, SECRET) // 签名错或过期会直接抛异常
签名的价值在于「防篡改」:攻击者能读到 payload,也能改,但只要改了内容,第三段签名立刻对不上,服务器一验就露馅。他没有密钥,伪造不出新签名。
五、JWT 的坑:无状态是优点,也是软肋
1. 没法主动撤销
Session 想让你「登出」,服务器删掉那条记录就行。但 JWT 服务器不存东西,签发出去就收不回来了——你没法让一张还没过期的 token 立刻失效。
这就是「登出/封号」的痛点。补救办法通常是黑名单:把要作废的 token(或它的 jti)存进 Redis,每次验签再查一遍黑名单。但这么一搞,服务端又变成有状态了——JWT 的爽点被吃掉一半。
所以实践里靠缩短有效期来缓解:token 活得越短,作废窗口越小。
2. 过期时间的两难
- 有效期太长:泄露了危害大,且撤销不了。
- 有效期太短:用户几分钟就被踢出去重新登录。
答案是双 token 方案,见下一节。
3. 存哪里都是权衡
| 存储位置 | 风险 |
|---|---|
localStorage | JS 能读到 → XSS 一打一个准 |
httpOnly Cookie | JS 读不到,但浏览器会自动带上 → CSRF 风险 |
没有完美选项,只能配 SameSite、CSRF token、CSP 等一起用。
4. 算法相关的攻击
alg: none:早期一些库不校验算法,攻击者把alg改成none、删掉签名,就绕过了验证。现代库默认拒绝。- 算法混淆:你把系统配成 RS256(公钥验签),攻击者把 header 改成 HS256,然后拿公开的公钥当 HMAC 密钥去签,有些实现会验过。所以要在服务端写死允许的算法,别信 token 里的
alg。
5. 密钥要够硬
HS256 的密钥一旦泄露,谁都能伪造任意用户的 token。别用弱密钥,别提交到仓库,该轮换就轮换。
六、和 Session 怎么选
| 维度 | Session | JWT |
|---|---|---|
| 状态存哪 | 服务端 | 客户端 |
| 服务端存储 | 需要(内存/Redis) | 不需要 |
| 横向扩展 | 麻烦(要共享) | 天然支持 |
| 跨域/跨端 | 受限(Cookie) | 友好 |
| 主动登出 | 容易 | 难(要黑名单) |
| 携带数据 | 只带一个 ID | 可带用户信息,省一次查询 |
| 体积 | 小 | 较大(每请求都带) |
判断口诀:
单体应用、要随时踢人下线 → Session
多服务、多端、无状态横向扩展需求 → JWT
既要扩展又要能撤销 → JWT + 短有效期 + 黑名单
七、实战里的典型方案:双 Token
现实中最常见的是 Access Token + Refresh Token 组合,解决「既要安全又要体验」的矛盾:
Access Token 有效期短(如 15 分钟) 用来访问接口,泄露了窗口也小
Refresh Token 有效期长(如 7 天) 专门用来换新的 Access Token
流程:
1. 登录 → 服务器发 access + refresh 两张 token
2. 每次请求带 access
3. access 过期 → 客户端拿 refresh 去 /refresh 换一张新的 access
4. refresh 也过期(或用户登出)→ 重新登录
Refresh Token 一般存得更保险(httpOnly Cookie),并且服务端会存一份——这样需要封号时,删掉这个 refresh 记录,用户最多再用 access 撑十几分钟就彻底登不上了。用短有效期把「无状态」的安全债压到可接受的范围。
小结
- JWT 是为了解决「HTTP 无状态下怎么记住登录」而生的:不把状态存服务器,而是把身份写进一张签名凭证交给客户端。
- 结构是 Header.Payload.Signature 三段 Base64URL,Payload 是明文 JSON,签名只防篡改、不防偷看,敏感信息别放。
- 签名原理很简单:用密钥对前两段算 HMAC,验证时重算比对;HS256 对称、RS256 非对称。
- 最大优点是无状态、易扩展、跨端友好;最大软肋是难撤销、易受 XSS/CSRF/算法攻击。
- 实践中用 短有效期 Access Token + 长有效期 Refresh Token 平衡安全和体验,必要时加黑名单。
- 一句话:Session 把身份存在服务器,JWT 把身份签给客户端;前者好管,后者好扩,选哪个取决于你更怕哪种麻烦。