JWT 该存哪里?localStorage、Cookie、HttpOnly Cookie + 刷新令牌策略

· 约 7 分钟 🎟️ JWT 解析

JWT(JSON Web Token)是现代 Web 应用最常用的认证方案,但”JWT 该存哪里”几乎是个永恒争论——localStorage 怕 XSS、Cookie 怕 CSRF、HttpOnly 又不灵活。这篇讲清三种存储位置的真实威胁模型 + 短期 token + 刷新令牌的工程设计。

三种存储方式的核心对比

维度localStorage普通 CookieHttpOnly Cookie
JS 可读
XSS 抗性
CSRF 抗性
自动带请求✗(手动加 header)
跨域容易(不同源不共享)复杂(需 CORS 配置)复杂
跨子域不共享可共享(设 Domain)可共享
存储大小5-10 MB4 KB4 KB
默认 SameSiteN/ALax(现代浏览器)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。

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 过期:
  跳转登录页

安全要点

  1. Refresh Token Rotation——每次刷新都换新 Refresh,旧的立即失效

    refresh_v1 → /refresh → access_new + refresh_v2
    refresh_v1 再用 → 拒绝(已失效)
  2. 检测异常使用

    • 同一 Refresh 短时间多次使用 → 判定泄露
    • 立即吊销整个会话 + 通知用户
  3. 路径限制

    Set-Cookie: refresh=xxx; HttpOnly; Path=/api/refresh; SameSite=Strict

    /api/refresh 路径才会带这个 cookie

  4. 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 });
});

实战清单

必做

  1. 用 HttpOnly Cookie + SameSite + CSRF token
  2. 短期 Access + 长期 Refresh + Rotation
  3. JWT 只放最小必要信息(不放敏感)
  4. 跨域优先 BFF 同域代理
  5. 注销时清除 Refresh + 加入黑名单

避免

  1. JWT 存 localStorage(XSS 风险)
  2. JWT 放敏感信息(手机号 / 身份证)
  3. 长期 Access Token(30 天)
  4. 不旋转 Refresh Token
  5. 跨域时 CORS 配 * + Allow-Credentials

JWT 的存储争论本质是威胁模型选择——理解 XSS / CSRF 的攻击路径,配合短期 token + 刷新机制,能构建实际可用的认证体系。

❓ 常见问题

JWT 应该存 localStorage 还是 Cookie?

没有"安全的存法",只有"威胁模型决定的取舍"localStorage:(1) 优点 —— JS 直接读写、跨子域不共享(即不会自动带到其他域)、API 简单;(2) 缺点 —— XSS 漏洞会泄露(任何注入的 JS 都能读 localStorage);(3) 不会自动带到请求 —— 必须 fetch 时手动加 Authorization: Bearer <token> 头。普通 Cookie:(1) 优点 —— 自动带到同域请求;(2) 缺点 —— XSS 仍能读 + CSRF 攻击风险(恶意网站让你点击触发请求自动带 cookie)。HttpOnly Cookie:(1) 优点 —— JS 读不了(XSS 拿不走)+ 自动带请求;(2) 缺点 —— CSRF 仍存在风险(cookie 会自动带)+ 跨域复杂(CORS 必须配 credentials 且白名单严格)。实务推荐:(1) 大多数 Web 应用 —— HttpOnly Cookie + SameSite=Lax + CSRF token;(2) SPA + API(同域) —— HttpOnly Cookie 仍是最佳;(3) 跨域 API(多前端) —— Authorization Bearer + 短期 token + 刷新令牌;(4) Native App —— 安全存储(Keychain / Android Keystore)。

把 JWT 存 localStorage 真的有那么危险吗?XSS 不是很罕见吗?

