Web Authentication & Authorization
HTTP 是无状态的:每一个请求都独立到达服务器,服务器对"上一秒是谁来过"没有任何记忆。但 Web 上几乎所有有用的东西都是有状态的,比如登录态、购物车、用户偏好。所有 Web 认证方案,本质上都是在回答同一个问题:
如何让客户端在每次请求中带上"我是谁",又如何让服务器相信它?
不同方案在"身份信息存哪里"、“以什么形式传输”、"服务器如何验证"上做了不同选择,由此形成了 Cookie、Session、Token 等不同的体系。这篇文章按出现顺序梳理它们,并指出每一步引入的取舍。
几个会反复用到的词
- 认证 (Authentication):确认"你是谁"。用户名密码、短信验证码、指纹都属于这一步。
- 授权 (Authorization):在已知"你是谁"的前提下,决定"你能做什么"。授权通常基于角色或权限组。
- 凭证 (Credentials):用户用来证明身份的信息,比如密码、API key、Access Token 等。
- 单点登录 (Single Sign-On, SSO):在一组互相信任的应用之间,用户登录一次即可访问全部。例如登录网易账号中心后访问网易系子站点时,无需再次登录。
接下来谈到的 Cookie、Session、Token 都是认证流程中的工具,不是认证本身。
Cookie:信息的载体
Cookie 经常被误解为"一种登录机制"。它不是。Cookie 是浏览器与服务器之间的一个键值对存储载体,浏览器把它存在本地,并在后续请求里自动附加到 HTTP 头里。
它的工作流程很短:
- 服务器在响应里加
Set-Cookie头,把若干 K-V 数据"种"到浏览器里。 - 浏览器把它存下来。
- 下次对同一域名发请求时,浏览器自动在
Cookie头里把这些 K-V 带回去。
每条 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。
工作流程:
- 用户登录成功后,服务器创建一份 Session 记录(存内存、文件或数据库),为其生成一个唯一的
Session ID。 - 服务器通过
Set-Cookie把 Session ID 写到浏览器里。Java Servlet 规范把这个 Cookie 命名为 JSESSIONID。 - 客户端后续请求会自动把这个 Cookie 带回来,服务器据此查出 Session 数据,恢复用户身份。
- 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 放在一起,三者的分工就清楚了:类型 客户端持有 服务端是否查存储 典型场景 Session Session 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)
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):攻击者注入脚本到你的页面里,读取你的令牌。
HttpOnlyCookie 可以挡住脚本读取;放在 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 / 自研) 视生态而定。
技术选型很少是"哪种更好",更多是"哪种限制和你的场景更匹配"。把每一种方案的状态去向、扩展性边界、攻击面想清楚,决策自然就有了形状。
