JWT 令牌的安全存储方式

FreeGuideOnline 最新 2026-07-10

JWT 令牌的安全存储:从入门到最佳实践

JSON Web Token(JWT)是现代 Web 应用中广泛使用的认证方案。它无状态、可扩展,但同时也带来了一个棘手的问题:令牌该如何安全地存储在客户端?选错存储方式可能导致令牌被窃取,攻击者便可冒充你的用户。本教程将带你一步步理解风险,并掌握工业级的安全存储方案。

1. 理解 JWT 的本质与风险

在决定如何存储令牌之前,你必须清楚自己保护的是什么。JWT 通常由三部分组成(Header、Payload、Signature),并以 Base64 编码。关键是,Payload 部分仅作编码而非加密,这意味着任何人都可以解码并读取其中的数据(如用户 ID、角色、过期时间)。签名只能保证令牌未被篡改,无法隐藏内容。

因此,JWT 应该被视为敏感的凭据,其存储安全的核心目标只有两个:

  • 防止 JavaScript 读取:避免 XSS 攻击窃取令牌。
  • 防止跨站请求自动携带:缓解 CSRF 攻击的风险。

2. 常见但危险的存储方式

在深入安全方案之前,有必要指出两种最常见的错误实践,它们会让你前功尽弃。

2.1 localStorage / sessionStorage

这是前端开发中最直观的做法:登录成功后,直接将 JWT 存入 localStorage.setItem('token', token)

为什么危险?

  • 任何在该源下运行的 JavaScript 都可以通过 localStorage.getItem('token') 访问令牌。
  • 如果应用存在 XSS 漏洞(哪怕是第三方依赖注入了恶意脚本),攻击者可以轻松窃取令牌并发送到远程服务器。
  • localStorage 中的数据没有过期机制,除非手动清除,且无法受到 HttpOnly 等 Cookie 安全标志的保护。

结论:永远不要在 localStoragesessionStorage 中存储 JWT 令牌,除非你的应用完全不需要防范 XSS(这几乎不可能)。

将 JWT 放在 Cookie 中是一个更好的起点,但如果你只是简单地将它种下而没有安全标志,同样等于裸奔。

// 危险示例
document.cookie = `token=${jwt}; path=/`;

如果缺少 HttpOnlySecureSameSite 等属性,Cookie 依然可能被 XSS 读取(无 HttpOnly),或者通过不安全的 HTTP 连接泄露(无 Secure),或遭受 CSRF 攻击(SameSite 设置不当)。

将 JWT 存储在一个经过严格配置的 HttpOnly Cookie 中,是目前公认的前端安全存储最佳实践之一。这种方式由服务器在登陆接口响应头中设置 Cookie,浏览器自动存储并随请求发送。

以下是一个使用 Express 框架的 Node.js 后端示例,展示如何安全地种下包含 JWT 的 Cookie。

res.cookie('access_token', jwtToken, {
  httpOnly: true,      // 禁止 JavaScript 访问,彻底防 XSS 窃取
  secure: true,        // 仅通过 HTTPS 传输,避免中间人攻击
  sameSite: 'Strict',  // 或 'Lax',防止跨站请求携带,缓解 CSRF
  maxAge: 15 * 60 * 1000, // 令牌有效期,与 JWT 过期时间保持一致
  path: '/',           // 限制 Cookie 的作用域路径
  // domain: 'example.com'  如有需要可指定域名
});

每个属性的安全意义:

  • HttpOnly:浏览器禁止 document.cookie 或任何客户端脚本访问该 Cookie,这是对抗 XSS 的核心防线。
  • Secure:指示浏览器只在 HTTPS 连接下发送 Cookie,防止令牌在网络传输中被窃听。
  • SameSite=Strict:浏览器仅在相同站点请求时携带 Cookie,完全阻止 CSRF 攻击。若要用 Strict 可能影响第三方链接跳转体验,可考虑 SameSite=Lax,它允许顶级导航(GET 请求)携带 Cookie,但阻止跨站 POST 等危险动作。
  • maxAge / expires:控制 Cookie 的生命周期,应与 JWT 的 exp 声明一致或更短,实现双重失效。

3.2 读取令牌:前后端协作

当令牌存在 HttpOnly Cookie 中后,前端 JavaScript 代码就无法再读取它。那么如何在客户端判断用户是否登录或获取用户信息呢?

  • 方案 A:后端返回一个非敏感的用户信息接口
    例如 /api/me,该接口根据用户请求中自动携带的 Cookie 验证 JWT,只返回用户公开信息(ID、姓名、角色),不返回令牌本身。
  • 方案 B:分离存储一个“伪令牌”
    在内存中存储一个标志(如 isLoggedIn: true),但不要存储真实令牌。前端仅需知道登录状态即可,所有 API 调用本来就会自动带上 Cookie。

3.3 注意事项

  • CSRF 仍需多重防御:即使设置了 SameSite=Lax,在不支持 SameSite 的旧版浏览器上,仍然建议额外使用自定义请求头(如 X-Requested-With)或CSRF Token来强化防护。
  • 移动端兼容性:如果你同时需要为移动 App 服务,移动端可能不擅长使用 Cookie。此时可改用不透明令牌 + BFF 模式,或为移动端单独提供 API 令牌,部分教程见下一节。