XSS 不罕见,且后果严重XSS 来源:(1) 用户输入未转义直接渲染(最常见);(2) 第三方依赖被供应链攻击(npm / CDN);(3) 富文本编辑器 Markdown / HTML 渲染漏洞;(4) URL 参数 / 哈希参数注入;(5) 存储的数据被服务端没 escape 渲染。真实案例:(1) eBay、Twitter、Yahoo 都被报告过 XSS;(2) 2022 年某大型电商网站 XSS → 大量用户 token 被盗;(3) WordPress 插件 XSS 几乎每月都报。XSS 后的灾难:(1) localStorage.getItem("token") 一行代码窃取;(2) 攻击者用此 token 完整冒充用户;(3) 如果 token 长期有效 → 永久 takeover;(4) 攻击者立即静默修改用户数据 / 转账。对比:(1) HttpOnly Cookie —— 即使有 XSS,攻击者也读不到 cookie 内容(只能在受害者浏览器内发请求);(2) CSRF —— 已有 SameSite=Lax 防御 + CSRF token 二次防御;(3) localStorage —— 单层防御,XSS 一破即失。结论localStorage 不应用于关键 token,除非业务上 XSS 风险确认极低(如纯静态站、严格 CSP、没有用户输入)。

HttpOnly Cookie 怎么和 SPA 配合?跨域怎么处理?

同域简单,跨域复杂但可行同域场景(API 和 SPA 都在 example.com):(1) 登录 → 服务端 set HttpOnly Cookie;(2) SPA fetch API → 浏览器自动带 cookie;(3) 注销 → 服务端 set 同名 Cookie + Max-Age=0 清除;(4) 几乎不需要前端改动 —— 是最简单 + 安全的方案跨域场景(API 在 api.example.com,SPA 在 app.example.com):(1) 同主域——cookie 设 Domain=.example.com 共享;(2) fetch 必须 credentials: "include";(3) 服务端 CORS 必须Access-Control-Allow-Credentials: true + 白名单具体来源(不能是 *)。完全异源(不同主域):(1) cookie 不能共享 → 用 Authorization Bearer + localStorage;(2) 但 localStorage 有 XSS 风险 → 短期 token + 刷新机制;(3) 或用 OAuth flow 在子窗口完成。实务:(1) 优先同域部署 —— api.example.com + app.example.com 共享 cookie;(2) 不可避免跨域时 —— 用 Authorization Bearer + 严格 CORS + 短期 token;(3) 微前端 —— 子应用之间共享 token 通过 postMessage 而不是 localStorage。

短期 token + 刷新令牌的标准流程是什么?

两 token 分工Access Token(短期):(1) 有效期 15-60 分钟;(2) 每次 API 调用都带;(3) 即使被盗,时间窗口短;(4) 包含用户身份和权限。Refresh Token(长期):(1) 有效期 7-30 天;(2) 仅用于换取新 Access Token;(3) 调用频率低(每 30 分钟一次);(4) 必须比 Access Token 更安全存储。流程:(1) 用户登录 → 服务端发 Access + Refresh;(2) Access Token 进 SPA 内存(变量)或 HttpOnly Cookie;(3) Refresh Token 进 HttpOnly Cookie(仅 /refresh 端点);(4) Access 过期 → SPA 调 /refresh → 服务端验证 Refresh → 发新 Access;(5) Refresh 过期 → 重新登录。安全要点:(1) Refresh Token 必须 HttpOnly + Secure + SameSite=Strict;(2) Refresh Token rotation——每次 refresh 都换发新 Refresh,旧的失效,防止被盗后无限刷新;(3) 检测异常 —— 同一 Refresh 在短时间内多次使用 → 判定泄露 → 立即吊销整个会话。陷阱:(1) 仅做 Access 短期但 Refresh 长期没 rotation —— 防御等于 0;(2) Refresh 存 localStorage —— 比 Access 更危险(长期);(3) 不限制 Refresh 调用频率 —— 攻击者可暴力破解。

SameSite cookie 三种设置有什么区别?怎么选?

SameSite 是防 CSRF 的关键三种设置:(1) Strict —— cookie 永不自动带到第三方网站发起的请求;(2) Lax(现代浏览器默认)—— cookie 带到 顶层导航 GET(如点击链接),不带到嵌入式资源(图片 / iframe)和 POST;(3) None —— cookie 任何跨站请求都带(过去的默认)。典型攻击:(1) 攻击者建恶意网站 evil.com;(2) 受害者访问 evil.com;(3) evil.com 提交 POST 到 bank.com/transfer;(4) 浏览器自动带 bank.com 的 cookie;(5) 转账成功!SameSite 防御:(1) Strict —— 完全阻止;(2) Lax —— 阻止 POST,但 GET 仍可(部分支付链接 GET 也危险);(3) None —— 不防御。何时用 None:(1) 真正需要跨站访问(嵌入式 widget、第三方支付重定向);(2) 必须配 Secure 标志(HTTPS Only)—— 现代浏览器要求;(3) 同时要其他防御(CSRF token、Referer 检查)。实务:(1) 登录 cookie / Refresh Token → SameSite=Strict + HttpOnly + Secure;(2) 一般会话 cookie → Lax 默认即可;(3) 需要跨站 SSO → None + 完整 CSRF 防御。

