JWT(JSON Web Token)是现代 Web 应用最常用的认证方案,但”JWT 该存哪里”几乎是个永恒争论——localStorage 怕 XSS、Cookie 怕 CSRF、HttpOnly 又不灵活。这篇讲清三种存储位置的真实威胁模型 + 短期 token + 刷新令牌的工程设计。
三种存储方式的核心对比
| 维度 | localStorage | 普通 Cookie | HttpOnly Cookie |
|---|---|---|---|
| JS 可读 | ✓ | ✓ | ✗ |
| XSS 抗性 | ✗ | ✗ | ✓ |
| CSRF 抗性 | ✓ | ✗ | ✗ |
| 自动带请求 | ✗(手动加 header) | ✓ | ✓ |
| 跨域 | 容易(不同源不共享) | 复杂(需 CORS 配置) | 复杂 |
| 跨子域 | 不共享 | 可共享(设 Domain) | 可共享 |
| 存储大小 | 5-10 MB | 4 KB | 4 KB |
| 默认 SameSite | N/A | Lax(现代浏览器) | Lax |
核心:没有完美的存储方式——选择取决于你的威胁模型:
- 主要担心 XSS → HttpOnly Cookie
- 主要担心 CSRF → localStorage + Bearer
- 跨域必须 → 复杂方案
XSS vs CSRF:两种攻击对比
XSS(跨站脚本)
攻击者把恶意 JS 注入你的网站
↓
受害者访问网站
↓
恶意 JS 在浏览器里执行
↓
窃取 localStorage / 操作页面
攻击效果:
- localStorage:恶意脚本一行
localStorage.getItem("token")拿到 token - HttpOnly Cookie:拿不到 cookie 值,但可以在受害者浏览器内发请求(受 SameSite 限制)
CSRF(跨站请求伪造)
受害者已登录 bank.com(带有效 cookie)
↓
受害者被诱导访问 evil.com
↓
evil.com 自动发送请求到 bank.com
↓
浏览器自动带 bank.com 的 cookie
↓
请求被认证为受害者发起
攻击效果:
- 普通 Cookie:自动带请求 → 攻击成功(如果无 SameSite)
- HttpOnly Cookie:仍自动带 → 仍受 CSRF 影响(HttpOnly 不防 CSRF)
- localStorage + Bearer:浏览器不自动带 → 安全
谁防御谁
| 存储 | 防 XSS | 防 CSRF |
|---|---|---|
| localStorage | ✗ | ✓ |
| 普通 Cookie | ✗ | ✗(除非 SameSite) |
| HttpOnly Cookie | ✓ | ✗(除非 SameSite + CSRF token) |
结论:HttpOnly Cookie 防 XSS 但仍需配合 SameSite 和 CSRF token 防 CSRF。
SameSite Cookie 设置
Strict
↓
完全不跨站发送 cookie
即使从外部链接进来也不带
↓ 适合:登录 / 高价值操作
Lax(现代浏览器默认)
↓
GET 顶层导航带 cookie
POST / iframe / 图片不带
↓ 适合:普通会话
None + Secure
↓
任何跨站请求都带
必须 HTTPS
↓ 适合:跨域 SSO / 嵌入式
典型 Cookie 设置:
Set-Cookie: session=abc123;
HttpOnly;
Secure;
SameSite=Lax;
Path=/;
Max-Age=3600
CSRF Token 防御
即使有 SameSite,关键操作仍应配合 CSRF token:
1. 服务端发送页面时生成随机 csrf_token
↓
2. 把 csrf_token 嵌入 HTML 表单(hidden input)
或返回给 SPA 让 SPA 写入 header
↓
3. 用户提交表单
↓
4. 服务端验证 csrf_token 与 session 关联
↓
5. 不匹配 → 拒绝
Double Submit Cookie 模式(无状态):
- csrf_token 既存 cookie 又传 header
- 攻击者无法读 cookie(HttpOnly),所以无法在 header 复制
- 服务端验证 cookie 和 header 是否一致
短期 Access + 长期 Refresh 的设计
登录
↓
服务端发:
Access Token (15 分钟有效)
Refresh Token (30 天有效)
↓
SPA 持久化:
Access:内存变量 / 短期 sessionStorage
Refresh:HttpOnly Cookie(仅 /api/refresh 路径)
↓
正常请求:
fetch + Authorization: Bearer <access>
↓
Access 过期:
fetch /api/refresh (自动带 cookie)
服务端验证 Refresh
发新 Access(旋转)
↓
Refresh 过期:
跳转登录页
安全要点:
-
Refresh Token Rotation——每次刷新都换新 Refresh,旧的立即失效
refresh_v1 → /refresh → access_new + refresh_v2 refresh_v1 再用 → 拒绝(已失效) -
检测异常使用:
- 同一 Refresh 短时间多次使用 → 判定泄露
- 立即吊销整个会话 + 通知用户
-
路径限制:
Set-Cookie: refresh=xxx; HttpOnly; Path=/api/refresh; SameSite=Strict仅
/api/refresh路径才会带这个 cookie -
Access Token 不存 localStorage:
- 内存变量(页面刷新后重新刷新)
- 或短期 cookie(HttpOnly)
不同部署架构的最佳方案
同域 SPA + API(最简单)
example.com → 静态 SPA
example.com/api → API
存储:HttpOnly Cookie + SameSite=Lax
认证:Cookie 自动带
注销:清除 Cookie
优势:最简单 + 最安全。
同主域跨子域
app.example.com → SPA
api.example.com → API
Cookie 设置:Domain=.example.com
fetch:credentials: "include"
CORS:Allow-Credentials + 白名单
注意:CORS 中 Access-Control-Allow-Origin 不能是 *,必须具体来源。
完全跨域
app.com → SPA
api.io → API
无法共享 Cookie
方案 A:Authorization Bearer + localStorage(接受 XSS 风险)
方案 B:Cookie + 严格 CORS(应配置但跨域复杂)
方案 C:BFF(Backend For Frontend)模式 → 加一层同域代理
实务:完全跨域时优先 BFF 模式——前端调用同域代理,代理转发到跨域 API。
微前端
父应用 + 多个子应用
↓
共享 token 方案:
❌ localStorage(每个子应用都能读,XSS 范围扩大)
❌ 每个子应用独立认证(用户体验差)
✓ HttpOnly Cookie(同主域共享)+ postMessage 通信
JWT 撤销的几种方案
方案 1:短期 Access + Refresh(最常用)
Access 15 分钟
Refresh 30 天 + 旋转 + 黑名单
注销 = 删除客户端 Refresh + 服务端加入吊销列表
最坏情况:15 分钟窗口期内仍然有效
方案 2:黑名单(Redis)
注销时把 token jti(JWT ID)加入 Redis 黑名单
验证时查黑名单
TTL 等于 token 剩余有效期
优点:即时生效
缺点:又"有状态"了
方案 3:用户级别版本号
用户表加 token_version 字段
JWT 含 version
验证时比对:JWT.version >= user.token_version
注销 / 改密码 → user.token_version + 1
所有旧 token 失效
方案 4:白名单(实际就是 Session)
仅记录有效 token
完全有状态
等同于 Session,失去 JWT 意义
实务推荐:
- 普通业务 → 方案 1
- 高敏感操作(密码修改)→ 方案 1 + 立即清除所有 Refresh
- 需要即时撤销 → 方案 2(黑名单)
不能放进 JWT 的内容
JWT 的 Payload 是 Base64 编码——不是加密!
任何拿到 JWT 的人 → jwt.io 解码看到内容
绝对不能放:
❌ 身份证号 / 护照号
❌ 手机号 / 邮箱(敏感场景)
❌ 密码 / API Key
❌ 银行卡号
❌ 详细地址
❌ 公司机密数据
可以放:
✓ user_id(数字 ID)
✓ role / permissions
✓ exp(过期时间)
✓ iat(发行时间)
✓ 必要的非敏感 claim
例(合理):
{
"sub": "12345",
"role": "admin",
"exp": 1700000000,
"iat": 1699996400
}
例(错误):
{
"sub": "12345",
"phone": "13800138000", ← 不要!
"id_card": "320...", ← 不要!
"email": "user@example.com" ← 视场景,敏感场景不要
}
工程实践清单
客户端
// 推荐:Access Token 在内存
let accessToken = null;
async function login(username, password) {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
credentials: 'include', // 带 Refresh Cookie
});
const data = await res.json();
accessToken = data.accessToken;
}
async function apiCall(url) {
let res = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
credentials: 'include',
});
if (res.status === 401) {
// Access 过期,刷新
const refreshRes = await fetch('/api/refresh', {
method: 'POST',
credentials: 'include',
});
if (refreshRes.ok) {
const data = await refreshRes.json();
accessToken = data.accessToken;
// 重试原请求
res = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
credentials: 'include',
});
} else {
// Refresh 也过期,跳登录
window.location = '/login';
}
}
return res;
}
服务端
// Express 示例
app.post('/api/login', async (req, res) => {
// ... 验证用户名密码
const accessToken = signJWT(user, '15m');
const refreshToken = signRefresh(user, '30d');
// 存 Refresh 到 DB(用于 rotation 检测)
await db.refresh.create({ user_id: user.id, token: refreshToken });
res.cookie('refresh', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/refresh',
maxAge: 30 * 24 * 3600 * 1000,
});
res.json({ accessToken });
});
app.post('/api/refresh', async (req, res) => {
const refresh = req.cookies.refresh;
// 验证 + 检测 rotation
const user = await verifyAndRotate(refresh);
if (!user) return res.status(401).end();
const newAccess = signJWT(user, '15m');
const newRefresh = signRefresh(user, '30d');
res.cookie('refresh', newRefresh, { /* 同上 */ });
res.json({ accessToken: newAccess });
});
app.post('/api/logout', async (req, res) => {
const refresh = req.cookies.refresh;
await db.refresh.delete({ token: refresh }); // 加入黑名单
res.clearCookie('refresh');
res.json({ ok: true });
});
实战清单
✅ 必做:
- 用 HttpOnly Cookie + SameSite + CSRF token
- 短期 Access + 长期 Refresh + Rotation
- JWT 只放最小必要信息(不放敏感)
- 跨域优先 BFF 同域代理
- 注销时清除 Refresh + 加入黑名单
❌ 避免:
- JWT 存 localStorage(XSS 风险)
- JWT 放敏感信息(手机号 / 身份证)
- 长期 Access Token(30 天)
- 不旋转 Refresh Token
- 跨域时 CORS 配
*+ Allow-Credentials
JWT 的存储争论本质是威胁模型选择——理解 XSS / CSRF 的攻击路径,配合短期 token + 刷新机制,能构建实际可用的认证体系。