﻿---
title: Web Authentication & Authorization
date: 2024-10-17
excerpt: 从 Cookie 到 JWT，Web 是如何绕过 HTTP 无状态特性的，每一步又留下了哪些权衡。
tags:
  - Web
  - Authentication
  - Authorization
i18n:
  en: /en/web_auth
updated: 2026-06-11 18:20:57
---

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](https://assets.vluv.space/Java/java_web1/cookie.webp)

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

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

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

<link rel="stylesheet" href="/css/optional/accordion.css">

<div class="accordion">
<details class="accordion-item" name="accordion-web_auth-1">
  <summary>Cookie 不是唯一的客户端存储</summary>

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

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

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

</details>
<details class="accordion-item" name="accordion-web_auth-1">
  <summary>Cookie 与跨域</summary>

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

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

</details>
</div>

## Session：服务器端记住你

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

工作流程：

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

```mermaid
sequenceDiagram
    participant Browser as browser
    participant WebServer as webserver
    participant Database as database

    Note over Browser: 用户登录流程

    Browser->>WebServer: POST /login (name:gjx,pwd:admin)
    activate WebServer

    Note over WebServer,Database: 验证用户凭据
    WebServer->>Database: SELECT * FROM users WHERE name='gjx'
    activate Database
    Database-->>WebServer: User data (id:001, name:gjx, password_hash)
    deactivate Database

    Note over WebServer: 验证密码
    WebServer->>WebServer: Verify password hash

    Note over WebServer,Database: 创建会话
    WebServer->>Database: INSERT INTO sessions (session_id, user_id, created_at) VALUES ('qwe0asd', 001, NOW())
    activate Database
    Database-->>WebServer: Session created successfully
    deactivate Database

    WebServer->>Browser: Response (Set-Cookie:SESSION_ID=qwe0asd)
    deactivate WebServer

    Note over Browser,WebServer: 会话建立完成

    Browser->>WebServer: GET /dashboard (Cookie:SESSION_ID=qwe0asd)
    activate WebServer

    Note over WebServer,Database: 验证会话有效性
    WebServer->>Database: SELECT user_id FROM sessions WHERE session_id='qwe0asd' AND expired_at > NOW()
    activate Database
    Database-->>WebServer: Valid session (user_id: 001)
    deactivate Database

    WebServer->>Browser: Return dashboard page
    deactivate WebServer

    Note over Browser: 用户成功访问仪表板
```

这个方案在单机部署里表现非常好：服务端完全掌握状态，可以随时销毁、可以改、可以在 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 为例：

```mermaid
sequenceDiagram
    participant Browser as browser
    participant WebServer as webserver
    participant Database as database

    Note over Browser: 用户登录流程（JWT）

    Browser->>WebServer: POST /login (name:gjx,pwd:admin)
    activate WebServer

    Note over WebServer,Database: 验证用户凭据
    WebServer->>Database: SELECT * FROM users WHERE name='gjx'
    activate Database
    Database-->>WebServer: User data (id:001, name:gjx, password_hash)
    deactivate Database

    Note over WebServer: 验证密码
    WebServer->>WebServer: Verify password hash

    Note over WebServer: 生成JWT
    WebServer->>WebServer: Sign JWT (user_id=001, exp=...)

    WebServer->>Browser: Response (JWT Token)
    deactivate WebServer

    Note over Browser,WebServer: 客户端保存JWT（localStorage或Cookie）

    Browser->>WebServer: GET /dashboard (Authorization: Bearer <JWT>)
    activate WebServer

    Note over WebServer: 验证JWT
    WebServer->>WebServer: Verify JWT signature & expiration

    alt Token有效
        Note over WebServer: 从JWT解析用户信息
        WebServer->>Browser: Return dashboard page
    else Token无效/过期
        WebServer->>Browser: 401 Unauthorized
    end

    deactivate WebServer

    Note over Browser: 用户访问受保护资源
```

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

### Access Token 与 Refresh Token

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

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

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

## JWT：自包含令牌的代表实现

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

```text
xxxxx.yyyyy.zzzzz
  │     │     │
header payload signature
```

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

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

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

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

常见的标准字段：

| 字段  | 含义                                             |
| ----- | ------------------------------------------------ |
| `iss` | Issuer，签发者                                   |
| `sub` | Subject，面向的用户                              |
| `aud` | Audience，接收方                                 |
| `exp` | Expiration Time，过期时间戳                      |
| `nbf` | Not Before，生效时间戳                           |
| `iat` | Issued At，签发时间戳                            |
| `jti` | JWT ID，唯一标识符，可用于实现一次性令牌或黑名单 |

**Signature** 由服务端用密钥对 `header.payload` 计算得到，用于检测篡改：

```text
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

- [HTTP cookie — Wikipedia](https://en.wikipedia.org/wiki/HTTP_cookie)
- [Using HTTP cookies — MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies)
- [Cookies vs. LocalStorage: Storing Session Data and Beyond](https://supertokens.com/blog/cookies-vs-localstorage-for-sessions-everything-you-need-to-know)
- [What is a JSESSIONID in Java](https://christopher-neve.com/what-is-a-jsessionid-in-java/)
- [jwt.io — Introduction to JSON Web Tokens](https://jwt.io/introduction)
- [JSON Web Token 入门教程 — 阮一峰](https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html)
- [JavaGuide — 多服务器节点下 Session-Cookie 方案如何做?](https://javaguide.cn/system-design/security/basis-of-authority-certification.html)
- [前端安全系列（二）：如何防止 CSRF 攻击？](https://tech.meituan.com/2018/10/11/fe-security-csrf.html)
