JWT(JSON Web Token)详解:Web应用身份认证的基石

JWT(JSON Web Token)详解:Web应用身份认证的基石

JWT详解

引言:为什么需要JWT?

假设你开发了一个Web应用,用户登录后,服务器如何识别”这个请求是谁发的”?

传统做法是Session:服务器存一份用户状态,每次请求带上Session ID。

但Session有个问题:服务器要存状态。用户多了,内存压力大;多节点部署,还要共享Session。

JWT(JSON Web Token)提出了一个不同的思路:把用户信息直接打包进Token里,服务器不需要存状态。

今天,我用最白话的方式,给你讲清楚JWT到底是什么、怎么用、有什么坑。

第一章:JWT长什么样?

一句话解释

JWT就是一个字符串,长得像这样:

1
2
3
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

三段字符串,用.连接。每段都有含义。

三段分别是什么?

1
2
3
Header.Payload.Signature
↑ ↑ ↑
头部 载荷 签名

Header(头部):说明这是什么类型的Token,用什么算法签名。

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

Payload(载荷):存放实际的数据(叫”claims”,声明)。

1
2
3
4
5
6
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622
}

Signature(签名):用私钥对Header和Payload做签名,防止篡改。

三段都是Base64Url编码

注意:Header和Payload都是明文! 任何人拿到Token都能解码看到内容。

1
2
3
4
5
6
7
# 解码Header
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | base64 -d
# 输出: {"alg":"HS256","typ":"JWT"}

# 解码Payload
echo "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ" | base64 -d
# 输出: {"sub":"1234567890","name":"John Doe","iat":1516239022}

这就是JWT最大的风险点——不要把密码、敏感信息放进Payload!

第二章:JWT的工作原理

认证流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
用户登录                    颁发Token              携带Token
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 前端 │───▶│ 后端验证账号密码 │───▶│ 生成JWT返回前端 │
└─────────┘ └──────────────────┘ └──────────────────┘
│
▼
前端存入localStorage/Cookie
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ API请求 │ │ 页面刷新 │ │ 新标签页 │
│ 带Token │ │ Token还在│ │ Token还在│
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└───────────────────┼───────────────────┘
▼
┌──────────────────┐
│ 后端验证签名 │
│ 不需要查数据库 │
└──────────────────┘

关键区别:

  • Session:每次请求,后端去数据库查Session
  • JWT:每次请求,后端验证Token签名,不查数据库

