如何使用 JWT 进行 Web 身份认证:实战完整指南
全方位掌握 JWT 在 Web 身份认证中的实战应用:双 Token 架构、防御 XSS 与 CSRF 的安全存储方案以及 Node.js 中间件代码实现。
JSON Web Token (JWT) 是现代 Web 开发中最为主流的身份认证标准之一。从单页应用(React、Vue、Svelte)到微服务集群和移动端 API,JWT 无处不在。
然而,如何正确且安全地使用 JWT 却始终是许多工程师面临的棘手问题:Token 应该存在哪?过期后如何无感刷新?如何同时兼顾防御 XSS 与 CSRF 攻击?
本文将从基础直观隐喻出发,深度拆解 JWT 在 Web 认证中的实战架构与落地细节。
1. 10 岁小孩也能听懂的解释(ELI5):储物柜钥匙牌 vs. 电子防伪手环#
为了直观理解 JWT 的优势,我们可以用游乐园入园的场景,将它与传统的 Session 会话认证进行对比:
传统模式(Session 认证):储物柜号牌与总账本#
- 入园时,前台发给你一个储物柜号牌(Session ID)。
- 你的所有个人信息(姓名、VIP 等级、入园时间)都写在前台的总账本(数据库或 Redis)上。
- 每次你想玩过山车,工作人员都要用对讲机呼叫前台:“请查一下总账本,102 号牌是 VIP 吗?”。
- 瓶颈所在: 当有 10 万名游客同时游玩 50 个项目时,前台对讲机线路会被海量的查账请求彻底打爆。
现代模式(JWT 认证):盖有数字防伪印章的智能手环#
- 入园时,前台发给你一个智能手环(JWT)。
- 手环上直接印着你的信息:
姓名: Alice, 等级: VIP, 到期时间: 18:00。 - 关键在于,园方在手环上盖了一个不可伪造的密码学数字印章(数字签名)。
- 当你进入任何游乐设施时,工作人员只需当场核验印章的真伪,并直接读取手环上的
VIP信息——全程无需向中心总账本发起任何查询。
flowchart TD
subgraph Traditional["1. 传统 Session 认证 - Stateful"]
Client1["Client"] -->|发送 Session ID| Server1["App Server"]
Server1 -->|每次请求都要查数据库| DB["Database / Redis<br/>Session Store"]
end
subgraph JWTWay["2. JWT 认证 - Stateless"]
Client2["Client"] -->|发送 JWT Token| Server2["App Server"]
Server2 -->|本地直接校验数字签名| Server2
end
2. JSON Web Token 的三大组成结构#
一个完整的 JWT 是由三部分经 Base64URL 编码后用英文句点(.)拼接而成的字符串:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiQWxpY2UiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MDAwMDAwMDB9.4a5b6c...text- Header(头部): 声明令牌类型(
JWT)和所采用的签名加密算法(如HS256、RS256)。 - Payload(负载): 携带业务身份声明(Claims),包含标准字段如
sub(用户 ID)、role(角色权限)、exp(过期时间戳)。 - Signature(签名): 将编码后的 Header 与 Payload 拼接后,使用服务端持有的密钥(Secret Key)生成的防篡改校验哈希。
[!WARNING] Payload 并没有加密! 它仅仅做了 Base64URL 编码,任何人拿到都可以轻松解码查看明文。因此,绝不能在 JWT Payload 中存放密码、银行卡号或敏感隐私数据!
3. 生产级架构:双 Token 机制(Access & Refresh Token)#
如果仅使用单一 Token:
- 有效期设太长(如 30 天) 一旦被拦截盗取,风险极高。
- 有效期设太短(如 15 分钟) 用户频繁被强制退出登录,体验糟糕。
业界的最佳实践是采用双 Token 轮换机制(Dual-Token Pattern):
- Access Token(访问令牌): 寿命极短(10 - 15 分钟),每次请求业务 API 时在 Header 中携带,服务端秒级快速无状态校验。
- Refresh Token(刷新令牌): 寿命较长(7 - 30 天),仅在 Access Token 过期时调用专用接口换取新 Token,无需打扰用户。
flowchart TD
User["Client Browser"]
AuthServer["Auth API Server"]
ResourceServer["Protected Resource Server"]
User -->|1. POST /login - 提交账号密码| AuthServer
AuthServer -->|2. 返回 Access Token 与 Refresh Cookie| User
User -->|3. 携带 Bearer Token 请求业务 API| ResourceServer
ResourceServer -->|4. 校验通过返回业务数据 200 OK| User
User -->|5. Access Token 过期 401 Unauthorized| ResourceServer
User -->|6. POST /refresh - 带 HttpOnly Cookie| AuthServer
AuthServer -->|7. 签发全新 Access Token - Token Rotation| User
4. 前端到底存放在哪里最安全?(XSS vs. CSRF)#
存储位置的选择是前端身份鉴权中最关键的防御决策:
| 存储位置 | XSS 攻击风险 | CSRF 攻击风险 | 综合评估 |
|---|---|---|---|
localStorage / sessionStorage | ❌ 极高(任何注入的恶意 JS 均可偷走) | ✅ 安全(浏览器不会自动携带跨站发送) | 不推荐用于长期凭证 |
普通 Cookie(无 HttpOnly) | ❌ 极高(可通过 document.cookie 读取) | ❌ 高(浏览器跨站自动附带) | 极不安全 |
HttpOnly + Secure + SameSite Cookie | ✅ 安全(JavaScript 完全无法读取) | ✅ 安全(SameSite=Lax/Strict 阻断跨站) | Refresh Token 黄金存储方案 |
| 内存变量(In-Memory JS 状态) | ✅ 安全(关标签即销毁,无法跨页面窃取) | ✅ 安全(不会自动跨站发送) | Access Token 最佳存储方案 |
生产级最佳存储组合:#
- Access Token: 仅保存在前端的内存变量中(如 Pinia、Redux、Zustand、React Context)。
- Refresh Token: 存放在开启了
HttpOnly、Secure和SameSite=Lax(或Strict)属性的 Cookie 中。 - 当用户刷新网页时,前端在后台发起一次无感刷新请求(
/auth/refresh),重新拉取 Access Token 装入内存。
5. Node.js / Express & TypeScript 完整实战代码#
5.1. 登录与颁发 Token#
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret_key';
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret_key';
export async function login(req: Request, res: Response) {
const { email, password } = req.body;
// 1. 验证用户凭据
const user = await validateUserCredentials(email, password);
if (!user) return res.status(401).json({ message: '账号或密码错误' });
// 2. 签发短寿命 Access Token(15 分钟)
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
// 3. 签发长寿命 Refresh Token(7 天)
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
// 4. 将 Refresh Token 写入安全的 HttpOnly Cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 天
});
// 5. 响应体仅返回 Access Token
return res.json({ accessToken });
}typescript5.2. 路由鉴权中间件#
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: '未提供身份认证令牌' });
}
const token = authHeader.split(' ')[1];
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!, (err, decodedUser) => {
if (err) {
return res.status(401).json({ message: '令牌已过期或无效' });
}
(req as any).user = decodedUser;
next();
});
}typescript5.3. 刷新 Token 接口(Token Rotation)#
export async function refreshAccessToken(req: Request, res: Response) {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: '缺少 Refresh Token' });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!, (err: any, decoded: any) => {
if (err) return res.status(403).json({ message: 'Refresh Token 无效或已过期' });
// 签发全新的 Access Token
const newAccessToken = jwt.sign(
{ userId: decoded.userId, role: decoded.role },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: '15m' }
);
return res.json({ accessToken: newAccessToken });
});
}typescript6. 必须防范的 JWT 常见安全漏洞#
[!TIP] JWT 安全自查清单:
- 强加密密钥: 对称签名秘钥长度至少达到 256 位,或使用 RSA (
RS256) / ECDSA (ES256) 非对称加密。- 禁用
"alg": "none"算法: 校验器必须强制白名单验证指定的签名算法。- 验证受众与签发者: 校验
aud(Audience)与iss(Issuer)确保 Token 由受信任的认证中心签发。- 启用令牌轮换(Token Rotation): 每次使用 Refresh Token 换取新令牌时,同时吊销旧 Refresh Token 并下发新 Refresh Token。
7. 总结#
在 Web 鉴权中使用 JWT,能够为现代分布式服务带来无与伦比的高性能与水平扩展能力。通过践行双 Token 架构、将 Access Token 隔离在内存中、并将 Refresh Token 锁定在 HttpOnly SameSite Cookie 中,你可以构建出兼具丝滑交互体验与企业级安全强度的现代鉴权系统。