前端鉴权的兄弟们:Cookie、Session、Token、JWT、单点登录
Category(分类): Browser, Security, Authentication Status: 已更新
本文保留原文的主线:从 HTTP 无状态讲到 Cookie、服务端 Session、Token、JWT、Refresh Token 和 SSO。旧文中把“Token 等于客户端保存全部状态”、把 Cookie 属性和跨域行为说得过于绝对的部分,统一按现代浏览器和 OAuth/OIDC 实践修正。
认证(Authentication)回答“你是谁”;授权(Authorization)回答“你能做什么”。本文重点讨论认证凭证和会话,业务授权仍必须由服务端执行。
一、从 HTTP 无状态说起
HTTP 请求不会自动记住上一次请求对应的用户。无状态描述的是应用层的请求上下文,而不是说每次请求都要重新建立 TCP 连接:HTTP/1.1 Keep-Alive、HTTP/2 多路复用和 HTTP/3/QUIC 都可以复用连接。
登录系统需要在后续请求中携带一个凭证:
登录凭证 -> 浏览器保存/发送 -> 服务端验证 -> 识别主体 -> 执行授权
凭证可以是:
- 服务端 Session ID;
- 不透明的 Access Token;
- 签名 JWT;
- OAuth/OIDC 授权码换来的本地会话;
- Passkey/WebAuthn 验证后创建的会话。
二、Cookie:浏览器自动携带的凭证容器
Cookie 的典型流程是:
- 登录接口通过
Set-Cookie写入 Cookie; - 浏览器根据域、路径、Secure、SameSite 和过期时间筛选;
- 后续匹配的 HTTP 请求自动带上
Cookie请求头; - 服务端解析 Cookie 并验证其中的会话标识。

