Web Authentication & Authorization

从 Cookie 到 JWT,Web 是如何绕过 HTTP 无状态特性的,每一步又留下了哪些权衡。

HTTP 是无状态的:每一个请求都独立到达服务器,服务器对"上一秒是谁来过"没有任何记忆。但 Web 上几乎所有有用的东西都是有状态的,比如登录态、购物车、用户偏好。所有 Web 认证方案,本质上都是在回答同一个问题:

如何让客户端在每次请求中带上"我是谁",又如何让服务器相信它?

不同方案在"身份信息存哪里"、“以什么形式传输”、"服务器如何验证"上做了不同选择,由此形成了 Cookie、Session、Token 等不同的体系。这篇文章按出现顺序梳理它们,并指出每一步引入的取舍。

几个会反复用到的词

  • 认证 (Authentication):确认"你是谁"。用户名密码、短信验证码、指纹都属于这一步。
  • 授权 (Authorization):在已知"你是谁"的前提下,决定"你能做什么"。授权通常基于角色或权限组。
  • 凭证 (Credentials):用户用来证明身份的信息,比如密码、API key、Access Token 等。
  • 单点登录 (Single Sign-On, SSO):在一组互相信任的应用之间,用户登录一次即可访问全部。例如登录网易账号中心后访问网易系子站点时,无需再次登录。

接下来谈到的 Cookie、Session、Token 都是认证流程中的工具,不是认证本身。

Cookie:信息的载体

Cookie 经常被误解为"一种登录机制"。它不是。Cookie 是浏览器与服务器之间的一个键值对存储载体,浏览器把它存在本地,并在后续请求里自动附加到 HTTP 头里。

它的工作流程很短:

  1. 服务器在响应里加 Set-Cookie 头,把若干 K-V 数据"种"到浏览器里。
  2. 浏览器把它存下来。
  3. 下次对同一域名发请求时,浏览器自动在 Cookie 头里把这些 K-V 带回去。

cookie
cookie

每条 Cookie 都附带若干属性,决定它的可见范围与生命周期:

属性作用
Name / Value标识符与值。Name 创建后不可改;二进制 Value 需要 BASE64 编码。
Domain / Path决定哪些 URL 可以访问。Domain=.example.com 表示所有子域可见。
Max-Age失效时间(秒)。0 表示立即删除;负数或缺省表示会话级(关闭浏览器即失效)。
Secure只通过 HTTPS 发送。
HttpOnlyJavaScript 无法读取,可防御 XSS 窃取。
SameSite控制跨站请求是否带 Cookie,可防御 CSRF。

Cookie 用途广泛:会话维持、偏好记忆、广告追踪、把身份令牌随请求带回。它只是一个传输和存储的通道,"安全性"取决于上层用它做什么。

Cookie 历史悠久但容量有限(每条约 4KB),且每次请求都会自动发送,造成额外开销。现代浏览器还提供了更专门的存储机制:

机制大致容量持久性是否随请求自动发送
Cookie~4KB / 条Max-Age 决定
Local Storage~5–10MB / 域名显式删除前持续存在
Session Storage~5–10MB / 域名标签页关闭即清除
IndexedDB通常数 GB显式删除前持续存在

规则简单:少量、需要随请求带去的身份/会话信息用 Cookie;大量、只在前端使用的数据用 Local Storage 或 IndexedDB。

CORS (Cross-Origin Resource Sharing) 出于安全考虑默认禁止 Cookie 跨域共享:example.com 设置的 Cookie,otherdomain.com 无法读取。

跨子域共享需要把 Domain 设到父域;跨完全不同的域名,则需要服务器配合 Access-Control-Allow-Credentials: true,并把 SameSite 调整为 None(同时强制 Secure)。这一系列设置每一项都会放宽某种限制,因此必须配合其他防御手段使用。

Session:服务器端记住你

有了 Cookie 这个传输通道,接下来需要解决的是状态本身存哪里。最直觉的方案是把状态留在服务器端,让客户端只持有一个指针,这就是 Session。

工作流程:

  1. 用户登录成功后,服务器创建一份 Session 记录(存内存、文件或数据库),为其生成一个唯一的 Session ID
  2. 服务器通过 Set-Cookie 把 Session ID 写到浏览器里。Java Servlet 规范把这个 Cookie 命名为 JSESSIONID
  3. 客户端后续请求会自动把这个 Cookie 带回来,服务器据此查出 Session 数据,恢复用户身份。
  4. Session 有过期时间,可以是定长 TTL 或滑动窗口,超时则销毁。