4. 安全存储方案二:避免持久化——内存存储

对于安全要求极高的应用(如银行系统),可以考虑不在任何形式的存储介质中保存令牌,仅将其保存在 JavaScript 运行时的闭包或变量中。

4.1 实现方式

在登录成功后,将 JWT 保存在模块作用域(非全局)的变量里,并通过一个提供令牌的函数供内部 API 模块使用,而不暴露给其他脚本。

// token.js (ES模块)
let accessToken = null;

export function setToken(token) {
  accessToken = token;
}

export function getToken() {
  return accessToken;
}

export function clearToken() {
  accessToken = null;
}

这样,任何外部脚本或第三方库都无法直接窃取这个变量(除非它们能够修改你的源文件并插入代码,但这已经是网站被完全攻陷的情况了)。

4.2 优缺点

优点

  • 完全防止 XSS 窃取(除非攻击者可以执行你的内部函数)。
  • 不留下持久化痕迹,关闭标签页或刷新页面后令牌自动消失。 缺点
  • 不持久:用户刷新页面后需要重新登录,体验不佳。
  • 跨页面共享困难:如果使用了多个 iframe 或多个窗口,内存令牌无法共享。

4.3 提升体验:搭配 Service Worker 或无感刷新

可以通过 Service Worker 将令牌保存在 Worker 的全局作用域,实现页面刷新后令牌仍然存在。或者采用一次性的 Refresh Token(同样设为安全的 HttpOnly Cookie)来在页面加载时自动获取新的 Access Token 存于内存,实现“无感登录”。这实际上将我们引向了更高级的架构:Token 管理策略。

5. 高级架构:BFF 模式与不透明令牌

当你有 Web、移动 App 等多种客户端时,一种更健壮的做法是引入**Backend For Frontend(BFF)**层。BFF 负责管理令牌的存储和会话,前端不再直接接触 JWT。

  • 用户登录后,BFF 会创建一个会话,并将一个不透明的会话 Cookie 发送给浏览器(依然是 HttpOnly; Secure; SameSite=Strict)。
  • 浏览器只拥有这个毫无意义的会话标识,无法从中提取任何用户信息。
  • BFF 持有 JWT,当接收到前端请求时,BFF 将会话 ID 映射到对应的 JWT,然后以服务端身份向资源服务器请求数据。

这种模式完全隔离了前端与令牌,安全性最高。代价是增加了架构复杂度,但对大型项目来说非常值得。

6. 常见问题与误区

Q1:在 Cookie 中存储 JWT,会不会让每次请求都变大?
确实,Cookie 会被自动附加到每个同源请求中,如果 JWT 过大(>1KB),会略微增加带宽消耗。可以通过压缩 Payload(移除不必要字段)、使用较小签名算法(HS256 比 RS256 输出短)来优化。相比于安全性的提升,这点成本完全可以接受。

Q2:SameSite=Strict 导致从邮件链接跳转过来时无法自动登录怎么办?
这是正常的取舍。如果你的应用严重依赖外部链接进入已登录状态,可以使用 SameSite=Lax,它允许顶级导航携带 Cookie,对于安全性和体验是一个不错的平衡。但仍建议配合 CSRF 防御措施。

Q3:如果必须用 localStorage(例如某些跨域特定场景),有没有办法降低风险?
没有“绝对安全”,只能缓解。可以这样做:

  • 极短的有效期(如 5 分钟),并配合 Refresh Token 轮换。
  • 对 Payload 加密(但这很少见,且无法阻止 XSS 直接盗取整个令牌)。
  • 严格的 Content Security Policy (CSP) 以最大限度防范 XSS。

但请记住,任何基于 JavaScript 可读存储的缓解方案,在 XSS 面前都如同纸糊的墙

7. 最佳实践总结

实践项 推荐等级 说明
使用 HttpOnly Cookie 存储 JWT ⭐⭐⭐⭐ 首选方案,阻止脚本读取。
设置 Secure 标志 ⭐⭐⭐⭐ 仅通过 HTTPS 传输,强制要求 TLS。
设置 SameSite=StrictLax ⭐⭐⭐⭐ 有效防御 CSRF。
保持 JWT 体积小,过期时间短 ⭐⭐⭐ 减轻泄露影响,并降低 Cookie 体量。
实行 Refresh Token 轮换 ⭐⭐⭐ 短期 Access Token + 长期 Refresh Token,便于吊销。
实施严格的 CSP ⭐⭐⭐ 作为纵深防御,降低 XSS 成功执行的概率。
不要将敏感数据放入 JWT Payload ⭐⭐⭐⭐ JWT 仅用于标识,敏感信息从服务器端获取。
避免在 URL 或日志中泄露令牌 ⭐⭐⭐⭐ 不要将 JWT 放在查询字符串中,并注意日志过滤。

安全是一种平衡,没有银弹。选择哪种方案取决于你的应用场景、用户基数和安全要求。对于大多数现代 Web 应用,安全的 HttpOnly Cookie + SameSite 已经能够满足需求,且实现成本最低。立即检查你的代码仓库,确保令牌没有被错误地放入 localStorage 吧。

记住:前端安全不仅仅是前端的事情。令牌存储策略必须与后端配置紧密协作。将本文分享给你的后端同事,一起构建更安全的认证流程。