1. Domain 和 Path
Set-Cookie: session=abc; Domain=example.com; Path=/app
- 不设置
Domain时是 host-only Cookie,只发送给设置它的主机,不会自动覆盖所有子域; - 设置
Domain=example.com后,符合规则的子域可能也会收到它; Path限制发送路径,但不是强安全边界,不能用它隔离高权限和低权限应用;- 不要为了“跨子域共享”而随意把会话 Cookie 设置到顶级主域,低信任子域被接管后可能影响整个主域。
2. Expires 和 Max-Age
Set-Cookie: session=abc; Max-Age=1800
Max-Age表示从现在开始的秒数;Expires是绝对日期,兼容旧客户端;- 两者同时存在时通常
Max-Age优先; - 两者都没有时,通常是会话 Cookie,但浏览器对“关闭浏览器”的处理和会话恢复会影响直觉;
- 退出登录不能只等 Cookie 过期,服务端也要撤销或删除会话。
3. Secure、HttpOnly 和 SameSite
推荐的会话 Cookie 形式:
Set-Cookie: __Host-session=RANDOM_ID; Path=/; Max-Age=1800; Secure; HttpOnly; SameSite=Lax
Secure:只通过 HTTPS 发送;HttpOnly:禁止 JavaScript 通过document.cookie读取;SameSite=Lax/Strict/None:控制跨站请求携带规则。SameSite=None必须同时使用Secure;__Host-前缀要求Secure、Path=/且不能设置Domain,可降低错误的跨子域覆盖。
HttpOnly 不能阻止 XSS 代码借助当前页面发起已认证请求,所以仍要做输出编码、CSP、依赖安全和 CSRF 防护。Cookie 自动携带也不意味着“必然无法防御 CSRF”,可以组合 SameSite、CSRF Token、Origin/Referer 校验和重新认证。
4. HTTP 头与 JavaScript 操作 Cookie
服务端可以一次响应多个 Cookie,每个 Cookie 使用单独的 Set-Cookie 头:
Set-Cookie: theme=dark; Path=/
Set-Cookie: session=abc; Path=/; HttpOnly; Secure; SameSite=Lax
请求头会把符合条件的非过期 Cookie 合并:
Cookie: theme=dark; session=abc
JavaScript 只能读取和设置非 HttpOnly Cookie,而且一次 document.cookie = ... 只设置一条:
document.cookie = 'theme=dark; Path=/; SameSite=Lax'
console.log(document.cookie)
前端不能通过 document.cookie 设置 HttpOnly。原文把 HttpOnly 写在前端赋值示例中是不正确的。
三、服务端 Session:Cookie 只保存 ID
Session 方案把状态保存在服务端,浏览器只保存一个不可预测的 Session ID:
- 用户提交账号密码;
- 服务端验证密码哈希、账号状态和风险策略;
- 服务端生成高熵 Session ID,并把用户、租户、过期时间、会话版本等写入 Session Store;
- 通过 HttpOnly Cookie 返回 Session ID;
- 后续请求自动带 Cookie;
- 服务端查找并验证 Session,再执行资源授权。
1. Session 存储
- 进程内存:开发简单,重启即失效,不适合多实例;
- Redis 等共享存储:常见的生产方案,适合 TTL、撤销和多实例;
- 数据库:便于审计和持久化,但需要关注访问性能;
- 加密/签名 Cookie Session:适合少量状态,但 Cookie 大小、密钥轮换、撤销和隐私仍需设计。
“Session 一定会把服务器压垮”并不成立。共享 Session Store、过期淘汰、水平扩展和合理 TTL 可以支撑大量会话。负载均衡的 IP Hash 只是旧式选择,会降低均衡效果,也不能解决机器宕机;通常优先使用共享存储。
2. Session 安全
- 登录成功后轮换 ID,防止 Session Fixation;
- ID 使用密码学安全随机数,不使用用户 ID、时间戳或自增数字;
- 设置空闲超时和绝对过期时间;
- 登出、改密、封禁、权限提升时撤销或轮换会话;
- 重要操作可以要求重新认证或 MFA;
- 服务端不要把密码明文写入 Session、日志或 Token;
- 记录设备、会话创建时间、撤销原因和必要的审计信息。
Node/Express 中可使用成熟的 session 中间件,但生产环境不要使用默认的内存 Store:
app.use(session({
name: '__Host-session',
secret: process.env.SESSION_SECRET,
store: redisStore,
resave: false,
saveUninitialized: false,
cookie: {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 60 * 1000
}
}))
示例中的 redisStore 需要选择与框架版本匹配的正式实现,并配置连接、故障处理和密钥管理。
四、Token:凭证不等于 JWT
Token 是凭证的泛称,可以是随机字符串,也可以是自包含的签名结构。它不一定通过 Cookie 传输,也不一定把所有用户信息放在客户端。

常见传输方式:
Authorization: Bearer ACCESS_TOKEN
或者:
Cookie: __Host-session=SESSION_ID
1. Opaque Token 和自包含 Token
Opaque Token 只是不可预测的随机值,服务端根据它查询状态,和 Session ID 很像;优点是容易撤销、内容不泄露,缺点是需要服务端存储或查询。
自包含 Token 中包含声明,服务端可以验证签名后读取,JWT 是最常见格式;它减少部分查询,但撤销、权限变更、Refresh Token、密钥轮换仍然需要服务端策略。
因此不要简单写成:
Session = 服务端有状态
Token = 服务端完全无状态
真实系统经常是混合的:短期 JWT Access Token + 服务端存储的 Refresh Token,或者 OAuth Token + 本站 HttpOnly Session。
2. Token 保存位置
| 位置 | 特点 |
|---|---|
| 内存 | XSS 持久性较低,刷新后丢失 |
localStorage | 方便但同源 XSS 可读取,不适合长期高价值令牌 |
| HttpOnly Cookie | JavaScript 不能读取,但自动发送,需要 CSRF 防护 |
| IndexedDB | 结构化持久化,但同样受同源脚本访问,不能抵御 XSS |
若浏览器应用不需要直接访问第三方 API,常用做法是 BFF 或服务端 Session,让 Token 留在服务端。若必须由 SPA 持有 Token,应使用短期 Access Token、Refresh Token Rotation、严格 CSP 和撤销策略,不能通过 URL 传递。
五、Token 编码、签名和加密
1. Base64 不是加密
把 JSON 进行 Base64URL 编码只能改变表现形式:
const encoded = btoa(JSON.stringify({ userId: 'user-42' }))
任何拿到结果的人都可以解码,用户也能修改内容。Base64 不能证明内容可信,也不能隐藏秘密。