JWT 撤销(注销)怎么做?JWT 不是无状态的吗?

JWT 设计上是无状态的——所以撤销很难矛盾:(1) JWT 签名后服务端不需要查数据库即可验证;(2) 因此服务端没有状态记住"谁的 token 已注销";(3) 注销后 token 仍然技术上有效到过期。解决方案:(1) 短 Access Token + Refresh Token(最常用):① Access 有效期 15 分钟;② 注销时 删除客户端 Refresh + 加入吊销列表;③ Access 自然过期后无法刷新;④ 缺点:注销后还有 15 分钟窗口期。(2) 黑名单:① Redis 存吊销的 token jti(JWT ID);② 验证时查黑名单;③ 缺点:又"有状态"了,但 Redis 性能足够;(3) 版本号:① 用户表加 token_version 字段;② JWT 含此字段;③ 注销 / 改密码 → version + 1;④ 验证时比对;⑤ 缺点:不能精细控制单个 token。(4) 白名单:① 仅记录有效 token;② 完全状态化;③ 实际上回到 session 模型 —— 失去 JWT 的意义。实务:(1) 大多数业务用方案 1(短 Access + 黑名单 Refresh);(2) 关键账户管理(密码修改 / 安全敏感操作)→ 立即吊销所有 token + 强制重登;(3) 不支持注销的"无状态" JWT —— 不要用于关键业务。

JWT vs Session vs OAuth Token,怎么选?

三者适用不同场景Session(cookie + 服务端 session 表):(1) 优点 —— 服务端完全控制(即时撤销 / 改权限 / 限会话数);(2) 缺点 —— 有状态(数据库压力 + 多机同步复杂);(3) 适合 —— 单服务器单租户、小型应用、需要细粒度控制。JWT:(1) 优点 —— 无状态(横向扩展简单 + 跨服务传播身份);(2) 缺点 —— 撤销难、token 越大请求越慢、签名密钥泄露后影响范围大;(3) 适合 —— 微服务、横向扩展、SPA + API。OAuth Token(如 OAuth 2.0):(1) 优点 —— 标准化的"代理授权" 协议;(2) 复杂度高 —— 需要授权服务器;(3) 适合 —— 第三方授权("用 GitHub 登录")、API 平台开放、企业 SSO。实务:(1) 传统 Web 应用 → Session(最简单);(2) 现代 SPA + API → JWT 短 Access + 长 Refresh;(3) 第三方应用集成 → OAuth 2.0;(4) 企业 SSO → OAuth + OIDC;(5) 混合架构 —— 内部用 Session,对外开放用 OAuth。

JWT 里能放敏感信息吗?比如手机号 / 身份证?

绝对不能JWT 是 Base64 编码不是加密:(1) 任何拿到 JWT 的人 —— jwt.io 直接解码看到内容;(2) 服务端签名只保证不被篡改,不保证保密;(3) JWT 在请求中传输 —— 中间人 / 浏览器扩展 / 日志都能看到。典型错误:(1) Payload 里放完整身份证号、手机号、地址;(2) 自定义 claim 含敏感业务数据;(3) "因为前端要用所以放进 token 让前端读"。正确做法:(1) JWT 只放身份标识(user_id、role)和最小必要信息;(2) 敏感信息通过 API 单独获取(GET /me);(3) 前端如需展示——调 API 拿;(4) JWT 越小越好(每次请求都带)。JWE(加密 JWT):(1) 标准的 JWT 加密扩展;(2) 内容加密(不只是签名);(3) 实际很少用 —— 复杂度高且不解决根本问题。实务:(1) 绝不在 JWT 放:身份证、手机号、密码、银行卡、API key、邮箱(如果敏感);(2) 可以放:user_id、role、permissions、expires;(3) 把 JWT 视为"公开数据"——任何有 token 的人都能读。

🎟️ 打开 JWT 解析 Token 三段着色 · exp/iat 时间展示 · 本地解析

📖 同一工具的其他教程