先说一个我印象挺深的场景:某团队做前后端分离项目,后端把会话Cookie加上HttpOnly之后,前端同事当天就找过来,说登录接口返回的Set-Cookie在浏览器里能看到,但JavaScript里document.cookie一片空白,下一步没法做。于是有人建议把HttpOnly去掉,因为“反正现在有CSRF Token了,Cookie藏着也没啥用”。这个建议对不对?对了一半,但把差点把CSRF防线也给拆了。后来我们花了不少时间把“HttpOnly到底防什么”“CSRF到底怎么防”“Cookie藏起来之后前端怎么配合后端做校验”这三件事彻底理顺,前后端通信才不再扯皮。
这个标题里的“设置HTTP-only标志防御CSRF攻击”,本身是个常见但容易误读的表述。HttpOnly不是直接防御CSRF的武器,它真正的战场是XSS攻击——防止注入脚本偷走会话凭证。但它确实改变了前后端的通信方式:Cookie里的东西前端读不到了,那CSRF Token怎么传、怎么存、怎么带上请求,就成了一个绕不开的设计问题。这篇就把这条链路从头到尾拆一遍,从攻击原理到方案选型,再到能直接落地的代码和排查经验,一次讲清楚。
1. CSRF攻击的本质:浏览器替你“背锅”的全过程
1.1 一次典型的CSRF攻击是怎么发生的
CSRF(跨站请求伪造)说起来不复杂:受害者已经登录了目标站点A,浏览器里存着A的会话Cookie;这时候受害者被诱导打开了攻击者的站点B。站点B里藏着一个自动提交的表单或者一个带参数的图片请求,目标是指向A站点的一个敏感接口,比如改密码、转账、发消息。
攻击流程里最关键的一点是:浏览器在发这个请求时,会自动带上属于A站点的Cookie。后端一看,这个请求带着我的会话Cookie,认证没问题,就正常处理了。但它没想过一件事——这个请求到底是不是用户在A站点页面上主动发起的?
用户此时根本不知情。他只是在看B站点的页面,结果地址栏里对A站点的请求已经发出去了,Cookie自动帮忙“验明正身”,后端也就放心执行了操作。这就是CSRF能成立的根本原因:服务端只相信Cookie代表的身份,却无法区分请求是用户真实意愿,还是被第三方站点诱导发出的。
1.2 为什么浏览器会“主动”带上Cookie
这里需要把Cookie的携带规则说清楚。Cookie在设置的时候就已经带好了作用域,包括Domain(域名)和Path(路径),有些还带SameSite属性。浏览器在发起请求时,只要当前请求的目标URL满足Cookie的Domain、Path和协议约束,就会无条件把Cookie放进请求头,不需要页面里的JavaScript做任何事,也不会询问页面是谁打开的。
这本来是一个很顺手的设计——用户登录后刷新页面、跳转页面、请求API,都不需要前端手动维护登录状态,Cookie自己会跟着请求走。但同一套机制也带来CSRF的隐患:攻击者站点构造的请求,目标域名是A,浏览器依然按照A的Cookie作用域把Cookie带上。也就是说,CSRF不是攻击者“拿到了”你的Cookie,而是浏览器“主动替”攻击者把Cookie发送了出去。Cookie本身没有泄漏给攻击者,但攻击者已经不需要Cookie了,他要的只是用户浏览器“附赠”的这个认证凭据。
这也是很多新手困惑的地方:我的Cookie加了HttpOnly,攻击者看不到,为什么还会被CSRF打?答案很简单——CSRF从头到尾就不需要“看到”Cookie,它只需要浏览器在请求里自动带上Cookie就够了。HttpOnly防的是JavaScript读取Cookie,而CSRF利用的是HTTP请求头自动附带Cookie的机制,两者根本不在同一个环节上。
1.3 CSRF的核心危害与触发条件
CSRF的危害取决于被攻击接口的“敏感度”。如果目标站点有一个修改手机号的接口,攻击者可以诱导用户在不知情的情况下把账号手机号改成攻击者的,然后通过手机验证码完成密码重置,账号就彻底易主了。还有一类典型的CSRF发生在管理后台,攻击者诱导管理员访问恶意页面,后台的“添加管理员”或者“导出数据”接口被自动触发,后果更严重。
一个CSRF攻击要成立,通常满足三个条件:
- 目标接口使用Cookie作为认证凭据,且没有额外的来源校验;
- 目标接口只检查请求是否已认证,不检查请求来源是否可信;
- 攻击者能预测请求的参数结构(或者通过固定的GET/POST表单就能触发的接口)。
所以你会注意到,凡是用了Authorization Header传Token的纯API接口,天然没有CSRF问题——因为攻击者的站点B没办法让浏览器自动给请求加上目标站点的Authorization头,这个头只有前端代码能加,攻击者没这个权限。
1.4 了解攻击链路之后,防御思路就清晰了
知道CSRF的本质之后,防御方向就非常明确了,重点不是你多想一个“认证之外”的前端校验,而是要让服务器能识别“这个请求是不是用户本人在目标站点页面里主动发起的”。
现在业界常用的手段有几个方向:CSRF Token(同步令牌模式)、双重Cookie提交、SameSite Cookie限制、Origin/Referer来源校验、自定义Header配合CORS策略。不同方案解决的是不同层面的问题,但核心逻辑是一致的——“仅仅带了Cookie还不够,你还得拿出另一个只有目标站点页面才能拿到的凭据,或者证明请求来自可信任的站点上下文”。
到这里,HttpOnly的问题就浮出水面了:如果CSRF Token要靠前端JavaScript配合去读取和注入,而HttpOnly又把所有Cookie都藏了起来,前后端就得想出一套新的“沟通语言”。
2. HTTP-only的真实战线:它到底防住了什么
2.1 HTTP-only的作用原理
HttpOnly是Set-Cookie响应头里的一个标志,一旦设置,浏览器就会禁止JavaScript通过document.cookie访问这个Cookie。它不会影响浏览器在HTTP请求里自动附带的逻辑,所以对服务端来说,每次请求依然能正常收到这个Cookie,认证完全不受影响。
举个例子:服务端返回一个Set-Cookie头:
Set-Cookie: SESSION=9f8e7d6c5b4a3210; Path=/; HttpOnly; SameSite=Lax; Secure这个SESSION Cookie会正常存到浏览器里,之后每次向目标域名发请求,浏览器自动带上;但页面里的任何脚本执行document.cookie时,只能看到其它没加HttpOnly的Cookie,看不到SESSION。恶意脚本偷偷把document.cookie发回攻击者服务器,这个操作也拿不到SESSION的原始值。
2.2 和XSS的关系:两者经常被一起提,但攻击路径完全不同
XSS(跨站脚本攻击)是攻击者把恶意脚本注入到目标站点的页面里,脚本在目标站点的上下文中执行。这时候脚本能干的事非常多:读取cookie、篡改页面、模拟用户操作、向目标站点内网发请求等等。如果会话Cookie没有HttpOnly,XSS脚本直接document.cookie拿会话ID,发给攻击者服务器,攻击者就能在远端伪造这个会话,实现“盗号”。
而CSRF是攻击者通过自己的站点B诱导用户浏览器向目标站点A发请求,利用的是“Cookie自动附带”的机制,而不是直接拿Cookie内容。两者一对比就特别清楚:
| 攻击类型 | 利用对象 | 攻击者需要拿到Cookie吗 | 核心防护 |
|---|---|---|---|
| XSS | 页面脚本执行环境 | 需要(拿到会话靠它盗号) | HttpOnly、CSP、输出编码 |
| CSRF | 浏览器Cookie自动携带机制 | 不需要(Cookie只是附带品) | CSRF Token、SameSite、来源校验 |
所以HttpOnly和CSRF的关系更像“间接战友”——它虽然防不住CSRF,但它保证了CSRF防御方案里的会话凭证不会被XSS顺手牵羊。如果会话Cookie能被脚本轻易读走,攻击者直接盗号,根本不需要费劲搞CSRF了。这也是为什么“设置HttpOnly防御CSRF”这个说法虽然不严谨,但在实践中经常被放在一起讲:它确实提升了整体会话安全级别,让CSRF防御有了可靠的前提。
2.3 只靠HttpOnly的常见误解
我见过不少人以为只要设了HttpOnly就万事大吉,结果被CSRF打了之后还很委屈:“我Cookie都藏起来了,攻击者怎么还能利用?”其实只要目标接口接收Cookie认证,并且没有其它校验,HttpOnly完全无法阻止那个“附带Cookie的请求”被自动发出。比如用curl模拟一个不带Token的POST请求,带上受害者的Cookie,服务端照样正常处理,跟页面里有没有HttpOnly毫无关系。
把HttpOnly当成CSRF的“免死金牌”是误区,但反过来,觉得“有了CSRF Token就不需要HttpOnly”也同样危险。CSRF Token解决了跨站伪造问题,但解决不了XSS偷会话的问题。Token是随机值,被盗走之后攻击者可以正常通过校验;会话Cookie里藏着用户的认证身份,一旦被盗,攻击者全盘接管。所以成熟的做法是:HttpOnly保护会话Cookie,防止XSS盗号;CSRF Token或SameSite保护请求本身,防止跨站伪造。两条防线各管各的,缺一不可。
3. 防御CSRF的体系化设计:从单点防护到纵深防御
3.1 主流防线逐一拆解
CSRF防御不是选一个方案就完事,而是要考虑多个维度的组合。目前应用最广的方案大致是下面这些:
第一种是同步令牌模式(Synchronizer Token Pattern)。服务端生成一个随机Token,存到Session或服务端存储里,页面前的表单或请求带上这个Token。提交时服务端比对请求里的Token和Session里存的是否一致。因为是随机、不可预测的,攻击者无法在恶意页面里预先构造这个Token,所以能有效防止跨站伪造。
第二种是双重Cookie提交(Double Submit Cookie)。服务端把Token写入一个Cookie,另外要求请求参数或请求头里带上同一个Token值。服务端校验时,从Cookie里取出Token,和请求里的Token对比。攻击者因浏览器同源策略无法读取目标站点的Cookie(除非目标站点的子域被攻破或者存在特殊漏洞),也就无法在恶意请求里构造出相同值。
第三种是SameSite Cookie属性。现代浏览器支持Cookie的SameSite属性,我们可以在Set-Cookie时设为Lax或Strict。Lax模式下,跨站请求中只有顶级导航的GET请求会带Cookie,POST、iframe、fetch等都不会带,这已经能挡住绝大多数的CSRF攻击。Strict更严格,所有跨站请求都不带Cookie,但用户体验上会有一些代价,比如从第三方站点跳转回来时登录态可能丢失。
第四种是来源校验,检查请求的Origin头或Referer头是否属于可信站点。缺点也比较明显:Referer在某些场景下会被浏览器省略,比如HTTPS页面跳到HTTP页面时不会发送Referer,太依赖这个就容易被误伤。
第五种是自定义Header配合CORS策略。要求所有敏感请求必须带一个自定义Header,比如X-Requested-With或者自定义的X-CSRF-Token。因为自定义Header会触发CORS预检,而跨站表单提交无法添加自定义Header,所以攻击者的恶意请求根本通不过服务端校验。这个方案通常会和别的方案配合使用,而不是单独扛大旗。
3.2 纵深防御的推荐组合
现在的Web应用环境里,我最推荐的是“SameSite + CSRF Token”的双层组合,再加上HttpOnly保护会话Cookie。这样即使某一层被绕过,另一层还挡着。
举个例子:同一套接口,Cookie设置了SameSite=Lax,POST跨站请求在浏览器层面就不会带Cookie,绝大多数CSRF攻击在第一道防线就被拦下了。但Lax并不是万能的,某些特定的跨站场景仍然可能带Cookie,比如顶级导航的GET请求,或者部分老浏览器的兼容行为。这时候第二道防线——CSRF Token校验——就能兜底。Token校验不为浏览器行为做假设,只看请求里有没有合法的Token,所以无论攻击者通过什么方式发出请求,只要没有Token就拒绝。
同时,会话Cookie设HttpOnly,能保证这道防线不被XSS瓦解。整体防御模型就变成了“浏览器层防一部分 + 服务端层防一部分 + 客户端脚本侵袭防护”,每一层负责的威胁类型不同,组合在一起覆盖得更全面。
3.3 核心矛盾:HttpOnly让前端读不到Cookie,Token怎么传
这里终于落到标题里最实际的问题上。在同步令牌模式中,服务端把Token存在Session里,前端需要拿到这个Token放进请求参数或Header里。如果服务端把Token也塞进一个HttpOnly Cookie里,那么前端JavaScript从document.cookie里读不到,也就没法把它放进自定义Header里。这是前后端通信最直接的一个“断点”。
要打通这个断点,得明确一件事:CSRF Token不是会话凭证,它是一次性或者周期性的跨站请求校验凭据,即使被JavaScript读取,也不会直接泄露用户身份。会话凭证那一份(SESSION Cookie)必须HttpOnly保护好,但CSRF Token可以走一条专门给前端读的通道。
这个思路落实下来,就出现了下面几种主流的前后端通信设计。
4. 前后端通信的几个关键设计:Token下发、存储、注入
4.1 登录接口:一次性把会话和Token都确认好
前后端通信的第一步是登录。用户提交用户名密码后,服务端校验成功,接下来要做两件事:设置HttpOnly会话Cookie,同时把CSRF Token交给前端。
比较简洁的做法是,在登录接口的响应头里同时设置两个Cookie:一个是SESSION的HttpOnly Cookie,一个是XSRF-TOKEN的普通Cookie。这里故意让XSRF-TOKEN不设HttpOnly,因为它就是要给前端脚本读取的。前端登录之后,从document.cookie里读XSRF-TOKEN的值,存进请求库的默认Header里,后续请求自动带上。
还有一种做法是登录接口的响应体里直接返回一个JSON字段,比如{ "csrfToken": "xxx" },前端拿到之后放内存变量或JavaScript模块里,每次发请求时手动或者通过拦截器填进Header。这么做的好处是Cookie里不存任何前端需要读取的东西,Cookie策略更干净;缺点是刷新页面后Token就丢了,前端需要重新调一个接口去拿,或者接受刷新后重新获取Token的流程。
4.2 前端如何自动带上Token:请求拦截器是主力
不管是Cookie方案还是接口返回方案,前端最终都要把Token放到自定义Header里。这里几乎都是用统一的请求封装来做的。以axios为例,默认配置里就支持从Cookie读取CSRF Token并自动注入Header。如果你用Angular,内置的HttpClient也有类似逻辑。
import axios from 'axios'; axios.defaults.xsrfCookieName = 'XSRF-TOKEN'; axios.defaults.xsrfHeaderName = 'X-XSRF-TOKEN'; axios.defaults.withCredentials = true; axios.interceptors.request.use(function (config) { // 从Cookie里读XSRF-TOKEN const token = readCookie('XSRF-TOKEN'); if (token) { config.headers['X-XSRF-TOKEN'] = token; } return config; });这样每一次API请求都会自动带上X-XSRF-TOKEN。如果Token不在Cookie里,而是登录接口返回的,那就换成把返回值赋给一个全局变量,拦截器里读这个变量。需要注意的问题是Token的时效性,服务端如果把Token设成短时效,前端在Token过期后必须能感知到,并且重新获取。这个环节通常和401处理逻辑绑在一起。
4.3 跨域场景下的通信“暗坑”:预检请求
前后端分离项目只要跨域,并且请求里自定义了Header,浏览器就会先发一个OPTIONS预检请求,询问服务端允许哪些Header、哪些方法、是否允许携带凭证。如果服务端CORS配置不完整,前端请求直接失败在预检阶段,看起来就像接口不可用。
这套机制本身是浏览器在帮你做安全决策:跨域请求如果带自定义Header,必须经过服务端明确“点头”才能放行。而攻击者从恶意站点发起的简单表单请求,压根不会走预检流程,服务端一旦要求必须有自定义Header,这种请求就会被直接拒绝。也就是说,自定义Header不仅让前端能传Token,它本身也是防御链路上的一环。
服务端要正确响应OPTIONS,并且配置Access-Control-Allow-Headers包含X-XSRF-TOKEN,Access-Control-Allow-Credentials为true,Access-Control-Allow-Origin明确指定可信来源,不能是星号——因为带Cookie凭证的跨域请求不允许星号通配。这一条容易被人忽略,后面常见问题里还会专门讲。
4.4 几种通信方案的选择对比
把这几种方案放在一起看,使用场景和取舍就清晰多了:
| 方案 | 会话Cookie是否HttpOnly | CSRF Token如何传给前端 | 推荐场景 | 需要注意的问题 |
|---|---|---|---|---|
| 独立XSRF-TOKEN Cookie | 是 | 前端从非HttpOnly的Cookie读取并写入Header | 同源部署、前后端页面由同一服务或同域网关托管 | 需要保证Token是安全随机数;Cookie作用域要收窄 |
| 登录接口返回Token | 是 | 登录响应体返回,前端存内存并写入Header | 前后端完全分离、接口经常被跨域调用 | 页面刷新后Token丢失,需要额外的Token获取接口 |
| Authorization Bearer | 不依赖Cookie | 登录返回JWT等Token,前端存内存或存储 | 纯API、移动端和Web复用同一套 | 需要防XSS盗Token,建议配CSP和短时效Token |
方案一和方案二是传统Cookie会话体系下的选择,方案三是彻底绕开Cookie的“无会话”风格。选型时主要看两点:服务端还有没有存Session,以及前端对Token存续期的容忍度。如果服务端还在用Session存登录态,那方案一和方案二更顺手;如果服务端已经JWT完全无状态化,那就别硬套Cookie体系,直接用方案三更干净。
5. 从“看懂”到“能跑通”:一套可直接落地的实现示例
5.1 后端:生成Token、下发Cookie、校验Header
先看后端。为了让问题本身更清晰,这里用一个最朴素的Servlet Filter写法说明核心逻辑,不依赖具体安全框架的封装。核心要做三件事:生成Token并写入Cookie、对非安全请求校验Token、对安全请求放行。
public class CsrfFilter implements jakarta.servlet.Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 首次访问时生成CSRF Token并写入Cookie String token = getCookie(req, "XSRF-TOKEN"); if (token == null) { token = generateSecureToken(); resp.addHeader("Set-Cookie", "XSRF-TOKEN=" + token + "; Path=/; SameSite=Lax"); } // GET、HEAD、OPTIONS等安全方法不校验,直接放行 if ("GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod()) || "OPTIONS".equals(req.getMethod())) { chain.doFilter(request, response); return; } // 从请求头里拿Token,与Cookie里的值比对 String headerToken = req.getHeader("X-XSRF-TOKEN"); if (headerToken == null || !constantTimeEquals(headerToken, token)) { resp.sendError(403, "CSRF token missing or mismatch"); return; } chain.doFilter(request, response); } }这里最关键的细节是constantTimeEquals。直接使用equals比较字符串,理论上存在时间侧信道风险,攻击者可以通过响应时间逐字符猜测Token。正确做法是先把两个字符串转成字节数组,然后按固定长度做异或累加,最后判断结果是否为0。Token本身用SecureRandom生成,它和普通随机数相比,安全性更高,IOW它产生的值是不可预测的,这是Token有效的根本保障。
如果项目已经用了Spring Security,那实现更简单,框架内置了CookieCsrfTokenRepository:
http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) ) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/login").permitAll() .anyRequest().authenticated() );withHttpOnlyFalse这个方法的命名其实是反直觉的:是把CSRF Token所在的Cookie设置成非HttpOnly,这样前端脚本才能读。注意它只影响CSRF Token那个Cookie,不影响你的会话Cookie——会话Cookie的HttpOnly需要自己配置。
5.2 前端:登录、取Token、带Token发请求
前端的完整流程分三步。第一步,提交登录请求,拿到会话Cookie和XSRF-TOKEN Cookie;第二步,从Cookie里读取XSRF-TOKEN;第三步,在后续请求的Header里带上它。
用axios实现,最简洁的方式是这样:
// 第一步:登录 async function login(username, password) { await axios.post('/api/login', { username, password }, { withCredentials: true }); } // 第二步:登录成功后读取Token并设置为默认Header async function initCsrfToken() { const token = readCookie('XSRF-TOKEN'); if (token) { axios.defaults.headers.common['X-XSRF-TOKEN'] = token; } } // 第三步:之后的业务请求 async function changePassword(oldPwd, newPwd) { // 这个请求会自动带上X-XSRF-TOKEN return axios.post('/api/change-password', { oldPwd, newPwd }); }readCookie这个函数不用引入库,直接操作document.cookie就可以。但要注意一个细节:如果Token是通过登录接口响应体返回的,而不是Cookie,那上面的读取逻辑就要改一下,登录接口的返回值先存到一个模块级变量里,拦截器从这个变量取。
5.3 接口级校验:不是所有接口都需要CSRF Token
另外一个容易被忽视的点:不是所有请求都需要CSRF防护。只读请求、公开接口、预检请求虽然不带Token,但也不能被误伤。以查询类接口为例,它本身不修改数据,CSRF攻击即使伪造了GET请求,最多读到一些数据,如果接口设计得不好还可能造成信息泄露,但这与CSRF防护是另一个问题。
因此后端的校验规则通常是:GET、HEAD、OPTIONS放行;POST、PUT、PATCH、DELETE等变更请求必须校验Token。个别公开的POST接口,比如注册、找回密码,这些接口在用户尚未登录时就应该允许无Token访问,不过如果攻击者可以在未登录状态下提交这些接口,需要考虑的是接口的滥用防护(限流、验证码),而不是CSRF。设计CSRF校验范围时,建议按接口分组维护白名单,做到变更接口默认开启校验,特殊接口显式声明豁免。
5.4 完整的请求链路串一遍
把上面的逻辑串起来看,一次正常的登录后请求是这样的:
- 浏览器访问登录页,服务端发现没有XSRF-TOKEN Cookie,生成一个安全随机数,写入Set-Cookie响应。
- 用户提交登录表单,前端发起POST请求;如果这个POST发生在登录前,Token还没被前端读取到,那请求就带不上X-XSRF-TOKEN头。这个矛盾怎么解?实践里通常把登录接口本身从CSRF校验白名单剔除,或者让前端在加载页面时先调一个GET接口触发Token生成。
- 登录成功后,前端拿到XSRF-TOKEN Cookie的值,设置到axios默认Header。
- 用户后续发起修改类请求,axios自动带上X-XSRF-TOKEN头,Cookie也由浏览器自动带上。
- 服务端从Header取出Token,和Cookie里的Token做常量时间比较,一致则放行。
- 如果请求里没有Header,服务端直接403,攻击者的跨站伪造请求在这里被拦截。
这条链路里,HttpOnly会话Cookie完全不需要前端参与,浏览器自动处理;前端参与的只是XSRF-TOKEN的读取与注入。而且,攻击者的恶意请求虽然会自动携带Cookie,却带不上X-XSRF-TOKEN头(浏览器不会帮它从Cookie里取Token),所以会在第6步被拦下。
6. 实战中反复踩过的坑与排查思路
6.1 设置HttpOnly后前端读不到Token
这个是最早暴露的问题。有人顺手把所有Cookie都加了HttpOnly,包括XSRF-TOKEN,结果前端document.cookie里干干净净,请求Header里永远没有Token,后端一直403。排查半天才反应过来,CSRF Token Cookie不能加HttpOnly——它的存在价值就是让前端读取。
这不是说要让会话Cookie裸奔,而是要把“会话Cookie”和“CSRF Token Cookie”分开看。会话Cookie加HttpOnly是刚需,CSRF Token Cookie不加HttpOnly是功能需要。如果对Cookie数量比较敏感,不想在Cookie里多暴露任何值,那就改成登录接口返回Token的方案,让Cookie里始终只有会话Cookie一个敏感值。
6.2 跨域配置不完整导致请求“神秘失败”
前后端分离项目,前端跑在一台开发服务器,后端跑在另一台,请求经常在Network面板里连着刷出两条:第一条是OPTIONS,第二条才是真正的POST。如果OPTIONS返回403或者根本没有响应,第二条请求根本不会发出。
排查这类问题,先看后端有没有给OPTIONS请求放行,再看Access-Control-Allow-Headers里有没有包含X-XSRF-TOKEN,还要确认Access-Control-Allow-Credentials是true。有一个很容易漏的配置:前端如果设置了withCredentials,后端就不能用Access-Control-Allow-Origin: *,必须明确写客户端来源域名,否则浏览器会拒绝。
用curl没法模拟完整的预检流程,但可以用一个快捷的检测方法——直接在浏览器开发者工具的Network里看预检请求的响应头,逐项核对后端返回的CORS头是否齐全。
6.3 页面刷新后Token丢失
登录接口返回体里带Token的方案,前端刷新页面后Token跟着内存一起被清了,后续第一个变更请求就没有Header,直接被403。解决方法是提供一个GET /api/csrf接口,后端动态生成Token返回给前端,前端在应用初始化时调用一次。或者干脆回到Cookie方案,Cookie天然跨刷新存活,前端每次加载时都从Cookie里读Token。
如果你选择了接口返回Token,就要特别注意多标签页的问题:两个标签页同时打开时,每个标签页的Token存在各自的JS内存里,互不相同。而后端无论用Session存储还是双重Cookie校验,都会出现一个标签页的Token被另一个标签页覆盖,导致前一个标签页的请求全部403。Cookie方案在这种场景下明显更稳。
6.4 SameSite=Lax对第三方登录回调的影响
SameSite=Lax严格限制了跨站带Cookie,这本来是为了安全,但在某些真实业务场景里会给用户添麻烦。最典型的是第三方授权登录:用户从第三方登录页跳转回本站,这个跳转是顶级导航GET,按Lax规则会带本站Cookie,基本没问题。但如果业务里用了iframe嵌入,或者需要跨站Set-Cookie的场景,Lax可能把Cookie挡掉,导致登录态丢失。
遇到类似情况,不建议直接退到SameSite=None,因为这需要配合Secure标志,并且会削弱对CSRF的浏览器层防护。先检查产品层面能否换成跳出iframe或者改成服务端中转的流程,只有在确实需要跨站携带Cookie时,才在受限范围内使用SameSite=None。当前的浏览器环境里,第三方Cookie本身也在逐步被收紧,长远看更推荐改造认证流程,而不是对抗浏览器的策略。
6.5 多实例部署时Session不一致导致Token校验失败
同步令牌模式下,Token存在Session里,后端如果部署多个实例,用户第一次请求命中了实例A,Token存到了A的Session里,下一个请求被负载均衡转发到实例B,B的Session里没有这个Token,校验就失败。项目一上集群,这种问题就冒出来了。
两个常见的解法。一个是把Session抽到Redis等集中式存储里,让所有实例共享同一份Session数据。另一个是放弃Session存储Token,用双重Cookie方案,Token存在Cookie里,服务端只是校验“请求头上的Token”和“Cookie中的Token”是否一致,不依赖任何服务端存储。如果实例配置了相同的Cookie校验密钥,还能对Token做签名,进一步增强安全性。
6.6 快速自测CSRF防护是否生效
最后给一个我常用的验证方法。用curl模拟一个“带Cookie但无Token”的请求:
curl -i -X POST https://your-site.com/api/change-password \ -H "Cookie: SESSION=xxx; XSRF-TOKEN=yyy" \ -d "oldPwd=123456&newPwd=abcdef"如果返回403,说明CSRF校验生效了。再手动加一个X-XSRF-TOKEN头并且值和Cookie里的XSRF-TOKEN一致,重新发一次,如果接口正常放行,说明前后端通信链路完整。这两条curl一打,整套CSRF防线是否通顺就一目了然。别忘了SESSION的值要用真实登录后的值去替换,否则会被认证环节拦掉,测不到CSRF这一层。
最后说点我自己的体会
这些年处理过的CSRF问题,大部分都不是方案本身不行,而是前后端对“Cookie能做什么、不能做什么”的预期不一致。HttpOnly把会话Cookie藏起来之后,前端很容易陷入“我什么都读不到,怎么做登录判断、怎么带Token”的恐慌,然后有人就想着把HttpOnly去掉或者把所有东西都塞进请求里,反而把安全边界搞模糊了。我个人经验是,在设计前后端接口规范时,就明确约定三条:会话Cookie一律HttpOnly且前端不得读取;CSRF Token必须有专门的下发通道;所有变更类接口默认启用Token校验。把这三条写成接口文档,前端照着做,后端照着验,之后基本不会因为CSRF的问题来回扯皮。至于选择Cookie方案还是接口返回Token方案,我的建议是同源部署优先Cookie方案,跨域部署优先接口返回方案,并且不管哪种,别忘记SameSite和CORS这两道容易出幺蛾子的配置。安全和便利从来都是一个平衡,想清楚了再动手,比写代码本身更重要。