这个方案在单机部署里表现非常好:服务端完全掌握状态,可以随时销毁、可以改、可以在 Session 里挂任何数据。代价开始显现于两个场景:

禁用 Cookie 的客户端。某些嵌入式客户端没有 Cookie 支持,或者用户主动关闭了。备选方案是把 Session ID 写进 URL(URL Rewriting,例如 ?sid=xxx)或写进表单的 hidden field。这两种方式都比 Cookie 脆,URL 容易被日志、Referer、剪贴板泄漏。

水平扩展。当服务从单节点变成多节点,Session 数据在哪一台服务器就成了问题。常见的三种解法各有取舍:

  • 粘性会话 (Sticky Session):负载均衡器把同一用户固定到同一台服务器。简单,但一台服务器宕机就会丢失所有它服务的用户的 Session。
  • 会话复制:每台服务器都保存全量 Session 数据,节点间互相同步。无单点丢失,但同步成本随节点数平方级上涨。
  • 共享 Session 存储:把 Session 集中放到 Redis 等共享数据节点上。最常用,但引入了一个新的关键依赖。

第三种方案在生产中最常见,也正是 Token 出现的动机起点之一。既然每次都要查共享存储,能不能干脆让客户端把状态自己带上?

Token:独立的客户端凭证

Session 把"凭证"嵌在浏览器的 Cookie 机制里,让它自动随请求附带。Token 走的是另一条路:把凭证抽成一个独立的字符串,由客户端在每次请求里显式地放进 HTTP 头发回服务器。

跳出 Cookie 自动附带的机制之后,认证就不再受浏览器规则约束。移动端、第三方接入、跨域调用、CLI 工具,任何只会发 HTTP 的客户端都能直接带 Token 请求。

Token 通常以 Authorization: Bearer <token> 的形式放在请求头里。也可以放在请求体或 Cookie 中,但放 Cookie 会重新引入 CSRF 风险(见下文)。

不透明令牌与自包含令牌

“Token” 是个比想象中宽的概念,它的内部实现其实有两种完全不同的路线:

  • 不透明令牌 (Opaque Token):本质上就是一串无意义的随机字符串。服务端发出去的时候把它和用户信息的映射记在自己的存储里。客户端每次带上,服务端拿它当 key 去查后端(内存 / Redis / 数据库),取出真正的用户信息。从服务端视角看,这与 Session 几乎是同构的:状态仍然在服务器侧,只是凭证的传输方式从 Cookie 改成了显式的 HTTP 头。OAuth 2.0 默认签发的 Access Token、传统 API key 都属于这一类。
  • 自包含令牌 (Self-contained Token):把用户信息直接编码进令牌本身,并用签名保护其完整性。服务端不再需要查任何存储,只验签名、解 Payload 就能拿到用户信息。这才真正反转了存储的方向:状态从服务端转移到了令牌内部,服务器从此可以是无状态的,水平扩展不再被共享存储拖累。JWT 是这一类里最流行的实现。

把它和 Session 放在一起,三者的分工就清楚了:

类型客户端持有服务端是否查存储典型场景
SessionSession ID必查传统单体 Web 应用
不透明令牌随机字符串必查OAuth、API key
自包含令牌 (JWT)编码后的 JWT 字符串不查,仅验签名微服务、SPA、移动端

可以看出 Session 和不透明令牌在服务端层面其实非常接近,区别更多在于令牌怎么传输 (Cookie 自动 vs Header 显式) 以及谁负责签发管理。真正引入"无状态服务器"这个新能力的,是 JWT 这类自包含令牌。

下面的流程图以 JWT 为例:

注意第二次请求中服务器不再访问数据库,整个验证只发生在 CPU 里。这是 JWT 相对 Session 最实质的差别。

Access Token 与 Refresh Token

Token 一旦签发,服务器很难单方面让它失效,这是无状态换来的代价。所以工业实践通常用两个 Token 配合:

  • Access Token:短期有效(几分钟到几小时),随每次业务请求发送。即使泄露,攻击者的可用窗口也很短。
  • Refresh Token:长期有效,只用来向认证服务换取新的 Access Token。它本身不参与业务请求,可以放到更安全的位置(HttpOnly Cookie,或仅在受信端点上传输)。

这样既保留了无状态的扩展性,又把"泄露风险 × 时间"压到合理水平。

