网络-14 Cookie、Session 与跨域
前置阅读:网络-12 HTTP 报文、方法与状态码
HTTP 是无状态的——服务端不会记住上一个请求是谁发的。但真实业务需要"登录状态"这种概念,于是有了 Cookie 机制。而 Cookie 天生和"域"绑定,这又牵出了同源策略和跨域问题。这三个话题是连着的,所以放在一篇里讲。
1. Cookie
1.1 基本机制
① 服务端在响应里下发:
Set-Cookie: session_id=abc123; Path=/; HttpOnly
② 浏览器保存,之后每次请求该域名自动带上:
Cookie: session_id=abc123
关键点:浏览器自动携带。这是 Cookie 最大的便利,也是 CSRF 攻击的根源——后面会讲。
1.2 属性详解
Set-Cookie: id=abc123; Domain=example.com; Path=/; Max-Age=3600;
Secure; HttpOnly; SameSite=Lax
| 属性 | 作用 |
|---|---|
Domain |
生效域名。设为 example.com 时子域名也会带上(api.example.com 生效);不设则只对当前精确域名生效 |
Path |
生效路径,/ 表示全站 |
Expires |
绝对过期时间 |
Max-Age |
相对过期秒数,优先级高于 Expires |
Secure |
只在 HTTPS 下发送 |
HttpOnly |
禁止 JS 通过 document.cookie 读取 |
SameSite |
控制跨站请求是否携带,防 CSRF |
会话 Cookie vs 持久 Cookie:不设 Expires/Max-Age 的叫会话 Cookie,浏览器关闭即失效(存内存);设了的叫持久 Cookie,写入磁盘。
HttpOnly 是防 XSS 的关键:如果攻击者往你页面注入了脚本,没有 HttpOnly 时一行 document.cookie 就能偷走会话凭证。存放 session_id 的 Cookie 必须加 HttpOnly。
SameSite 的三个取值:
| 值 | 行为 |
|---|---|
Strict |
完全不允许跨站携带。最安全,但从外部链接跳进来会显示未登录 |
Lax |
允许顶级导航的 GET 请求携带(点链接跳转),阻止 POST 和 iframe/img 等子资源请求。现代浏览器的默认值 |
None |
允许所有跨站携带,但必须同时设置 Secure |
Lax 成为默认值是近年浏览器的重大变化——它默认阻止了跨站 POST,从根本上削弱了传统 CSRF 攻击。需要跨站携带 Cookie(如独立部署的前后端)必须显式设 SameSite=None; Secure。
注意 SameSite 判断的是"站点(Site)“而非"源(Origin)”:a.example.com 和 b.example.com 是同站但不同源。站点比对的是 eTLD+1(可注册域名),比同源策略宽松。
1.3 局限
- 容量小:每个域名下通常 4KB 左右、条数有限;
- 每个请求都带:即使请求图片、CSS 也会带上,浪费带宽(所以静态资源常放在独立域名下,即"Cookie-free domain");
- 只能存字符串。
2. Session、Token 与 JWT
2.1 Session(服务端存储)
① 用户登录成功
② 服务端生成 session_id,把用户信息存在服务端(内存/Redis)
③ Set-Cookie: session_id=abc123 下发给浏览器
④ 后续请求带上 session_id,服务端据此查出用户信息
问题在分布式场景:服务端要存状态,多台机器就要解决共享问题。三种解法:
- 粘性会话(Sticky Session):负载均衡按 IP 哈希,同一用户固定打到同一台。缺点是那台机器挂了会话就丢,且扩缩容时会话大面积失效。
- Session 复制:机器之间互相同步。机器多了同步开销爆炸。
- 集中存储:统一存 Redis。这是现在的主流做法,代价是多一次网络查询和一个中心依赖。
2.2 Token(客户端存储)
① 用户登录成功
② 服务端签发一个 token(自带用户信息 + 签名)
③ 客户端自己保存(localStorage / sessionStorage)
④ 后续请求手动放到 Authorization: Bearer <token>
服务端不存状态,只验证签名,天然适合分布式和多端(App、小程序没有 Cookie 概念)。
2.3 JWT 的结构
JWT(JSON Web Token)是 Token 的一种具体实现,由三段 Base64URL 编码的内容用 . 连接:
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiIxIiwiZXhwIjoxNzAwfQ . SflKxwRJSMeKKF2QT4
└──── Header ────────┘ └──────── Payload ──────────┘ └─── Signature ───┘
算法声明 用户信息+过期时间 签名
最关键的认知:Payload 只是 Base64 编码,不是加密。任何人都能解开看到内容。 所以 JWT 里绝对不能放密码、身份证号等敏感信息。签名的作用是防篡改(保证完整性),不是防偷看(不保证机密性)。
2.4 三者对比
| Session | Token / JWT | |
|---|---|---|
| 状态存储 | 服务端 | 客户端 |
| 分布式扩展 | 需 Redis 等共享存储 | 天然支持 |
| 能否主动失效 | ✓ 删掉即可 | ✗ 签发后无法撤销 |
| 传输方式 | Cookie 自动携带 | 手动放 Header |
| CSRF 风险 | 有(自动携带) | 低(手动携带) |
| XSS 风险 | 可用 HttpOnly 防护 |
存 localStorage 无法用 HttpOnly 防护 |
| 多端支持 | 依赖 Cookie,App 端不便 | 好 |
JWT 最大的软肋是无法主动失效。用户改密码、管理员封号、检测到异常登录,签出去的 token 在过期前依然有效。常见缓解手段:
- 缩短有效期 + Refresh Token:access token 设几分钟到几小时,配一个长效 refresh token 用于续期;
- 维护黑名单:把要撤销的 token ID 存 Redis——但这就重新引入了服务端状态,等于放弃了 JWT 的核心优势。
所以选型上:单纯的 Web 应用用 Session + Redis 往往更简单可靠;需要多端、跨服务、无状态鉴权时 JWT 更合适。别因为 JWT “更现代"就无脑选它。
3. 同源策略
同源 = 协议 + 域名 + 端口 三者完全相同。
以 https://www.example.com:443/a 为基准:
| URL | 是否同源 | 原因 |
|---|---|---|
https://www.example.com:443/b |
✓ | 仅路径不同 |
http://www.example.com |
✗ | 协议不同 |
https://api.example.com |
✗ | 子域名也算不同源 |
https://www.example.com:8443 |
✗ | 端口不同 |
同源策略是浏览器的核心安全机制。没有它,你打开一个恶意网页,它的 JS 就能直接读取你银行网站的数据。
受限制的:XMLHttpRequest / fetch、DOM 访问、读取 Cookie 和 localStorage。
不受限制的(历史遗留,也是 CSRF 的基础):
<img src>、<script src>、<link href>、<iframe src>—— 可以跨域加载<form>提交 —— 可以跨域提交
注意一个关键细节:跨域请求其实是发出去了的,服务端也处理了,只是浏览器拦截了响应不让 JS 读取。这解释了一个常见困惑:“为什么后端日志显示收到请求了,前端却报跨域错误”——因为拦截发生在响应返回后。
也正因为如此,同源策略只约束浏览器。服务端之间互相调用、curl、Postman 都不存在跨域问题。
4. CORS 跨域资源共享
CORS 是 W3C 的标准解决方案,核心是服务端通过响应头明确授权。它把请求分成两类。
4.1 简单请求
同时满足以下条件才算简单请求:
- 方法是
GET/HEAD/POST之一; Content-Type只能是text/plain、multipart/form-data、application/x-www-form-urlencoded三者之一;- 没有自定义请求头。
简单请求直接发出,浏览器自动加上 Origin 头:
GET /api/data HTTP/1.1
Origin: https://www.example.com
服务端同意就在响应里带上:
Access-Control-Allow-Origin: https://www.example.com
浏览器检查这个头,匹配则放行,不匹配就拦截响应并在控制台报错。
4.2 预检请求(Preflight)
不满足简单请求条件的(比如发 Content-Type: application/json 的 POST,或带自定义 header 的请求),浏览器会先自动发一个 OPTIONS 请求试探:
OPTIONS /api/data HTTP/1.1
Origin: https://www.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
服务端回应允许的范围:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400 ← 预检结果缓存 24 小时
预检通过后才发真正的请求。
为什么需要预检? 因为简单请求的那个白名单,恰好覆盖了 HTML 表单本来就能跨域提交的范围——这些能力在 CORS 出现前就存在,所以不需要额外许可。而 PUT、DELETE 或自定义头是表单做不到的新增能力,必须先征得服务端同意,避免旧服务端在毫不知情的情况下被跨域调用而产生副作用。
Access-Control-Max-Age 很重要:不设的话每个非简单请求都要多一次 RTT。设了之后浏览器缓存预检结果,同样的请求在有效期内直接发。
4.3 携带凭证
跨域请求默认不发送 Cookie。需要携带时两边都要配合:
// 前端
fetch(url, { credentials: 'include' })
# 服务端
Access-Control-Allow-Origin: https://www.example.com ← 不能是 *
Access-Control-Allow-Credentials: true
这里有个硬性限制:Allow-Credentials: true 时,Allow-Origin 不能是通配符 *,必须是明确的源。这是刻意的安全设计——否则任意网站都能带着用户 Cookie 访问你的接口。
实践中常见的做法是:从请求的 Origin 头读取来源,与白名单比对,匹配则原样回显。注意不要无脑回显 Origin(等价于放行所有来源),必须校验白名单。
4.4 Nginx 配置
location /api/ {
# 预检请求直接在网关层返回,不打到后端
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Max-Age 86400;
return 204;
}
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
proxy_pass http://backend;
}
生产环境应把 $http_origin 换成白名单校验(用 map 指令),而不是直接回显。
4.5 其他跨域方式
| 方式 | 说明 |
|---|---|
| 反向代理 | 让前端和 API 处于同源,Nginx 转发到后端。生产环境最常用,因为根本不产生跨域 |
| JSONP | 利用 <script> 不受限制的特性。只支持 GET,已过时 |
postMessage |
页面与 iframe/新窗口之间通信 |
| WebSocket | 不受同源策略限制(见 网络-19),但服务端应校验 Origin 头 |
5. CSRF
CSRF(Cross-Site Request Forgery,跨站请求伪造)利用的正是 Cookie 自动携带这个特性。
攻击流程:
① 用户登录了 bank.com,浏览器持有其 Cookie
② 用户在同一浏览器打开了恶意页面 evil.com
③ evil.com 中埋了:
<img src="https://bank.com/transfer?to=hacker&amount=10000">
或一个自动提交的表单
④ 浏览器发出这个请求时,自动带上了 bank.com 的 Cookie
⑤ 服务端看到合法 Cookie,执行了转账
注意攻击者全程看不到响应(被同源策略拦住了),但副作用已经产生。这也说明为什么"不安全方法必须用 POST 而不是 GET"很重要——见 网络-12 里那个把删除写成 GET 的反面案例。
防御手段(应组合使用):
| 手段 | 原理 |
|---|---|
SameSite=Lax/Strict |
从根本上阻止跨站携带 Cookie。现代浏览器默认 Lax,这是最有效的一层 |
| CSRF Token | 服务端下发随机 token,放在表单隐藏域或自定义请求头。攻击者因同源策略读不到这个 token |
校验 Origin / Referer |
判断请求来源是否合法。注意两者都可能缺失(隐私设置),不能作为唯一手段 |
| 敏感操作二次验证 | 转账、改密码要求输入密码或短信验证码 |
| 不用 GET 做写操作 | 让 <img>、<link> 这类标签无法触发副作用 |
为什么 Token 认证天然抗 CSRF? 因为 token 存在 localStorage 里,需要 JS 手动放进 Authorization 头。攻击者的页面因同源策略读不到你的 localStorage,也没法让浏览器自动带上——不存在"自动携带"这个前提,攻击链就断了。
代价是 localStorage 无法用 HttpOnly 保护,一旦有 XSS 漏洞,token 就会被直接读走。所以:
- Session + Cookie:怕 CSRF,但可用
HttpOnly抗 XSS; - Token + localStorage:天然抗 CSRF,但对 XSS 毫无防护。
两种方案是风险互换,不存在哪个绝对更安全。折中做法是把 token 也放进 HttpOnly Cookie,再配 SameSite 和 CSRF Token——同时拿到两边的防护。
6. 本章面试题
Cookie 和 Session 的区别?
- Cookie 是浏览器端的存储机制,由
Set-Cookie下发,之后每次请求自动携带。容量约 4KB,只能存字符串。 - Session 是服务端的会话状态,通常把 session_id 通过 Cookie 传给浏览器,服务端据此查出用户信息。
关系是:Session 的实现依赖 Cookie 传递标识。Session 数据在服务端(内存/Redis),Cookie 只存一个 id。
Session 和 JWT 怎么选?JWT 的最大缺点是什么?
JWT 最大缺点:签发后无法主动失效。 用户改密码、管理员封号,token 在过期前依然有效。缓解手段是缩短有效期 + Refresh Token,或维护黑名单(但黑名单重新引入服务端状态,等于放弃了 JWT 的核心优势)。
选型:单纯 Web 应用用 Session + Redis 往往更简单可靠;需要多端(App/小程序)、跨服务、无状态鉴权时选 JWT。别因为"更现代"无脑选 JWT。
补充:JWT 的 Payload 只是 Base64 编码,不是加密,任何人可解开。绝不能放敏感信息,签名只防篡改不防偷看。
什么是同源策略?跨域请求到底发出去了吗?
同源 = 协议 + 域名 + 端口三者完全相同。子域名不同也算跨域。
请求确实发出去了,服务端也处理了,只是浏览器拦截了响应不让 JS 读取。这就是"后端日志有记录但前端报跨域"的原因。
同源策略只约束浏览器——服务端互调、curl、Postman 都不受限。
不受限制的例外:<img>、<script>、<link>、<iframe> 可跨域加载,<form> 可跨域提交——这也是 CSRF 的基础。
CORS 的预检请求是什么?为什么需要它?
不满足简单请求条件时(非 GET/HEAD/POST、Content-Type: application/json、带自定义头),浏览器先自动发一个 OPTIONS 请求询问服务端是否允许。
为什么需要:简单请求的白名单恰好是 HTML 表单本来就能跨域做到的事,无需额外许可。而 PUT/DELETE 或自定义头是新增能力,必须先征得服务端同意,避免旧服务端在不知情的情况下被跨域调用产生副作用。
用 Access-Control-Max-Age 缓存预检结果,否则每个非简单请求都多一次 RTT。
跨域携带 Cookie 要注意什么?
前端设 credentials: 'include',服务端设 Access-Control-Allow-Credentials: true。
硬性限制:此时 Access-Control-Allow-Origin 不能是 *,必须是明确的源。这是刻意的安全设计,否则任意网站都能带用户 Cookie 访问接口。
实践中从 Origin 头读取并与白名单比对后回显,不要无脑回显(等于放行所有来源)。
CSRF 的原理和防御?为什么 Token 认证天然抗 CSRF?
原理:利用 Cookie 自动携带的特性。用户登录 bank.com 后访问恶意页面,页面里的 <img src="bank.com/transfer?..."> 会让浏览器自动带上 Cookie 完成攻击。攻击者看不到响应,但副作用已产生。
防御(组合使用):SameSite=Lax/Strict(最有效,现代浏览器默认)、CSRF Token(攻击者因同源策略读不到)、校验 Origin/Referer、敏感操作二次验证、不用 GET 做写操作。
Token 抗 CSRF 的原因:token 需要 JS 手动放进 Authorization 头,攻击者读不到你的 localStorage,也无法让浏览器自动携带——“自动携带"这个前提不存在,攻击链就断了。
代价:localStorage 无法用 HttpOnly 保护,XSS 下 token 直接被读走。两种方案是风险互换。
HttpOnly 和 SameSite 分别防什么?
HttpOnly防 XSS:禁止 JS 通过document.cookie读取。存 session_id 的 Cookie 必须加。SameSite防 CSRF:控制跨站请求是否携带 Cookie。Strict完全禁止,Lax(默认)只允许顶级导航的 GET,None全允许但必须配Secure。
注意 SameSite 判断的是**站点(eTLD+1)**而非源,a.example.com 和 b.example.com 是同站不同源。
xingliuhua