2. 签名防篡改
可以使用 HMAC 或非对称数字签名:
- HS256/HMAC:签发和验证使用同一共享密钥,验证方也有生成合法 Token 的能力;
- RS256/PS256/ES256:私钥签发、公钥验证,适合多个服务只需要验证的场景;
- 签名用于检测篡改,不等于加密;
- 服务端必须固定允许算法,不能盲信 Token header 的
alg; - 验证
iss、aud、exp、nbf、iat、jti和业务权限。
原文把 HMAC 说成“用私钥加密”是不准确的:HMAC 使用共享密钥;非对称签名才有私钥和公钥的角色。
import { jwtVerify } from 'jose'
const key = new TextEncoder().encode(process.env.JWT_SECRET)
const { payload } = await jwtVerify(token, key, {
algorithms: ['HS256'],
issuer: 'https://id.example.com',
audience: 'api.example.com'
})
生产代码应使用维护良好的库、密钥轮换和 JWKS 缓存,不要手写完整 JWT 验证器。

3. JWT 的结构
签名 JWT(JWS)通常是三段:
base64url(header).base64url(payload).base64url(signature)
示例 payload:
{
"iss": "https://id.example.com",
"sub": "user-42",
"aud": "api.example.com",
"exp": 1893456000,
"iat": 1893452400,
"scope": "article:read"
}

JWT payload 默认可读,不要放密码、私钥或不应暴露的个人资料。若确实需要保密,应讨论 JWE 或更合适的服务端会话,而不是把 JWS 当加密。
六、Access Token 和 Refresh Token
Access Token 用于访问业务 API,通常应较短;Refresh Token 用于换取新的 Access Token,生命周期更长但访问频率更低。
Access Token -> 调用 API,短期
Refresh Token -> Token Endpoint,换新 Access Token

推荐实践:
- Refresh Token 只发给需要它的客户端类型;
- 使用 Refresh Token Rotation,每次刷新都作废旧 Refresh Token;
- 服务端检测到旧 Refresh Token 重用时,撤销整个令牌族;
- 服务端存储 Refresh Token 的哈希、设备、客户端、过期时间和撤销状态;
- 不把 Token 放在 URL、日志、Referer 和错误追踪数据中;
- 访问令牌过期时只尝试一次刷新,多个标签页需要协调刷新锁,避免刷新风暴;
- Refresh Token 失效后让用户重新认证。
七、Session 和 Token 怎么选择
| 维度 | 服务端 Session | Opaque/JWT Token |
|---|---|---|
| 浏览器保存 | 通常是 HttpOnly Cookie | Cookie、内存或 Authorization 头 |
| 服务端状态 | Session Store | Opaque 需要;JWT 验证本身可少查一次 |
| 立即撤销 | 直接删除/撤销 Session | JWT 需要版本、黑名单或短过期;Refresh Token 可撤销 |
| CSRF | Cookie 自动发送,需要防 CSRF | Authorization 头由脚本主动设置,可降低 CSRF,但 XSS 风险仍在 |
| 扩展 | 需要共享 Session Store | 需要密钥、JWKS、撤销和刷新体系 |
| 适合 | 同域 Web、BFF、管理后台 | 移动端、第三方 API、服务间调用、OAuth/OIDC |
没有“Token 一定比 Session 高级”的结论。优先根据浏览器、移动端、服务间调用、撤销要求、隐私和运维能力选择。
八、单点登录 SSO
1. 同一主域并不等于好的 SSO
过去常见做法是把 Cookie 的 Domain 设置为主域,让 a.example.com 和 b.example.com 共享:
Set-Cookie: session=...; Domain=example.com
这不是跨不同顶级域的通用 SSO,而且会扩大 Cookie 的信任范围:任意低信任子域都可能影响主域 Cookie。现代系统更常让每个业务应用拥有自己的本地会话,由统一 IdP 通过 OIDC 提供登录体验。
2. 不同域名的 SSO