JWT:自包含令牌的代表实现

前面提到,JWT (JSON Web Token) 是自包含令牌里最常见的格式。它的样子是三段 base64url 编码的字符串,用点号分隔:

xxxxx.yyyyy.zzzzz            header payload signature

Header 声明令牌类型和签名算法:

{	"alg": "HS256",	"typ": "JWT"}

Payload 携带"声明 (Claims)",既可以是标准字段,也可以是自定义业务字段:

{	"sub": "1234567890",	"name": "John Doe",	"admin": true,	"exp": 1735689600}

常见的标准字段:

字段含义
issIssuer,签发者
subSubject,面向的用户
audAudience,接收方
expExpiration Time,过期时间戳
nbfNot Before,生效时间戳
iatIssued At,签发时间戳
jtiJWT ID,唯一标识符,可用于实现一次性令牌或黑名单

Signature 由服务端用密钥对 header.payload 计算得到,用于检测篡改:

HMACSHA256(  base64UrlEncode(header) + "." + base64UrlEncode(payload),  secret)
Warning

secret 是签名密钥,绝不能下发到客户端。验证 JWT 时服务器用同一个密钥(或公钥)重算签名,与令牌里携带的签名比对。

需要特别强调的一点:JWT 只签名不加密。Payload 是 base64url 编码而非加密,任何拿到 Token 的人都能解出来读。所以:

  • 不要把密码、手机号等敏感信息放进 Payload。
  • 真要存敏感字段,自己再对内容加密一层。

优点

  • 无状态验证:任何持有密钥的服务都能独立验证,无需访问中心化的 Session 存储。
  • 跨域跨服务:天然适配微服务和多端架构。
  • 紧凑:base64url 编码后体积小,放进 HTTP 头不成问题。

缺点

  • 难以主动吊销。签发出去就难以收回,这是无状态的代价。常见的妥协是缩短 Access Token 寿命,配合 Refresh Token;如果一定要支持立即吊销,就得在服务端维护一个黑名单(这等于把状态又拉回服务器,向不透明令牌靠拢一步)。
  • Payload 可读。不是缺陷,而是设计,但容易被使用者误用。
  • 密钥管理责任前移。签名密钥泄露相当于全员伪造,必须像数据库密码一样严肃管理。

CSRF 与 XSS:哪种存放方式安全

经常听到"JWT 比 Session 更安全"或者反过来的说法。两者的安全性其实取决于令牌存哪里、怎么发,不取决于令牌本身的格式。

存放方式XSS 风险CSRF 风险
Cookie (HttpOnly)低(JS 无法读)高(浏览器自动附带,会被跨站请求利用)
Cookie (HttpOnly+SameSite)
LocalStorage高(任何脚本注入都能读出)低(浏览器不会自动带)
Authorization中(取决于内存里如何持有)

要点:

  • XSS(Cross Site Scripting):攻击者注入脚本到你的页面里,读取你的令牌。HttpOnly Cookie 可以挡住脚本读取;放在 LocalStorage 里的 JWT 则毫无防御。
  • CSRF(Cross Site Request Forgery):攻击者诱导用户的浏览器对受信站点发起请求,浏览器自动带上 Cookie,请求"看起来"是合法的。把令牌放在 Authorization 头里就能避免,因为浏览器不会替跨站请求自动添加自定义头。SameSite=Lax/Strict 也能从 Cookie 侧封堵这类利用。

所以"JWT 防 CSRF"这种说法只在"JWT 放在 Authorization 头"时成立。把 JWT 放 Cookie 里,CSRF 风险照样存在。

怎么选

  • 单体应用、流量可控:Session-Cookie 简单可靠,不必为了"新潮"换 JWT。
  • 微服务 / 跨域 / 移动端:Token 几乎是必选项。具体选不透明令牌还是 JWT,看你愿不愿意为"无状态验证"付出"难以吊销"的代价。
  • 令牌必须能被主动吊销:要么短 Access Token + Refresh Token,要么接受在服务端维护黑名单(这时不透明令牌或 Session 反而更直接)。
  • SSO:在认证中心签发 Token / 颁发 Session ID,子站点信任即可。具体实现 (OIDC / SAML / 自研) 视生态而定。

技术选型很少是"哪种更好",更多是"哪种限制和你的场景更匹配"。把每一种方案的状态去向、扩展性边界、攻击面想清楚,决策自然就有了形状。

Ref