签发流程(后端代码示例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const jwt = require('jsonwebtoken');

// 用户登录成功
const user = { id: 1, username: 'diebug' };

// 签发JWT
const token = jwt.sign(
{
sub: user.id, // 主题(用户ID)
username: user.username,
role: 'user'
},
'your-256-bit-secret', // 签名密钥(千万不要泄露!)
{
expiresIn: '1h' // 1小时后过期
}
);

// 返回给前端
res.json({ token });

验证流程(后端代码示例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 中间件:验证JWT
function authenticate(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');

if (!token) {
return res.status(401).json({ error: '未授权' });
}

try {
// 验证签名 + 检查过期
const decoded = jwt.verify(token, 'your-256-bit-secret');
req.user = decoded; // 把用户信息挂到请求上
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token已过期' });
}
return res.status(401).json({ error: 'Token无效' });
}
}

// 保护路由
app.get('/api/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});

第三章:JWT的签名算法

两种签名方式

1. HMAC(对称签名)

1
HS256 = HMAC_SHA256(base64Url(header) + "." + base64Url(payload), secret)
  • 签发和验证用同一个密钥
  • 简单快速
  • 适合单体应用

2. RSASSA(非对称签名)

1
RS256 = RSA_SHA256(base64Url(header) + "." + base64Url(payload), privateKey)
  • 签发用私钥,验证用公钥
  • 公钥可以公开,私钥必须保密
  • 适合分布式系统(多个服务验证Token)

选哪种算法?

场景 推荐算法
单体应用 HS256
微服务架构 RS256
需要密钥轮换 ES256 (ECDSA)
生产环境 至少HS256或RS256

⚠️ 致命陷阱:alg: “none”攻击

有些JWT库允许alg: "none"——意思是不签名。

1
2
3
4
{
"alg": "none",
"typ": "JWT"
}

如果后端代码没有校验算法,攻击者可以:

  1. 把Header改成alg: none
  2. 伪造任意Payload
  3. 留空Signature

结果:攻击者可以伪造任意用户的Token!

1
2
3
4
5
6
7
8
9
10
11
12
# 攻击示例
import base64, hmac, hashlib

# 1. 伪造Header
header = base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').rstrip(b'=')

# 2. 伪造Payload(管理员权限)
payload = base64.urlsafe_b64encode(b'{"sub":"1","role":"admin"}').rstrip(b'=')

# 3. 签名留空
token = header.decode() + "." + payload.decode() + "."
print(token)

防御:后端必须明确指定允许的算法,拒绝none。

第四章:JWT vs Session — 该选谁?

Session的特点

1
2
3
4
5
6
7
8
9
优点:
✅ 服务端可控,可以随时吊销
✅ Token可以很长,安全性高
✅ 不需要前端存储Token

缺点:
❌ 服务端要存状态(内存/Redis)
❌ 多节点需要共享Session
❌ 跨域需要处理CSRF

JWT的特点

1
2
3
4
5
6
7
8
9
优点:
✅ 无状态,服务端不需要存Token
✅ 适合分布式/微服务架构
✅ 天然支持跨域

缺点:
❌ Token不可撤销(除非加黑名单)
❌ Payload是明文,不能存敏感信息
❌ Token一旦被截获,有效期内都能用

怎么选?

场景 推荐方案
单体应用 Session(简单可靠)
微服务/分布式 JWT(无状态优势)
移动端App JWT(跨域友好)
需要强控制(随时吊销) Session 或 JWT + 黑名单
安全要求极高 OAuth2 + Refresh Token

第五章:JWT的安全最佳实践

实践1:密钥要足够长且随机

1
2
3
4
5
6
7
// ❌ 错误:密钥太短、太简单
jwt.sign(payload, 'secret')

// ✅ 正确:至少256位随机密钥
const crypto = require('crypto');
const secret = crypto.randomBytes(64).toString('hex');
jwt.sign(payload, secret)

密钥长度建议:至少256位(32字节)

实践2:设置合理的过期时间

1
2
3
4
5
6
7
8
// ❌ 错误:永不过期
jwt.sign(payload, secret) // 默认永不过期!

// ✅ 正确:短期Access Token
jwt.sign(payload, secret, { expiresIn: '15m' })

// ✅ 搭配Refresh Token长期有效
jwt.sign(refreshPayload, secret, { expiresIn: '7d' })

建议:Access Token短期(15分钟),Refresh Token长期(7-30天)

实践3:不要把敏感信息放进Payload

1
2
3
4
5
6
7
8
// ❌ 错误:密码明文放Payload
jwt.sign({ password: '123456' }, secret)

// ❌ 错误:身份证号放Payload
jwt.sign({ idCard: '110101199001011234' }, secret)

// ✅ 正确:只放必要信息
jwt.sign({ sub: userId, role: 'admin' }, secret)

记住:Payload是Base64编码的,不是加密的!任何人都能解码看到。

实践4:验证时检查所有字段

1
2
3
4
5
6
// ✅ 正确:完整的验证
jwt.verify(token, secret, {
algorithms: ['HS256'], // 只允许HS256
issuer: 'https://your-domain.com', // 检查签发者
audience: 'your-app' // 检查受众
})

实践5:实现Token吊销机制

JWT本身是无状态的,但生产环境需要能吊销Token。

方案1:黑名单(最简单)

1
2
3
4
5
6
7
8
9
10
11
12
13
const revokedTokens = new Set();

function revokeToken(token) {
revokedTokens.add(token);
}

function verifyToken(token) {
const decoded = jwt.verify(token, secret);
if (revokedTokens.has(token)) {
throw new Error('Token已吊销');
}
return decoded;
}

方案2:短寿命Token + 数据库状态(推荐)

1
2
3
// Access Token只生效15分钟
// 用户注销时,在数据库标记该用户当前Token失效
// 下次请求时,后端检查数据库

实践6:使用HTTPS

1
2
❌ HTTP + JWT = Token明文传输,中间人可直接截获
✅ HTTPS + JWT = Token传输加密

没有HTTPS,JWT形同虚设。

实践7:Secure + HttpOnly Cookie存储

1
2
3
4
5
6
7
// ✅ 推荐:存到HttpOnly Cookie,防止XSS窃取
res.cookie('token', jwtToken, {
httpOnly: true, // JavaScript无法读取
secure: true, // 只通过HTTPS传输
sameSite: 'strict', // 防止CSRF
maxAge: 15 * 60 * 1000 // 15分钟
});

第六章:JWT常见漏洞

漏洞1:密钥硬编码

1
2
3
4
5
// ❌ 错误:密钥写在代码里
const secret = 'my-secret-key-123';
jwt.sign(payload, secret);

// 攻击者反编译后,就能伪造任意Token

修复:密钥放到环境变量或密钥管理服务

1
2
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET未配置');

漏洞2:算法混淆攻击

1
2
3
4
5
// ❌ 错误:不限制算法
jwt.verify(token, publicKey); // 攻击者把alg改成HS256,用公钥当私钥签名

// ✅ 正确:明确指定算法
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

漏洞3:过长的Token

1
2
3
4
5
6
7
8
9
10
// ❌ 错误:把大量用户数据塞进Payload
jwt.sign({
id: 1,
name: 'John',
email: 'john@example.com',
roles: ['admin', 'user', 'editor'],
// ... 几百个字段
}, secret);

// 问题:每次请求都传输大量数据,且信息泄露风险增加

修复:Payload只放最小必要信息,其他数据从数据库获取

漏洞4:不检查过期时间

1
2
3
4
5
// ❌ 错误:不检查exp
const decoded = jwt.decode(token); // decode不验证!

// ✅ 正确:用verify验证
const decoded = jwt.verify(token, secret); // verify会检查过期

jwt.decode()只解码不验证,jwt.verify()才会检查签名和过期时间。

第七章:JWT在其他场景的应用

OAuth2中的JWT

OAuth2的Access Token可以是JWT格式:

1
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

好处:资源服务器可以直接验证签名,不需要每次回Authorization Server查询。

Refresh Token模式

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────┐
│ Access Token (短期,15分钟) │
│ - 每次API请求携带 │
│ - 过期后无法使用 │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Refresh Token (长期,7天) │
│ - 存在HttpOnly Cookie中 │
│ - 用于换取新的Access Token │
│ - 不携带在API请求中 │
└─────────────────────────────────────────────┘

好处:Access Token泄露后,攻击者只能用15分钟;Refresh Token在Cookie里,XSS偷不到。

总结

JWT的核心要点

  1. JWT = Header.Payload.Signature,三段Base64Url编码
  2. Payload是明文,不要放敏感信息
  3. 签名防止篡改,验证时用verify而非decode
  4. 设置合理的过期时间,Access Token短期,Refresh Token长期
  5. 密钥要足够长且随机,不要硬编码
  6. 限制算法,防止none和算法混淆攻击
  7. 搭配HTTPS和Secure Cookie,形成完整的安全链条

一句话总结

JWT是无状态认证的利器,但它不是银弹。正确使用JWT,需要理解它的结构、知道它的风险、掌握最佳实践。


延伸阅读