blog.dopana

Back

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 编码后用英文句点(.)拼接而成的字符串:

Header.Payload.Signature\text{Header} . \text{Payload} . \text{Signature}

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiQWxpY2UiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MDAwMDAwMDB9.4a5b6c...
text
  1. Header(头部): 声明令牌类型(JWT)和所采用的签名加密算法(如 HS256RS256)。
  2. Payload(负载): 携带业务身份声明(Claims),包含标准字段如 sub(用户 ID)、role(角色权限)、exp(过期时间戳)。
  3. Signature(签名): 将编码后的 Header 与 Payload 拼接后,使用服务端持有的密钥(Secret Key)生成的防篡改校验哈希。

[!WARNING] Payload 并没有加密! 它仅仅做了 Base64URL 编码,任何人拿到都可以轻松解码查看明文。因此,绝不能在 JWT Payload 中存放密码、银行卡号或敏感隐私数据!

3. 生产级架构:双 Token 机制(Access & Refresh Token)#

如果仅使用单一 Token:

  • 有效期设太长(如 30 天)\rightarrow 一旦被拦截盗取,风险极高。
  • 有效期设太短(如 15 分钟)\rightarrow 用户频繁被强制退出登录,体验糟糕。

业界的最佳实践是采用双 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 最佳存储方案

生产级最佳存储组合:#

  1. Access Token: 仅保存在前端的内存变量中(如 Pinia、Redux、Zustand、React Context)。
  2. Refresh Token: 存放在开启了 HttpOnlySecureSameSite=Lax(或 Strict)属性的 Cookie 中。
  3. 当用户刷新网页时,前端在后台发起一次无感刷新请求(/auth/refresh),重新拉取 Access Token 装入内存。

5. Node.js / Express & TypeScript 完整实战代码#

5.1. 登录与颁发 Token#

5.2. 路由鉴权中间件#

5.3. 刷新 Token 接口(Token Rotation)#

6. 必须防范的 JWT 常见安全漏洞#

[!TIP] JWT 安全自查清单:

  1. 强加密密钥: 对称签名秘钥长度至少达到 256 位,或使用 RSA (RS256) / ECDSA (ES256) 非对称加密。
  2. 禁用 "alg": "none" 算法: 校验器必须强制白名单验证指定的签名算法。
  3. 验证受众与签发者: 校验 aud(Audience)与 iss(Issuer)确保 Token 由受信任的认证中心签发。
  4. 启用令牌轮换(Token Rotation): 每次使用 Refresh Token 换取新令牌时,同时吊销旧 Refresh Token 并下发新 Refresh Token。

7. 总结#

在 Web 鉴权中使用 JWT,能够为现代分布式服务带来无与伦比的高性能与水平扩展能力。通过践行双 Token 架构、将 Access Token 隔离在内存中、并将 Refresh Token 锁定在 HttpOnly SameSite Cookie 中,你可以构建出兼具丝滑交互体验与企业级安全强度的现代鉴权系统。

参考资料#