大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Cookie 与会话
这一课你将能做到
- 画出
Set-Cookie→ 浏览器存储 → 后续请求自动带Cookie头的完整往返,并说明它如何补上 HTTP 无状态的缺口 - 解释 HttpOnly、Secure、SameSite、Max-Age/Expires、Domain/Path 各防什么、各不防什么,并判断一条 Set-Cookie 写得安不安全
- 用 DevTools Application → Cookies 面板与 curl
-c/-b模拟「登录后带票访问」,说清 Cookie 会话与 Bearer Token 的取舍
为什么要学
协议不记得你是谁,网站却要登录态——Cookie(或前端自己存的 Token)就是补上这个缺口的胶水。属性配错全是真实事故模式:不设 HttpOnly 被 XSS 脚本偷走会话票、不设 Secure 在 HTTP 环境泄露、SameSite 配 None 又不防 CSRF 导致用户被伪造请求。
面试必问「Cookie 和 Session 的区别」「Session 是怎么实现的」「CSRF 怎么防」,本站管理端就是 Sa-Token + Redis 会话的活样板。这一课学完,登录相关的八成线上问题你都能定位。
先建立图像
登录往返全景:
① 登录请求
POST /api/auth/login HTTP/1.1
Content-Type: application/json
{"username":"admin","password":"***"}
② 服务器发票
HTTP/1.1 200 OK
Set-Cookie: satoken=abc123; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=7200
③ 之后每次请求,浏览器自动带上
GET /admin/tracks HTTP/1.1
Cookie: satoken=abc123
浏览器侧:按域名隔离的 cookie jar
└─ name=value + 一堆属性(Path/Domain/HttpOnly/…)
服务器侧:通常只认票号
Cookie 里的 satoken ──查──► Redis 里的会话详情(多实例共享)
| 属性 | 人话 | 防什么 / 不防什么 |
|---|---|---|
| HttpOnly | 页面 JS 读不到 | 防 XSS 偷票;不防「恶意脚本以你的身份发请求」 |
| Secure | 仅 HTTPS 连接才发送 | 防明文链路泄露;本地 http 调试会「失效」 |
| SameSite=Lax/Strict/None | 跨站请求带不带这张票 | Lax/Strict 减 CSRF;None 必须 Secure |
| Domain / Path | 哪些域名/路径会带上 | 收窄=攻击面小;设父域=子域共享风险 |
| Max-Age / Expires | 有效期 | 都不设 = 会话级,关浏览器即丢 |
跟着做
步骤 1:在 Spring Boot 里亲手发一张票
写一个最小登录演示(真实项目用 Sa-Token/Spring Security,这里看协议本质):
@RestController
@RequestMapping("/api/auth")
public class AuthDemoController {
private final Map<String, String> sessions = new ConcurrentHashMap<>(); // 生产换 Redis
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestBody Map<String, String> body) {
if (!"admin".equals(body.get("username")) || !"123456".equals(body.get("password"))) {
return ResponseEntity.status(401).body(Map.of("error", "用户名或密码错误"));
}
String token = UUID.randomUUID().toString().replace("-", "");
sessions.put(token, "admin"); // 服务端记会话
ResponseCookie cookie = ResponseCookie.from("DEMO_TOKEN", token)
.httpOnly(true) // JS 读不到
.secure(false) // 本地 http 调试先关,上生产必须 true
.sameSite("Lax") // 防 CSRF 第一道
.path("/")
.maxAge(Duration.ofHours(2))
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body(Map.of("msg", "login ok"));
}
@GetMapping("/me")
public Map<String, String> me(@CookieValue(value = "DEMO_TOKEN", required = false) String token) {
if (token == null || !sessions.containsKey(token)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "未登录");
}
return Map.of("user", sessions.get(token));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@CookieValue(value = "DEMO_TOKEN", required = false) String token) {
if (token != null) sessions.remove(token); // 服务端使票作废
ResponseCookie cleared = ResponseCookie.from("DEMO_TOKEN", "").maxAge(0).path("/").build();
return ResponseEntity.noContent().header(HttpHeaders.SET_COOKIE, cleared.toString()).build();
}
}
步骤 2:curl -c / -b 模拟浏览器带票
# -c:把 Set-Cookie 存进 cookie jar
curl -s -c jar.txt -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" -d '{"username":"admin","password":"123456"}'
# 打开 jar.txt 看看浏览器替你保管了什么
cat jar.txt
# -b:下次请求带上这张票 → 200
curl -sb jar.txt -i http://localhost:8080/api/auth/me
# 不带票 → 401
curl -i http://localhost:8080/api/auth/me
# 注销后旧票立即失效(服务端 sessions 已删)
curl -sb jar.txt -X POST http://localhost:8080/api/auth/logout -o /dev/null -w "%{http_code}\n"
curl -sb jar.txt -i http://localhost:8080/api/auth/me # 401
这一组命令完整演示了「发票 → 带票 → 验票 → 废票」四步。
步骤 3:DevTools 看票的属性
- 浏览器访问登录页并登录(可用任意已登录站点)
- F12 → Application → Cookies → 选当前域名
- 逐列检查:HttpOnly 是否勾了、SameSite 是什么、Expires/Max-Age 到期时间
再切到 Network:任选一个同站请求,Request Headers 里的 cookie: 就是自动带上的——你没写任何代码,浏览器替你干了。
步骤 4:HttpOnly 的「看不见」实验
登录后打开 Console 执行:
document.cookie
HttpOnly 的会话票不会出现——这是好事:就算页面被注入恶意脚本,也读不走票。注意区分:读不到 ≠ 防不住,脚本仍可以借你的浏览器发请求(XSS 的破坏面在下面 Q&A)。
步骤 5:多实例下会话必须放 Redis
单机 Map 挂两个实例就会「A 实例登录、B 实例不认」。生产做法:会话详情存 Redis,Cookie 里只带票号:
Cookie: satoken=abc123
│
▼
Redis: key = satoken:abc123
value = {userId, roles, expireAt...} ← TTL 就是会话有效期
本站管理端即 Sa-Token + Redis 集成。失效有两把刀:浏览器清 Cookie(用户侧),Redis 过期或主动 DEL(服务端踢下线)。这也解释了为什么「退出登录」必须调服务端接口——只清前端存储,Redis 里的票还活着。
步骤 6:SameSite 三值情景题
- 从微信里点链接跳进本站(顶级 GET 导航):Lax 会带票,登录态正常
- 外站页面里藏一个自动提交的表单 POST 到本站:Lax 不带票 → CSRF 被挡
- 本站要被别的站以 iframe/跨站 fetch 嵌入并带票:只能
SameSite=None; Secure,同时必须补 CSRF Token 等防线
讲清楚要点
- 因为 HTTP 无状态,所以「登录了」必须在每个后续请求里可验证——Cookie 或 Authorization 头是载体,不存在「服务器自动记住」的魔法。
- 因为 Cookie 由浏览器自动附带,所以 CSRF 攻击主要盯 Cookie 方案——SameSite=Lax 是低成本第一道防线,关键写操作再加 CSRF Token 或校验 Origin。
- 因为 HttpOnly 挡的是「脚本读票」,挡不住「已注入的脚本替用户发请求」——防 XSS 还是要靠输出转义、CSP;HttpOnly 只是让偷票变难。
- 因为 Secure 要求 HTTPS,所以本地 HTTP 调试时「登录成功但 Cookie 存不上」不是 Bug——开发环境关 Secure 或本地配自签 HTTPS,但生产配置必须 Secure。
- 因为 Domain 写成父域会让所有子域共享票,所以能收窄就收窄——任何一个子域被打穿(如测试环境 XSS),主站会话跟着遭殃。
- 因为会话的权威在服务端(Redis),所以「退出登录」必须服务端作废票据——客户端清存储只是丢票,票号本身若没失效等于还被人拿着。
你可能会懵的地方
-
Cookie 和 Session 是一回事吗? 不是。Cookie 是浏览器侧的存储与自动携带机制;Session 是服务端的会话状态。经典组合「Session + Cookie」= 服务端存状态、Cookie 只带 session id。JWT 方案则把状态签名后自包含在 Token 里,服务端可不存 Session——但吊销和提前过期变难。
-
为什么有了 SameSite=Lax 还要 CSRF Token? Lax 只拦跨站的「非安全方法」,同站内的攻击、老浏览器、以及
SameSite=None的嵌入场景都还有暴露面。关键操作(改密码、支付)双保险:Token 校验或要求二次确认。 -
第三方 Cookie 要被全面禁用了,登录怎么办? 被收紧的是跨站跟踪类 Cookie。第一方登录 Cookie(用户直接访问你的站)不受影响。嵌入场景(第三方登录、iframe 支付)要用 CHIPS(分区 Cookie)、Storage Access API 等新方案迁移。
-
curl 能通浏览器不通,是不是 Cookie 的问题? 高频原因之一:浏览器同源请求自动带 Cookie,fetch 默认
same-origin(跨源不带),curl 忘了-b。逐项对照「谁没带票」:看 Network 请求头里有没有cookie:,再看服务端日志里票校验失败的原因。 -
Max-Age 和 Expires 哪个优先? 同时存在时
Max-Age优先(Expires 是老规格、秒级精度差、还受客户端时钟影响)。都缺省就是会话级 Cookie,关闭浏览器即失效——但现代浏览器有会话恢复机制,别把「关浏览器就登出」当成安全保证。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 会话 Cookie 不设 HttpOnly | 一处 XSS 就能偷走所有用户会话 | 会话票默认 HttpOnly |
| 生产 Cookie 无 Secure 标志 | 用户在 HTTP 环境(或降级攻击下)明文带票 | 全站 HTTPS + Secure |
SameSite=None 却忘了 Secure | 规范要求 None 必须 Secure,浏览器直接拒收该 Cookie | 成对配置,或改回 Lax |
| 退出登录只清 localStorage | 服务端票仍有效,等于没退 | 调服务端登出接口作废会话 + 清 Cookie |
| 把长期 refresh token 存在可被 JS 读的 localStorage 且无轮换 | XSS 偷走长效凭证 | HttpOnly Cookie 承载 + 短期 access token + 轮换/吊销机制 |
卡点急救
- 「登录成功但刷新又变未登录」:先看 Set-Cookie 响应头是否真的下发(Network → 登录请求 → Response Headers);再看 Application → Cookies 里有没有存上——常见原因是 Secure 在 HTTP 本地环境不生效、Domain 与当前域名不匹配、
SameSite=None缺 Secure 被浏览器丢弃。 - 接口一会儿 200 一会儿 401:多实例 + 会话存内存的典型症状(负载均衡轮到没存你票的实例)。会话迁 Redis,或确保负载均衡 sticky(治标);检查 Redis TTL 是否被配置得过短。
- 跨域前端拿不到/带不上 Cookie:三件套齐全才行——前端
credentials: 'include';后端 CORS 回具体 Origin(不能*)且Access-Control-Allow-Credentials: true;Cookie 的 SameSite/Domain 允许该场景。缺一不可(CORS 课细讲)。
面试常问
-
Cookie 和 Session 的区别?分布式环境下 Session 怎么办? Cookie 存在客户端、随请求自动携带、有大小(约 4KB)与数量限制;Session 是服务端会话状态,Cookie 里通常只存 session id。分布式下单机内存 Session 会被负载均衡打散,方案:Session 集中到 Redis(Spring Session / Sa-Token+Redis,主流)、客户端自包含 JWT、或粘性会话(不推荐为长期方案)。
-
CSRF 是什么?Cookie 方案怎么防? 跨站请求伪造:用户在 A 站已登录,访问恶意 B 站时,B 偷偷向 A 发请求——浏览器会自动带上 A 的 Cookie,请求「看起来」是本人发的。防护:SameSite=Lax/Strict 让跨站请求不带票、CSRF Token(服务端下发、提交时校验)、校验 Origin/Referer。JWT 放 Authorization 头天然不自动携带,CSRF 面更小,但要防 XSS。
-
HttpOnly、Secure、SameSite 分别解决什么问题? HttpOnly:JS 读不到 Cookie,缓解 XSS 偷票。Secure:只在 HTTPS 上发送,防明文泄露。SameSite:控制跨站请求是否携带,缓解 CSRF。三者都只是降低攻击面,不能替代 XSS/CSRF 的完整防护。
自测三问
- 从登录成功到第二次带态请求,Cookie 在协议上经历了哪两步?分别由谁执行?
- HttpOnly、Secure、SameSite 各解决哪类风险?各举一个「它解决不了」的例子。
- Cookie 会话与 Bearer Token 在「浏览器是否自动携带」和「CSRF 暴露面」上的核心差别是什么?
小结一句话
Cookie 是无状态 HTTP 上的回执号:HttpOnly/Secure/SameSite 决定它能不能被偷、被跨站滥用;会话详情常落 Redis,退出必须服务端作废。
延伸
- MDN:HTTP Cookie
- MDN:Set-Cookie
- 下一课:CORS——跨源请求被浏览器拦截时的红字,为什么 curl 通浏览器不通:同源策略、预检 OPTIONS 与 Spring 的 CORS 配置。