1. 这年头还在被CSRF打脸?先说说我当年踩的坑
先说结论:CSRF(跨站请求伪造)这玩意儿,2026年了依然是Web安全里的“老熟人”,而且远没到可以躺平的地步。很多前端同学觉得CSRF是后端的事,Token是后端生成的,自己只要把Token塞进请求头里就行——我当年也是这么想的,直到线上环境被人用CSRF刷爆了接口,踩了一周坑,才明白前端在CSRF防御里的角色远不是“工具人”那么简单。
那是一次典型的攻击:用户在登录态有效期内,访问了一个恶意站点。恶意站点里藏了一张图片,src指向我方网站的某个“改密码”或“发消息”接口。浏览器自动带上Cookie,请求合法发出,后端傻傻地信任了,于是用户在不知情的情况下完成了敏感操作。更阴的是,那张图片的地址还有参数,攻击者连耗时都省了。
那会儿我方的接口虽然校验了登录态,但完全没有防CSRF措施。攻击者不需要拿到你的Cookie,不需要知道你的密码,只需要“借用”你的身份发一个请求即可。事后排查日志时,请求的Referer字段直接暴露了来源是那个恶意域名,但后端压根没看。说白了,不是我技术多差,而是从设计上就没把“请求来源不可信”当回事。
这篇文章我就把CSRF的原理、同源策略和Token方案的细节一次讲透,所有方法都验证过、坑都踩过,按步骤抄就行。不管你是在维护老项目,还是在搭建2026年的新前端基座,这套组合拳都能用。
2. 先搞懂CSRF的本质:浏览器帮你背了黑锅
2.1 CSRF为什么能成功?核心就三个条件
CSRF能成立,需要同时满足三个条件,缺一个都打不起来。记住这个框架,你排查和防御就有方向了。
第一个条件是用户处于登录态。也就是说被害者在目标站点有一个有效的会话,通常是Cookie里有会话ID,或者其他自动携带的身份凭证。第二个条件是目标接口存在副作用。所谓副作用,就是这次请求会改变数据状态——改密码、发帖、转账、删数据都算,GET、POST都可能中招。如果接口是纯查询、不产生状态变更,CSRF的破坏力就小很多。第三个条件是浏览器自动携带凭证。Cookie的SameSite属性如果没设置成Lax或Strict,跨站请求时Cookie就跟着走了,这正是CSRF能得手的关键。
为什么说“浏览器帮你背了黑锅”?因为攻击者并不需要拿到你的Cookie文件,浏览器出于设计原因,会在你访问第三方站点、向目标站点发请求时自动带上Cookie。前端没法用JavaScript直接读取Cookie(有HttpOnly保护),但这恰恰成了CSRF可利用的点:我读不到,但我能用。
2.2 同源策略在这件事里扮演什么角色
很多人把同源策略理解成“浏览器不允许跨域请求”,这其实是错的。同源策略限制的是“跨域读取响应”,而不是“跨域发送请求”。举个例子:页面A域名是a.example.com,它通过<img>或<form>向b.example.com发请求,浏览器根本不会拦——请求照样发出去,Cookie照样带上,只是JS拿不到响应内容而已。
这就有意思了:同源策略帮不了你挡住CSRF的请求发出,但它能帮你判断请求是否来自可信来源。服务端拿到的Origin头、Referer头,就是浏览器基于同源策略标记出来的来源信息。如果一次“合法操作”的请求来源是一个完全陌生的站点,那你基本可以断定这是CSRF。
所以我一直跟团队里的人说:同源策略不是用来“防”CSRF的,它是用来“识别”CSRF的。真正的拦截动作,要么在后端校验来源,要么靠前端/后端配合加Token,要么在浏览器层面用SameSite把Cookie管住。
2.3 2026年了,为什么CSRF还没死
有个很自然的疑问:既然SameSite Cookie已经默认Lax了,CSRF是不是已经过时了?我只能说,理想很丰满,现实很骨感。SameSite默认Lax确实堵掉了大部分跨站GET请求,但有几个漏洞依旧存在。
第一,SameSite=None; Secure的Cookie照样在跨站请求里发送,很多第三方登录、嵌入场景必须这么配。第二,同站(same-site)不等于同源(same-origin),site是协议+注册域名,a.example.com和b.example.com虽然源不同,但属于同站,SameSite管不住这种请求。第三,老项目里Cookie的SameSite属性可能压根没设置过,或者项目里一直用的是旧浏览器内核。再加上局部网络环境里的代理、客户端WebView等场景,浏览器策略并不总是生效。
我自己的结论是:浏览器层面的防御都在“尽力而为”,但你不能把身家性命全押在浏览器策略上。服务端校验和Token机制必须兜底。这也是为什么2026年我写这篇东西,不是考古,而是真的还有人在裸奔。
3. Token方案拆解:前端到底该怎么接怎么发
3.1 双提交Token(Double Submit Cookie)为什么适合前端
现在主流的CSRF防御方案里,有一个对前端特别友好的:双提交Cookie机制。原理是:后端在用户会话里随机生成一个Token,把它同时写入Cookie和返回体(或由前端自行存储)。前端在发请求时,从Cookie里读出这个Token,放到自定义请求头里。后端校验时,只需比对请求头里的Token和Cookie里的Token是否一致,如果一致就放行。
为什么说它适合前端?因为它不需要服务端存储会话Token,天然适配无状态服务。后端不需要维护一个巨大的Token字典,也不用在分布式场景下做同步,只要比对两个值是否相等就行。而前端要做的,就是在请求拦截器里“读Cookie、塞Header”这么简单。
当然这个方案的前提是:Cookie里的Token不能被跨域读取。这就要给Token Cookie加上HostOnly属性(也就是不设置Domain)和HttpOnly关闭(因为前端要读)。有人会问:HttpOnly关了是不是不安全?这里要说明白,Token Cookie本身就是用来让前端读的,它防的不是脚本盗取,而是防跨域请求时被自动带上并被服务端误判。只要同源策略正常,第三方脚本没法跨域读你的Cookie,双提交就能撑起一片天。
3.2 前端拦截器的标准写法
以Axios为例,一个干净的前端CSRF拦截器大概是这样的:
import Cookies from 'js-cookie'; const CSRF_COOKIE_NAME = 'csrf_token'; const CSRF_HEADER_NAME = 'X-CSRF-Token'; // 仅对写操作启用CSRF防护 const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS', 'TRACE']; axios.interceptors.request.use(config => { const method = (config.method || 'get').toUpperCase(); // GET等幂等请求一般不需要加Token,但如果你后端要求全加也不冲突 if (SAFE_METHODS.includes(method)) { return config; } // 只在同源请求下自动加Token,避免跨域场景误伤 const targetUrl = new URL(config.url, window.location.origin); if (targetUrl.origin !== window.location.origin) { return config; } const token = Cookies.get(CSRF_COOKIE_NAME); if (token) { config.headers[CSRF_HEADER_NAME] = token; } return config; }, error => Promise.reject(error));这里有几个细节值得你多看一眼。
第一,为什么只拦截写操作?因为CSRF的危害集中在有副作用的请求上,GET通常只做查询。只给写操作加Token,既降低了所有请求的复杂度,也减少了误伤。第二,为什么只对同源请求加?第三方接口、跨域调用场景下,对方自己的CSRF策略我们管不了,不掺和是最好的。第三,为什么用js-cookie?因为原生Cookie API没有“按名取一个值”这么便捷的方法,手写解析Cookie字符串很啰嗦,用库能少些低级错误。
3.3 同步表单场景:别忘了隐藏域
单页应用用拦截器很顺手,但如果项目里还有传统同步表单——比如服务端渲染的页面,登录、留言、支付这些还是整页提交——拦截器就失效了。因为同步表单提交是浏览器原生行为,不经过Axios。
这时候你需要往<form>里塞一个隐藏域:
<form action="/api/transfer" method="POST"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="text" name="account" > <input type="text" name="amount" > <button type="submit">确认转账</button> </form>后端模板渲染时把Token塞进隐藏域,提交后后端比对隐藏域里的Token与会话里的Token。这个方案在传统后端框架(比如服务端渲染的Java、PHP、Python项目)里极其常见。前端别觉得这不关自己的事,如果你负责的前端工程里有模板引擎,这活儿就是你的事。
3.4 Token放Cookie还是放内存?我的取舍建议
关于Token存哪,江湖上一直有分歧。有人坚持放内存(比如Vuex、Redux、Pinia),刷新页面后重新拉取;有人喜欢放Cookie图省事。我的建议是看你项目的实际场景。
如果后端用的是“会话内校验”模式,也就是后端在Session里存Token,那前端放内存完全没问题,缺点是刷新后Token没了,得重新获取。如果后端用的是“双提交”模式,那Token必须在Cookie里,否则校验无从谈起。如果项目里既有服务端渲染页面、又有API请求,那Cookie放Token是唯一的选择,不然两种场景要维护两份Token。
我个人在实际项目中更倾向Cookie放Token + 自定义Header的模式,原因很简单:前端实现最统一,所有请求走一套逻辑,服务端渲染页面通过隐藏域读同一个Cookie值,两边不会打架。但要注意,Cookie的SameSite要设成Lax,这样跨站脚本没法通过Cookie“借用”Token去构造请求——组合拳的意义就在这儿。
4. 同源策略侧的防御实操:Origin、Referer一个也别放过
4.1 为什么服务端必须校验Origin和Referer
你可能会想:前端加一个X-CSRF-Token头就够了吧?不,不够。为什么?因为攻击者完全可以先在你的站上触发一个请求(比如通过某个未过滤的JSONP接口、或者通过表单探测),把Token偷出来,再构造恶意请求。Token是重要防线,但不能是唯一防线。
服务端校验Origin或Referer是最朴素但非常有效的辅助手段。在CSRF攻击中,恶意请求的来源几乎必然不是你的站点。如果后端统一校验这个来源,攻击者连“偷Token”这一步都没机会——请求直接被打回。
我在实际项目中要求后端对两类头做校验:一类是Origin,只要有这个头就必须在信任名单里;另一类是Referer,在Origin缺失的旧浏览器场景下兜底。顺序上以Origin为主,Referer为辅,两个都缺失的请求要小心,但也不一定全拦——因为有些隐私设置会禁用Referer,这里有误杀风险。
4.2 前端该配合做什么?把机密信息赶出URL
前端在“源头治理”上的第二件事,是确保敏感操作的参数不落在URL上。很多老项目有这种坏毛病:转账金额、目标账号、操作类型统统拼在URL查询参数里。这不光是日志泄漏风险,还让CSRF攻击者造一个<img>或<a>链接变得毫无难度。
我见过一个惨烈的例子:某后台的“删除文章”功能是GET /admin/article/delete?id=123,攻击者只需要在恶意页面上放一张src指向这个地址的图片,点都不需要点,只要加载页面就删了。这还是普通操作,如果是改密码接口带newPassword参数的,那画面不敢想。
所以前端在设计和开发时就要立规矩:
- 所有有副作用的操作,一律使用
POST、PUT、DELETE等非GET方法。 - 敏感参数放进请求体或请求头,不拼URL。
- 请求签名、校验码等机密信息禁止出现在URL上。
你可能会觉得这是后端该定的约束,但前端在联调时天天写接口,第一时间就该发现“这个接口怎么是GET删除?”然后怼回去改掉,而不是默默接上。这种安全意识,2026年的前端必须要有。
4.3 跨域场景的例外处理:别把CORS和CSRF搞混
这里要特别提一下跨域请求。很多人一听到“跨域”就觉得要放宽CSRF限制,这其实是把CORS和CSRF混为一谈了。
CORS是浏览器帮你决定“能不能读取响应”的机制,跨域请求发出去了,但响应能否被JS读取,要看后端是否返回Access-Control-Allow-Origin。而CSRF关心的是“请求是否被伪造”,一个请求不管是同源还是跨域发来的,只要它带着合法身份凭证,就可能被利用。
所以结论是:跨域场景下,你反而要更严格地对待CSRF。如果后端开启了CORS并允许携带Cookie,那这个接口天然暴露面更大。此时除了Token,还建议加上:
- 精确的
Access-Control-Allow-Origin白名单,禁止使用*。 - 对自定义头(如
X-CSRF-Token)明确加入预检请求的处理。 - 敏感接口校验
Origin的时候,把信任名单维护好,不用的域名别留。
我见过一个真实事故:某项目为了让某个小程序的WebView跨域调用,后端把CORS开成了*,结果CSRF的Token也等于是“公开的”。不是Token没生成,是Token白发了——攻击者直接发一个fetch带上自定义Header就完事,因为后端允许任何源“读取并携带”。这两个概念不掰扯清楚,防御就会漏成筛子。
5. 2026年实战组合拳:从后端到前端的完整落地方案
5.1 三层防线我来给你盘一盘
真正可靠的CSRF防御,应该是分层防守的。下面这套方案我在多个项目里落地过,可以当清单用。
第一层是浏览器侧:把会话Cookie的SameSite设为Lax或Strict,这是成本最低、覆盖面最广的一道防线。大部分老代码只要改属性就有效果,没必要升级框架。
第二层是服务端侧:校验Origin和Referer,非信任来源直接拒绝。同时对所有写操作接口强制要求CSRF Token。
第三层是前端侧:请求拦截器自动加Token、敏感操作只用POST、不让机密信息出现在URL上,以及在前端构建期做好依赖安全检查,避免引入带风险的第三方脚本。
你注意到没有,这三层不是“三选一”,而是“三合一”。每一层都有自己的盲区:浏览器侧管不住同站请求,服务端侧拦不住合法来源内的攻击者,前端侧防不了服务端不做校验的裸奔。只有叠起来,才能覆盖到绝大多数攻击面。
5.2 一个后端校验的参考伪代码
下面给一个服务端校验的伪代码,方便你理解整个链路长什么样,也方便你拿去和后端同学对齐。
import re from flask import request, abort TRUSTED_ORIGINS = {"https://app.example.com", "https://admin.example.com"} def csrf_protect(): # 1. 安全方法直接放行(GET/HEAD/OPTIONS/TRACE) if request.method in ("GET", "HEAD", "OPTIONS", "TRACE"): return # 2. 优先校验 Origin origin = request.headers.get("Origin") if origin: if origin not in TRUSTED_ORIGINS: abort(403, "Untrusted Origin") return # 3. Origin缺失时回退校验Referer referer = request.headers.get("Referer") if referer: # 只校验站点根,防止路径伪造 m = re.match(r"^https://([^/]+)/", referer) if not m or f"https://{m.group(1)}" not in TRUSTED_ORIGINS: abort(403, "Untrusted Referer") return # 4. 两个头都缺失,保守校验Token token = request.headers.get("X-CSRF-Token") cookie_token = request.cookies.get("csrf_token") if not token or token != cookie_token: abort(403, "CSRF Token Missing or Mismatch")这段代码的思路是“能校验来源就校验来源,来源可信则Token可以从宽,来源不可信直接打回”。有些团队觉得麻烦,我建议直接一点:只要是非安全方法,一律同时校验来源和Token。多一次校验的耗时几乎可以忽略,但安全性上限高很多。
5.3 登录、登出这些特殊接口别漏了
CSRF防御最容易漏的不是主业务接口,而是那些“边缘接口”——登录、登出、验证码获取、头像上传。攻击者篡改这些接口,虽然不一定能直接偷数据,但搞出“账号锁定”“服务异常”是够用的。
登录接口的CSRF有个特殊点:用户此时可能还没有登录态,那Token从哪来?常见做法是服务端在登录页面渲染时输出一个一次性Token,前端带着它去提交登录。如果用户没有先“拿到Token的资格”,登录请求就没有Token可带——此时靠SameSite=Lax来兜底。登出接口同理,CSRF登出攻击防不好,用户会莫名其妙掉线,体验极差。
上传接口是CSRF重灾区,因为它天然接受POST且可能有副作用大的操作。文件上传加上Token校验还不够,最好再做一次内容校验:校验文件类型、大小、后缀,以及文件是否真的被前端解析过。CSRF只是入口,上传后的存储型XSS才是大BOSS,一前一后要一起防。
5.4 前端工程化:把CSRF防御写进基建,而不是写进个人习惯
防御如果只靠每个人写代码时“注意一下”,那等于没有防御。2026年的前端工程化,应该把这套机制沉淀到基建里。
我比较推荐的做法是:封装一个统一的request模块,在模块内部完成CSRF Token的注入。这样业务代码只用request.get()/request.post(),不需要关心Token从哪来。团队里的新同学即使不懂CSRF,也不会踩坑。代码评审时只用看业务逻辑,什么跨域、拦截器都是透明的。
另一个关键点是把Cookie名和Header名做成常量,前后端通过接口文档对齐。这里有个非常容易翻车的细节:名字稍微对不上,比如前端发X-CSRF-TOKEN,后端校验的是X-CSRF-Token,请求直接被403,但你在页面上肉眼看不出来,只能看网络面板。所以我在项目里统一约定,所有和CSRF相关的标识都用小写连字符形式,文档里标注清楚。
6. 实战排查:CSRF相关的疑难杂症速查表
6.1 线上出问题,怎么快速定位是不是CSRF被拦
你最终逃不过一个问题:上线后,部分接口突然大量403。这时候先别急着把CSRF校验关掉,按下面的顺序排查。
先看请求是不是非安全方法(POST/PUT/DELETE),如果是GET被拦截,大概率是拦截器把SAFE_METHODS判错了,检查config.method的大小写。再看前端有没有成功带上Token:打开DevTools的Network面板,点击那个403的请求,找到Request Headers,看有没有X-CSRF-Token。如果没有,问题在拦截器;如果有但403,问题在后端比对逻辑;如果后端比对发现两次读取的Cookie不一样,多半是路径或域的问题。
Cookie的域和路径是另一个重灾区。Token写入时如果用的是path=/api,而页面在/admin下,前端就读不到。我建议Token Cookie统一用path=/,并设置SameSite=Lax。跨域场景千万别用Domain前缀,否则子域全都能读到,等于给攻击者发钥匙。
6.2 常见问题与解决方案速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 所有POST请求都403 | 后端没正确对比Token,或前端拦截器没生效 | 打开Network面板确认请求头是否有Token,无则查拦截器,有则查后端代码 |
| 部分用户请求被拦,部分正常 | Cookie路径不一致,或SameSite设置不一致 | 统一Token Cookie的路径为/,域不设Domain |
| 跨域正常,同域反而不行 | 拦截器对同源请求判断逻辑出错 | 检查new URL()拼接逻辑,确认targetUrl.origin和window.location.origin完全一致 |
| 服务端渲染页面提交表单403 | 隐藏域的名字和后端校验名不一致 | 确认name="csrf_token"和后端读取的字段名完全一致 |
| 第三方登录跳转回来403 | 跳转过程中Cookie没带上,或者Token被重置 | 确认跳转链接、回调地址都在信任名单内,Token在此场景建议每次请求重新生成 |
| 刷新页面后Token失效 | Token只在内存里,或Cookie过期 | 把Token持久化到Cookie并设置合理过期时间,或刷新后重新拉取 |
6.3 一个真实排障案例:Token时灵时不灵
有一次项目联调,测试同学反馈“时好时坏,过一会儿又403”。我打开Network面板一看,发现请求头里Token一会儿有、一会儿没有。继续查才发现,前端在某个页面里用了document.cookie手动追加了一个同名Cookie,而我们的拦截器用的是js-cookie的get,它是按枚举顺序返回的——手动追加的Cookie在后面,拦截器读到的正是那个冒牌货。
这种“同名覆盖但读取顺序不一致”的坑非常隐蔽。我的建议是:项目里统一用一套Cookie管理方式,不要混用原生API和第三方库。如果你从拦截器里取Token,就用js-cookie;如果你在服务端渲染模板里注入了Cookie,确保跟前端代码用的是同一个逻辑读取。Cookie这玩意儿虽然老旧,但它带来的坑永远不会过时。
还有一个常见坑:Token在响应里每次更新,但前端没有把新Token同步到Cookie。比如后端在登录后返回一个Token,前端存到了localStorage,但请求拦截器读的却是Cookie——于是第一次请求成功,第二次Token过期就403。这种现象看起来像“随机失败”,实际是前后端没对齐Token的存储与更新策略。后来我把规则改成“以后端写入Cookie为准,前端只负责读和塞”,问题彻底消失。
6.4 一些容易“顺手关掉防御”的时刻
我特别想提醒一件事:排障时千万别为了临时过个接口就把CSRF校验删了。开发环境可以理解,但线上千万别这么干。如果你实在要快速验一个接口通不通,与其禁用校验,不如在本地临时构造一个合法请求,或者用Postman带好Token去调。真要在线上关校验,必须有审批、有记录、有时限,我见过不止一次“临时关一下”变成“永久裸奔”的。
还有一类容易翻车的操作:用了第三方SDK(比如监控上报、埋点、客服聊天插件)后,这些SDK发请求时不会走你自己的拦截器。如果它们恰好访问了你后端的写接口,Token就带不上了。这种情况要么把这些SDK的请求放到白名单里单独处理,要么确保它们走的域名和你主站的CSRF校验规则无关。记住:你做的防御只保护你自己写的代码路径,第三方代码全在你控制之外。
7. 最后说一点我在实践中越用越深的体会
CSRF防御这件事,做得越久越会发现,它不单是“加个Token”的局部问题,而是整个系统信任模型的设计问题。你信任了什么、不信任什么,都会反映在接口设计上。前端能做的,是让每一次请求都带着“我确实来自可信前端”的证据;而后端要做的,是不放过任何一个可疑来源——这句话说起来简单,做到位要靠一套覆盖开发、测试、线上监控的完整机制。
我个人现在的习惯是,每次新项目启动时,先把安全和权限相关的设计文档写清楚:哪些接口需要CSRF Token、哪些接口需要额外的来源校验、Cookie策略是什么、CORS白名单是什么。写文档这事看着耽误时间,但等出事故再救火,那才是真的耽误时间。前端老鸟的经验就四个字:提前想好。
如果你现在正在维护老项目,建议从今天起做三件事:第一,把所有写操作的接口过一遍,确认它们都用了POST类方法;第二,确认线上所有Cookie都设置了SameSite,核心Cookie设置成Lax或Strict;第三,找后端同事或者自己看一眼代码,确认服务端对Origin/Referer有校验逻辑。这三件事做完,你至少脱离了“裸奔”级别。剩下的,再慢慢用Token体系和工程化把防护拉满。
CSRF不是一道多难的题,它就是一场持续的、和攻击者抢时间、抢认知的攻防战。你每多设一道防线,攻击者的成本就高一分。把基础打牢了,不管2026年还是2030年,你都不用在凌晨三点爬起来处理漏洞告警——那种背锅的感觉,希望我这篇文章能帮你彻底避开。