Web 认证与通信架构笔记
约 4853 字大约 16 分钟
2026-02-16
目标与威胁模型
认证系统本质是在做两件事:证明“你是谁/你有权限”,以及控制“凭证被偷后能造成多大伤害”
常见威胁模型需要分清
- XSS:攻击脚本能在用户浏览器里运行,很多“把 token 藏起来”的方案只能降低“被带走跨设备复用”,但挡不住“当场用你的身份发请求”
- Token 泄露:日志/抓包/存储被读导致 token 被 exfiltrate(拿走),这类场景 PoP/DPoP 的收益很大
CSRF 跨站请求伪造攻击
大白话讲就是:攻击者利用你已登录的身份(Cookie 还在),诱导你点击恶意链接 / 访问恶意页面,让你的浏览器替攻击者向目标网站发 “合法” 请求,从而完成非你本意的操作(比如转账、改密码、下单)。
CSRF 攻击的完整场景
假设你登录了某银行网站(bank.com),登录后银行给你的浏览器设了login_token Cookie(HttpOnly 但没做 CSRF 防护),且 Cookie 的SameSite是默认值:
- 你没登出银行网站,又打开了攻击者的恶意网站(
hack.com); - 恶意网站里藏了一段代码:
<img src="https://bank.com/api/transfer?to=黑客账号&amount=1000" />; - 你的浏览器加载这张 “图片” 时,会自动带上
bank.com的 Cookie(因为 Cookie 的域匹配),向银行接口发转账请求; - 银行服务器只校验 Cookie 是否有效,没做其他防护,就认为是你本人的操作,完成了转账,这就是 CSRF 攻击。
核心问题:服务器只认 Cookie(身份),但无法判断请求是 “你主动发的” 还是 “攻击者诱导的”。
Cookie 和 CSRF 的核心关联
Cookie 的 “自动携带” 特性是 CSRF 的基础:
- 浏览器有个默认规则:只要请求的域名和 Cookie 的域名匹配,就会自动把该域名下的 Cookie 带上(不管请求是从哪个页面发起的);
- 攻击者正是利用这个规则,让你的浏览器 “无意识” 地带 Cookie 发请求,服务器误以为是合法操作。
防 CSRF(针对 Cookie 的防护手段)
Cookie 设置SameSite属性(最核心)
// 后端设置Cookie时加SameSite(以Express为例)
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // 防XSS
secure: process.env.NODE_ENV === 'production', // 生产环境HTTPS
sameSite: 'Strict', // 仅同站请求(你主动访问bank.com)才带Cookie
// 或用Lax(更宽松:GET请求如跳转可带,POST等写操作不带)
// sameSite: 'Lax',
maxAge: 7 * 24 * 60 * 60 * 1000
});SameSite=Strict:只有你主动访问bank.com时,请求才带 Cookie;从hack.com发的请求,浏览器直接不携带 Cookie,CSRF 攻击直接失效;
SameSite=Lax:是主流默认值,兼顾体验和安全(比如从其他网站点击链接跳转到bank.com的 GET 请求可带 Cookie,POST/PUT/DELETE 等写操作请求不带)。
“Refresh Cookie + Access Token” 结合,只要做好这两点,就完全不怕 CSRF:
- Refresh Token 的 Cookie 设置
SameSite=Strict/Lax; - 刷新 Token 的接口(
/api/refresh)只接受 POST 请求(SameSite=Lax下,跨站 POST 请求不会带 Cookie);
这样即使攻击者诱导请求/api/refresh,也拿不到新的 Access Token,CSRF 攻击就断了根。
还有个手段是验证请求的 “来源 / 出处”,对应技术上的Origin/Referer 校验,服务器通过这两个请求头,判断请求是不是从自己的合法域名发过来的,而非恶意网站。
| 请求头 | 作用 | 场景 |
|---|---|---|
| Origin | 只包含 “协议 + 域名 + 端口”(比如https://bank.com),无路径 / 参数 | 所有跨域请求(POST/PUT/DELETE)都会带,更纯净、优先校验 |
| Referer | 包含完整的请求来源 URL(比如https://bank.com/pay.html) | 部分场景(比如表单提交)会带,可作为 Origin 的兜底 |
注意事项(避免校验失效)
- 不要校验太细:只校验 “域名”(比如
bank.com),不要校验具体路径(比如bank.com/pay.html),否则前端页面路径变化会导致校验失败; - 兼容内部请求:如果有服务器内部调用(比如 SSR 服务调用后端接口),Origin/Referer 可能为空,可加白名单(比如
127.0.0.1、内网 IP); - 不要依赖 Referer 唯一校验:部分浏览器 / 场景会屏蔽 Referer(比如隐私模式),所以 Origin 是首选,Referer 只是兜底。
注意:
| 特征 | Referer | Origin |
|---|---|---|
| 伪造可能性 | 容易(前端可自定义) | 几乎不可能(浏览器强制管控) |
| 管控方 | 前端可修改,浏览器仅做弱校验 | 浏览器内核强制生成,前端无法篡改 |
| 适用场景 | 仅作为 Origin 的兜底(Origin 缺失时) | 跨域请求核心校验字段(首选) |
认证凭证分层思路
长期凭证与短期凭证分离
长期凭证(Refresh / Session ID):尽量放在 HttpOnly Cookie,并缩小作用域(Path)
短期凭证(Access / Session token):尽量放在 JS 内存,每次请求用 header 携带,TTL 短(例如 5–15 分钟)
关键动机
- 长期凭证更怕被偷走“跨设备复用”,所以要尽量让 JS 读不到(HttpOnly)
- 业务请求更可控、CSRF 面积更小,所以业务接口尽量不依赖 cookie 自动携带
Cookie 体系要点
Cookie 的风险不是没有,而是“类型不同”
Cookie 并非“更安全”,而是把主要风险从 XSS 偷 token 转为 CSRF + 配置风险
主要风险与压制方式
- CSRF:cookie 自动携带导致第三方站点可诱导请求,常用 SameSite 与 CSRF Token 组合防护
- XSS:HttpOnly 能挡“读”,挡不住“用”(脚本仍能在浏览器里发请求并自动带 cookie)
- 网络与配置:未设置 Secure 或非 HTTPS 可能被窃取,正确做法全站 HTTPS + Secure
- 会话固定/劫持:登录后 rotate session/refresh,重要操作 re-auth
Cookie 不是“header 的 cookie”
Cookie 写入来自响应头 Set-Cookie,请求发送时在请求头 Cookie 字段里出现
Cookie 4KB 限制与信息放置原则
单个 cookie 常见约 4KB,且同域 cookie 多了会增加每次请求带宽开销
工程建议
- cookie 放短小的凭证(session id / refresh token),不要塞用户资料
- access token 也尽量短(别塞一堆 claim)
多个 Cookie 与同名 key 的规则
cookie 的“唯一标识”不只是 name,而是大致由 (name, domain, path) 决定
同名 cookie 可能并存的常见原因:Path 不同或 Domain 不同
请求头里可能出现同名两次:Cookie: key1=value2; key1=value1
浏览器顺序通常 Path 更具体的在前,但不要指望所有客户端 100% 一致
服务端到底取哪个取决于框架解析策略(取第一个/最后一个/覆盖/保留数组),容易出 bug,工程上建议避免同名
覆盖规则
- 同 name + 同 domain + 同 path:后者覆盖前者
- 同 name 但不同 path/domain:并存
Expires / Max-Age 与系统时间
浏览器判断 Expires / Max-Age 会用本机时间,改系统时间会让 cookie 表现异常(提前/延后/立刻过期)
Expires vs Max-Age
- Expires:绝对时间,更受本机时间影响
- Max-Age:相对秒数,通常更稳一些(但仍由本地时间推进倒计时)
改系统时间最多影响“浏览器是否还愿意发送 cookie”,通常骗不过服务端的会话/令牌校验
JWT 要点与“加密”误区
JWT 默认不是加密,是签名
大多数 JWT(JWS)是签名,用于防篡改与可验证来源,但 不保证保密(payload 可被解码看到)
真要“客户端看不到内容”需要 JWE(加密 JWT) 或自做对称加密,但 Web 场景复杂度更高
Cookie 放 JWT vs Header 放 JWT
cookie 放 JWT(HttpOnly):JS 读不到,更抗 XSS 偷 token,但更容易引入 CSRF,需要 SameSite/CSRF 防护
header 放 JWT:不自动携带,CSRF 面积小;但 token 往往需要存储(localStorage/内存),若落 localStorage,XSS 风险更大
“加密后放 header”本质是 token in header,安全性主要取决于 token 存哪(内存最好、localStorage 最危险)
主流混合方案(Refresh Cookie + Access Token)
混合方案核心
Refresh:HttpOnly Cookie(长期,作用域尽量缩小,如 Path=/auth/refresh)
Access:短命 token 放 JS 内存,每次请求 header:Authorization: Bearer <access>
用户信息不要塞进 token(或尽量少塞),更建议 /me 单独取 profile 并缓存
典型流程
登录
- 后端验证成功后
Set-Cookie写 refresh(HttpOnly/Secure/SameSite/Path),响应 body 返回 access token - 前端把 access 存内存,后续请求都带
Authorization
正常请求:业务 API 只看 header access token(降低 CSRF 面积)
自动续期
- 业务请求 401(access 过期)→ 调
/auth/refresh,浏览器自动带 refresh cookie → 后端校验后返回新 access → 前端更新内存并重放原请求 - 可选:refresh rotation(刷新时换新 refresh,让旧 refresh 失效)
登出:后端作废 refresh,并通过 Set-Cookie 让 refresh 过期(Max-Age=0)
Set-Cookie: refresh=...; HttpOnly; Secure; SameSite=Lax; Path=/auth/refreshOAuth 2.0 DPoP
DPoP 在解决什么
普通 Bearer token:谁拿到 token 谁就能用
DPoP token:必须同时拿到 token + 私钥才能用,防 token 被偷后在别的机器/脚本里重放
DPoP 的核心构件
DPoP proof 是什么
- 每次请求带
DPoP头,它是一个签名 JWT,payload 含htu/htm/iat/jti - access token 里带
cnf,用于把 token 与公钥绑定(sender-constrained)
proof 的结构
DPoP proof 作为 JWT,本身三段:header + payload + signature,签名就是 JWT 第三段
header 里带公钥 jwk,payload 里绑定 URL/方法与防重放字段
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { "kty": "...", "crv": "...", "x": "...", "y": "..." }
}{
"htu": "https://api.example.com/auth/login",
"htm": "POST",
"iat": 1712345678,
"jti": "随机uuid"
}DPoP 完整流程(从登录到业务请求)
生成密钥对
浏览器用 WebCrypto 生成 key pair
- 私钥留在浏览器(内存/IndexedDB,取决于你要不要持久化)
- 公钥用于给服务器验签,后续“塞进 DPoP proof 的 header”里发出
极端敏感场景“关页面就丢”更安全
登录请求阶段
- 登录时还没有 access token,只是
POST /auth/login,header 带 DPoP,body 传 username/password 等业务数据 - 关键点:DPoP proof 不包含 username/password,它只是额外的 header,用来证明“这次请求来自持有私钥的客户端”
POST /auth/login
DPoP: <proofJWT>
Content-Type: application/json
{ "username": "...", "password": "..." }服务端处理登录
- 先验 DPoP proof:用 header 里的 jwk 验签,并检查
htu/htm/iat/jti等约束 - 再验证用户名密码,通过后签发 access token,并把公钥指纹写入
cnf.jkt完成绑定
{
"sub": "user123",
"exp": 1712349999,
"cnf": { "jkt": "SHA-256(规范化公钥)" }
}业务请求阶段
每次业务请求都要带两样
Authorization: Bearer <access_token>DPoP: <新的proofJWT>(每次都新生成,htu/htm/jti/iat 都会变)
服务端验证阶段(DPoP 的“绑定核心”)
服务端要验证两件事
- PoP:用 DPoP 头里的公钥验 DPoP proof 签名,验签通过等价于“你有私钥”
- 绑定关系:把该公钥算成指纹 jkt′,和 access token 的
cnf.jkt比对,相等才放行
指纹比对的细节:服务端计算 SHA-256(标准化后的公钥) 得到 Z,与 token 里的 cnf.jkt(Y)比对
关键问题汇总
Refresh Token 用 cookie?Access Token 放 localStorage/sessionStorage?
结论:Refresh 放 HttpOnly Cookie,Access 尽量放内存而不是 localStorage/sessionStorage
原因:refresh 是长期凭证,越不暴露给 JS 越好;access 放内存刷新会丢但可 refresh 立刻恢复
放 cookie 就没有风险吗
结论:有风险,只是风险类型不同,cookie 最大坑是 CSRF,HttpOnly 只能挡“读”挡不住“用”
设置多个 cookie?同名 key 会怎样
结论:同名 cookie 可能并存(不同 Path/Domain),请求头里会出现两次;服务端解析取哪个不可靠,建议避免同名
cookie 过期看 Expires,用的是本地系统时间,改系统时间岂不是……
结论:会影响浏览器是否继续带 cookie,但通常骗不过服务端的会话/令牌校验,所以不能靠它实现真正越权或无限登录
把 cookie 信息用“jwt 算法加密一层”再传更安全?
结论:JWT 默认不是加密而是签名(JWS),payload 仍可被解码看到;要保密需 JWE 或对称加密
更推荐:cookie 里只放随机不可猜的 session id / refresh token,不放敏感用户信息
2026年2月15日183441聊天记录
DPoP proof 的签名放在哪里
结论:签名不是单独字段,它就是 JWT 的第三段
私钥到底签了什么,是不是签请求体
结论:私钥签的是 base64url(header)+"."+base64url(payload),也就是 proof JWT 自己,不是签整个 HTTP 请求体
登录请求里的 username/password 在哪,DPoP 包不包含它们
结论:username/password 在 body;DPoP proof 只是额外 header,不包含业务数据
服务器如何验证“绑定关系”,要对比哪两个字段
结论:对比 DPoP proof 里的 jwk 算出的 jkt′ 与 access token 里的 cnf.jkt
攻击者偷到 access token 和公钥但没有私钥,能伪造新的 DPoP proof 吗
结论:不能,因为没有私钥就签不出能通过公钥验签的签名(PoP 的核心)
为什么不能只验 DPoP proof 的签名而不比对 cnf.jkt
结论:只验签只能证明“这个请求确实由某把私钥发出”,但无法证明“这把私钥就是这张 access token 绑定的那把”,必须通过 jkt′ == cnf.jkt 建立绑定关系
为什么登录时必须带 DPoP proof,如果登录不带会发生什么
结论:登录/refresh 阶段是服务器“首次知道你的公钥并把指纹写入 access token”的时机;不带 DPoP proof,服务器拿不到可验证的公钥来源,就无法把 access token 绑定到你的密钥(后续也就无法做到 sender-constrained)
简洁流程图(混合方案 + DPoP)
Login:
Browser generates key pair
-> POST /auth/login
DPoP: proof(htu=/auth/login, htm=POST, iat, jti, jwk=pubkey, sig=privkey)
Body: username/password
<- 200 Set-Cookie: refresh=...; HttpOnly; Secure; Path=/auth/refresh
Body: access_token { cnf.jkt = hash(pubkey) }
API Request:
-> GET /api/xxx
Authorization: Bearer access_token
DPoP: proof(htu=/api/xxx, htm=GET, iat, jti, jwk, sig)
Server:
verify access_token
verify DPoP signature with jwk
jkt' = hash(jwk)
allow iff (jkt' == access_token.cnf.jkt) and (jti not replayed)总结
- Cookie/JWT 谁更好不是重点,重点是把长期与短期凭证分层:长期(refresh/session id)尽量放 HttpOnly cookie 并缩小 Path,短期(access)放内存用 header 跑业务请求,401 触发 refresh 并重放
- JWT 默认不是加密,不要把“把敏感信息塞进 JWT/cookie”当成安全设计;更稳的是 cookie 放 opaque 随机串,用户资料走
/me拉取 - DPoP 的价值是把“谁拿到 token 谁能用”升级为“必须 token + 私钥才能用”,对 token 泄露/离线重放非常有效;但对 XSS 只能缩小危害半径,挡不住浏览器内当场滥用
普通 Web 业务认证架构
目标与原则
- 登录态稳定、可撤销、可续期(记住我)
- 不把长期凭证暴露给 JS
- 尽量同源/同站点,减少 CORS / SameSite 地狱
- 出事可止血(强制下线、复用检测、风控)
部署形态(优先级)
- 同源反代(最省事):
https://app.example.com/api/*→ Nginx 反代到后端 - 次选同站点:
https://mp.example.com+https://api.example.com(仍需 CORS,但比跨站好)
凭证模型(推荐)
Refresh Token:放 Cookie(长期、可撤销)
- 存放:
HttpOnlyCookie(JS 读不到) - 用途:只用于换取 access,不直接访问业务接口
- 有效期:7–30 天(按业务)
- 必须做:refresh rotation + reuse detection(复用就踢)
Cookie 属性建议(同站点/同源情况下):
HttpOnly; Secure; SameSite=Lax; Path=/auth/refreshDomain:不设(host-only,最稳)- 需要“多子域共享”才设
Domain=example.com(能不共享就别共享)
Access Token:短期(请求用)
- 存放:内存(不要 localStorage)
- 有效期:5–15 分钟
- 用途:每次业务请求带
Authorization: Bearer <access>
普通业务一般不强求 DPoP,要加也是加在 access 上,refresh 不绑。
核心接口(最小集合)
POST /auth/login
- 入参:账号密码(+ 可选 MFA)
- 出参:
- Set-Cookie:refresh cookie
- body:access token(短期)+ 用户信息(必要字段)
POST /auth/refresh
- 入参:无(靠 refresh cookie)
- 校验:
- refresh 是否有效
- rotation:旧 refresh 作废,发新 refresh
- reuse detection:发现旧 refresh 再次被用 → 撤销整条会话链(强制下线)
- 出参:
- Set-Cookie:新的 refresh cookie
- body:新的 access token
POST /auth/logout
- 行为:服务端撤销当前 refresh family(或当前会话),清 cookie
业务接口:/api/**
- 要求:
Authorization: Bearer <access> - access 过期:返回 401,前端触发 refresh 重试一次
CSRF(Cookie 会话场景必谈)
在同源/同站点 + SameSite=Lax下,CSRF 风险已经小很多,但仍建议:
对所有写操作(POST/PUT/PATCH/DELETE):
- 校验 Origin(缺失可回退 Referer)
- (高风险业务再加 CSRF token:
X-CSRF-Token)
记住:CORS 不是 CSRF 防护;Origin 校验才是。
XSS(最值钱的三件事)
- 不把长期凭证放 JS(refresh HttpOnly、access 内存)
- 上 CSP 基础版(禁 inline + 限制脚本源)
- 禁用危险渲染:
innerHTML/v-html/dangerouslySetInnerHTML(必须用就 sanitize)
高风险操作(改密/改邮箱/支付/提现)再加:
- step-up / re-auth(密码/OTP/二次确认)
会话与风控(让系统“可止血”)
- refresh rotation + reuse detection(最关键)
- 设备/会话列表(可选):支持“踢设备下线”
- 异常检测(可选):异地/设备突变/高频刷新 → step-up 或冻结
- 登录/刷新/复用事件审计日志
前端行为(简单但关键)
- access 放内存(页面刷新会丢,正常)
- 401 → 调 refresh → 重放原请求(只重试一次,避免循环)
- 页面启动时可调用一次 refresh 来拿 access(可选,取决于体验)
普通 Web 业务认证架构(二版)
核心设计原则
- Cookie 里不放业务信息,只放不可猜的随机 token
- 服务端必须有状态存储(DB / Redis)
- refresh 可轮转、可撤销
- access 短期存在,refresh 长期存在
- 不要把长期凭证暴露给 JS
整体架构模型
浏览器
Refresh Cookie(长期)
内容:随机生成的 refresh token
属性:
HttpOnlySecureSameSite=LaxPath=/auth/refresh- 不设
Domain(host-only 更安全)
Access Token(短期)
存放:内存
有效期:5~15 分钟
每次请求放:
Authorization: Bearer <access>服务端数据结构
表 1:auth_sessions(会话级)
表示一次登录(通常对应一个设备)
字段:
session_iduser_iddevice_idcreated_atlast_seen_atrevoked_at
用途:
- 查看登录设备列表
- 强制下线
- 风控分析
表 2:refresh_tokens(刷新令牌级)
字段:
token_idsession_idtoken_hash(只存 hash)status:active / rotated / revokedissued_atexpires_atreplaced_by_token_idlast_used_at
用途:
- refresh rotation
- reuse detection
- 复用后整条链撤销
刷新流程(Rotation)
- 客户端发送 refresh cookie
- 服务端:
- 校验 token_hash
- 状态必须是 active
- 生成新的 refresh token
- 当前 token 标记为 rotated
- 新 token 标记为 active
- 返回新的 refresh cookie + 新 access
复用检测(Reuse Detection)
如果:一个已 rotated 的 refresh token 再次出现
说明:令牌可能被盗
处理:
- 撤销该 session(或整个 refresh family)
- 强制重新登录
- 记录安全日志
设备 ID 设计
不要用浏览器指纹。
正确做法:
- 服务端生成随机
device_id - 存 cookie(HttpOnly)
- 记录在 auth_sessions
作用:
- 风控
- 显示登录设备
- 辅助异常检测
Cookie 内容规则
Cookie 不加密也没问题,但必须:
- 随机
- 足够长
- 不可猜
- 服务端可撤销
加密 cookie 只能防篡改,不防复制。
CSRF 防护
- SameSite=Lax
- 写请求校验 Origin
- 高风险接口可加 CSRF token
XSS 防护(关键点)
- HttpOnly refresh
- access 不存 localStorage
- 不使用危险渲染 API
- 上 CSP(推荐)
最终推荐架构(普通 Web)
同源反代 + refresh(HttpOnly cookie) + access(短 TTL 内存) + rotation + reuse detection + Origin 校验 + 高风险操作 step-up