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

· 更新于 2026-05-02 · 约 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
  • 更需要前端显式携带 Authorization 头 → 倾向 Bearer 模式
  • 跨域复杂度高 → 优先想办法缩小跨域面,而不是先扩散 token

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 一起使用。

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 更容易被 XSS 直接读走;Cookie / HttpOnly Cookie 更容易落入 CSRF 和跨域配置问题。工程上常见组合是:同域 Web 应用优先考虑 HttpOnly Cookie;需要前端自己携带 Authorization 头、或跨域形态更复杂时,再考虑 Bearer 模式。

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

风险不低,而且后果通常直接落到会话劫持。一旦站点存在可利用的 XSS,攻击脚本往往可以直接读取 localStorage 里的 token;而 HttpOnly Cookie 至少能把“直接读走会话材料”这一步挡住。是否能接受 localStorage,取决于你对 XSS 暴露面的判断,而不是一句“大家都这么用”。

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

同域最简单,跨域最容易把问题复杂化。同域或同主域时,HttpOnly Cookie 往往是更省事的方案;完全异源时,Cookie、CORS、凭证模式、SameSite 和前端携带方式要一起设计。实务上如果能通过同域部署或 BFF 缩小跨域面,通常比在前端四处传播 token 更稳。

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

核心是把短期访问凭证和长期续期凭证分开。常见做法是:Access Token 短有效期,Refresh Token 只用于换新;Refresh 端再配合轮换、吊销和异常检测。至于 Access 放内存、HttpOnly Cookie 还是别的容器,要跟你的同域/跨域部署方式一起看。

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 时间展示 · 本地解析

📖 同一工具的其他教程