推荐的 Authorization Code + PKCE/OIDC 流程:
- 用户访问 A 系统,没有 A 的本地会话;
- A 将浏览器重定向到 IdP,携带
client_id、精确redirect_uri、state、nonce和 PKCEcode_challenge; - IdP 没有自己的会话时要求用户登录;
- 登录成功后重定向回 A 的 callback,并只携带一次性授权码;
- A 的后端用授权码和
code_verifier与 IdP 换取 Token; - A 验证 issuer、audience、签名、nonce、PKCE 和 code,再创建 A 自己的 HttpOnly Session;
- 用户访问 B 时,B 重复相同过程;因为 IdP 已经有自己的会话,用户通常不需要再次输入密码;
- A 和 B 各自执行自己的业务授权,不能因为 SSO 登录就成为管理员。
授权码可以短暂出现在回调 URL 中,但应使用 HTTPS、一次性、短期、精确回调地址,并在换取后清理 URL,不能直接把长期 Token 放在 URL。
3. 登出
单点退出需要分别考虑:
- 清除业务系统本地 Session;
- 撤销 Refresh Token;
- 调用 OIDC RP-Initiated Logout 或提供商撤销端点;
- 如果 IdP 支持,使用 Front-Channel/Back-Channel Logout;
- 处理其他设备和并发会话。
只删除浏览器本地 Cookie,不一定会让服务器会话、IdP 会话和其他设备退出。
九、密码、OAuth 和现代认证
- 密码服务端使用 Argon2id、bcrypt 或 scrypt 哈希,不能明文保存;
- 登录、重置密码和 MFA 接口需要限速、审计和风险控制;
- OAuth 2.0 是授权委托,登录身份应使用 OIDC 并验证 ID Token;
- 新的浏览器应用优先 Authorization Code + PKCE,不使用旧式 Implicit Flow;
client_secret不能放在纯前端应用中;- Passkey/WebAuthn 使用公钥凭证,私钥留在验证器,能提高抗钓鱼能力。
十、常见错误
- 把 Cookie 认为只能防 Session,不能防 CSRF:Cookie 自动发送会产生风险,但 SameSite、CSRF Token 和 Origin 校验可以组合防护;
- 把 localStorage 认为比 Cookie 安全:它减少自动携带,但 XSS 可以读取;
- 把 Base64 当加密:编码没有保密性和完整性;
- 把 JWT 当成永远不需要服务端状态的 Session:撤销、刷新、权限版本和审计仍需要服务端;
- 把 Domain Cookie 当作跨域 SSO:不同源不能直接共享 Cookie,扩大 Domain 还有子域风险;
- 收到 JWT 就信任
role=admin:必须验证签名和服务端授权策略; - 把第三方 Access Token 直接当本站登录态:应绑定第三方主体,再创建本站会话;
- 把刷新 Token 无限重试:会造成刷新风暴,应该处理并发和失败状态。
总结
- Cookie 是浏览器自动携带的凭证容器,不等于完整的会话方案;
- Session 通常让 Cookie 保存 ID、服务端保存状态,生产环境用共享 Store 解决集群问题;
- Token 是泛称,可以是 opaque token 或 JWT;
- Base64 不是加密,HMAC 共享密钥和非对称签名的密钥模型不同;
- Access Token 短期使用,Refresh Token 要轮换、撤销和检测重用;
- SSO 应优先采用 OAuth 2.0 Authorization Code + PKCE 与 OIDC;
- 每个业务系统仍需独立维护会话、授权、撤销和审计。