在前后端分离的开发模式已经普及的今天,调试接口时最让人摸不着头脑的,往往不是业务逻辑本身,而是浏览器开发者工具 Network 面板里那个红色的 CORS error。你明明把接口地址填对了,参数也传了,请求却偏偏被拦在半路,更气人的是,后端日志里压根没有你这笔请求的记录。这种时候,十有八九是预检请求出了问题。
预检请求(Preflight Request)本质上是浏览器替我们自动发送的一次 HTTP OPTIONS 请求,它就像小区门口那个从不露面的保安,在你进行跨域请求之前,先拦下来问一句:你打算用什么方法、带哪些头去访问别人家的资源,业主同意了没有?只有在服务端明确放行之后,浏览器才会真正把业务请求发出去。很多新手甚至不少有几年经验的后端同学,都会被这个“多出来”的 OPTIONS 请求搞到怀疑人生。这篇文章我就把预检请求的前因后果、完整握手流程、服务端配置和排查技巧一次讲清楚,里面绝大多数坑都是我实际踩过并解决的,值得你收藏。
1. 预检请求到底是什么:浏览器的“隐形保安”上岗逻辑
在讲预检请求之前,绕不开同源策略。浏览器默认只允许页面里的脚本访问同源资源,所谓同源,指的是协议、域名、端口三者完全一致。哪怕只是从 http://localhost:8080 访问 http://localhost:3000,端口不同,就已经属于跨域,浏览器的安全机制会出手拦截。这个限制本身是为了保护用户数据不被恶意网站盗取,可同时也给正经的前后端联调添了不少堵。
CORS(跨域资源共享)机制就是在这种背景下被设计出来的。它允许服务器通过响应头显式声明“哪些源可以访问我”,浏览器拿到这个声明后,才会放行跨域响应。而预检请求,则是 CORS 机制里一个非常特殊的环节:浏览器在真正发送某些“危险”请求之前,先自动发送一个 OPTIONS 请求去探测服务端的态度。
1.1 为什么需要预检:同源策略这道墙
你可以把同源策略理解成一座小区的大门,门禁默认只放行本小区的业主。CORS 就是物业发放的访客通行证,而预检请求则是门卫在放行之前的电话确认:这个访客要带大件物品进去,我得先问问业主同不同意。
服务端此时还没有收到你的实际业务请求,它收到的只是一次“试探”。如果服务端配置得当,会返回一组 Access-Control-Allow-* 头,相当于在电话里明确说:可以,这个源的人能进,能用 GET 和 POST 方法,可以带 Content-Type: application/json 这种头。浏览器听到这个回答后,才会发出真正的业务请求。否则,浏览器直接拦住请求,并且在后端日志里完全看不到这笔业务请求——响应在浏览器这一层就被中止了,根本到不了服务端。
这个设计其实非常巧妙。它把风险决策前置,避免浏览器在未知服务端态度的情况下,贸然发送可能产生副作用的写操作。想象一下,如果没有什么预检机制,恶意网站可以随意往你的银行账户接口发起转账请求,只要服务端没做额外的防护,后果不堪设想。预检请求至少让服务端有一个机会来验证来者身份和请求合法性。
1.2 什么请求需要预检:简单请求与非简单请求的分界线
并不是所有跨域请求都会触发 OPTIONS 预检。浏览器定义了一类“简单请求”,这类请求被认为不会对数据产生不可预期的副作用,不需要提前探测。满足以下条件,浏览器才会把它当作简单请求直接发送:
- 请求方法仅限于
GET、HEAD、POST三种。 - 请求头只能是浏览器自动生成的那些基础头,加上少部分手动指定的头,比如
Accept、Accept-Language、Content-Language、Last-Event-ID,以及Content-Type为application/x-www-form-urlencoded、multipart/form-data或text/plain。 - 不能使用
XMLHttpRequest或fetch的ReadableStream对象。
一旦超出这个范围,比如使用 PUT、DELETE 方法,或者 Content-Type 用了 application/json,又或者自定义了 Authorization、X-Custom-Header 之类的请求头,浏览器就会先发送一次 OPTIONS 预检请求。
这里有一个非常常见的误区:很多人以为只要跨域了就会发 OPTIONS,其实不是。只发 GET 请求且不带头、不用 JSON 格式,走的是简单请求路线,不会有预检;但是一旦加上 Content-Type: application/json,预检就会立刻出现。这也是为什么前端工程师在联调时经常发现,同样的接口,POST 数据时多了一个 OPTIONS,而 GET 时却看不到。
| 请求情况 | 是否触发预检 | 原因 |
|---|---|---|
| GET 不带自定义头 | 不触发 | 简单请求 |
| POST x-www-form-urlencoded | 不触发 | 简单请求 |
| POST application/json | 触发 | 非简单请求 |
| PUT / DELETE | 触发 | 非简单请求 |
| 携带 Authorization 头 | 触发 | 非简单请求 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. OPTIONS预检请求的完整握手过程
清楚了预检的触发条件后,我们来拆解整个握手的细节。这个过程说复杂也不复杂,但每一条请求头和响应头都对应着一次权限校验,少了任何一环,浏览器都会毫不留情地把请求拦下来。
2.1 浏览器主动发起的“安检”请求
当浏览器判定需要预检时,它会自动发起一个方法为 OPTIONS 的请求,目标 URL 和实际业务请求的 URL 完全一致。这个请求里不会携带实际业务数据,而是带着几个关键的请求头,告诉服务端“我想干什么”:
Origin: http://localhost:8080:当前页面的源,也就是我来自哪儿。Access-Control-Request-Method: POST:我接下来真正要发送的 HTTP 方法。Access-Control-Request-Headers: content-type, authorization:我接下来打算携带的额外请求头。
举个例子。如果前端代码是这么写的:
javascript复制fetch('http://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123'
},
body: JSON.stringify({ name: 'alice' })
})
浏览器实际发出的请求会是两个。第一个是预检请求,长这样:
code复制OPTIONS /users HTTP/1.1
Host: api.example.com
Origin: http://localhost:8080
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
注意,预检请求本身也是跨域请求,但它由浏览器内部机制发起,前端 JavaScript 代码看不到也不能主动干预。服务端收到这个请求后,需要判断是否允许来自 http://localhost:8080 的页面用 POST 方法,同时携带 content-type 和 authorization 这两个头来访问 /users 接口。
2.2 服务器必须回应的“通行证”响应头
如果服务端同意放行,会在 OPTIONS 请求的响应中返回一组 Access-Control-Allow-* 头。这组响应头就是通行证,浏览器读完以后才会继续发送真正的业务请求。关键的响应头包括:
Access-Control-Allow-Origin:允许访问的源。可以回显Origin请求头的值,也可以返回*。但注意,如果请求携带了凭证(Cookie),*是不允许的,必须明确指定源。Access-Control-Allow-Methods:允许的方法列表,比如GET, POST, PUT, DELETE, OPTIONS。Access-Control-Allow-Headers:允许的请求头列表,必须包含预检请求里Access-Control-Request-Headers声明的所有头。Access-Control-Max-Age:预检结果的缓存时间,单位秒。浏览器在这个时间范围内再次发起同类跨域请求时,不会重复发送预检,直接发送业务请求。Access-Control-Allow-Credentials:是否允许携带凭证,值为true时表示允许。这个头需要和前端fetch里的credentials: 'include'配合使用。
服务端正确响应后,完整响应看起来差不多是这样:
code复制HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:8080
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600
Access-Control-Allow-Credentials: true
这里有个细节值得注意:预检请求的响应状态码一般是 204 No Content,表示有响应头但没有响应体。很多配置不熟练的朋友会给 OPTIONS 返回 200,这也不是不行,但 204 更符合语义,而且能避免一些网络代理对响应体的额外处理。
2.3 预检通过后的“正式请求”链路
浏览器收到预检响应后,会做一次严格核对:Origin 是否被允许,Access-Control-Request-Method 是否在 Access-Control-Allow-Methods 里,Access-Control-Request-Headers 里的每一项是否都在 Access-Control-Allow-Headers 里。只要有一项对不上,预检就算失败,浏览器立刻抛错,真正的业务请求根本不会发出。
如果核对全部通过,浏览器才会发送真正的业务请求。以刚才的登录场景为例,第二个请求才是:
code复制POST /users HTTP/1.1
Host: api.example.com
Origin: http://localhost:8080
Content-Type: application/json
Authorization: Bearer token123
{"name":"alice"}
这个请求同样需要服务端在响应中返回 Access-Control-Allow-Origin,否则即使预检通过,浏览器也会把响应拦截,前端拿不到任何数据。这往往是很多人忽略的第二道关:预检通过了,但实际请求的响应没有 Access-Control-Allow-Origin,照样报 CORS 错误。
3. 手把手配置服务器:让预检请求安全放行
理解理论之后,最重要的就是动手配置。常见的后端环境无非是 Node.js、Java(Spring Boot)、Python(Flask/Django)以及 Nginx 反向代理,我以 Node.js 和 Nginx 为主讲一下配置方法,其他语言思路完全一致。
3.1 Node.js/Express 场景:中间件处理 OPTIONS
在 Express 里处理 CORS,最简单的方式是直接使用 cors 中间件,但如果你不想引第三方包,也可以通过几行自定义中间件把问题解决。我的原则是:先搞清楚原理,再决定要不要用工具。
一个最基础的允许所有跨域请求的中间件长这样:
javascript复制app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
这里必须把 OPTIONS 请求单独拦截并立即返回,因为很多业务中间件并没有处理 OPTIONS 方法的路由,如果不在这里提前结束,请求会被正常的业务路由逻辑处理,最终变成一个 404 或者 405,预检自然失败。而且我也建议把方法列表写全,不要只写 GET, POST, OPTIONS,否则以后前端一旦改用 PUT 或 DELETE,你又要改代码。
如果涉及登录凭证,Access-Control-Allow-Origin 就不能是 * 了。* 与 Access-Control-Allow-Credentials: true 是互斥的,浏览器规定两者不能同时出现。你需要动态回显请求的 Origin,并且维护一个白名单。
javascript复制const allowedOrigins = ['http://localhost:8080', 'https://admin.example.com'];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
注意,只有请求的 Origin 在白名单中,响应头才会带上 Access-Control-Allow-Origin 和 Access-Control-Allow-Credentials,这样既支持了携带 Cookie 的跨域请求,也避免了对未授权来源放行。
3.2 Nginx 反向代理场景:常见踩坑与配置
生产环境里,很多团队会用 Nginx 做反向代理和静态资源服务。如果前端资源和后端接口都在同一个域名下,通过 Nginx 把 /api 转发到后端服务,那其实不存在跨域问题,因为浏览器看到的请求是同源的。但如果你用 Nginx 对外提供跨域访问,或者把静态资源和接口拆到不同域名,就需要单独配置。
下面是一段我在生产环境用过的 Nginx 配置,注意几个细节:
nginx复制location /api/ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'http://localhost:8080';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' 3600;
return 204;
}
add_header 'Access-Control-Allow-Origin' 'http://localhost:8080';
add_header 'Access-Control-Allow-Credentials' 'true';
proxy_pass http://backend_server;
proxy_set_header Host $host;
}
这里最容易踩的坑是:add_header 只在当前 location 生效,一旦你用了 proxy_pass,那 Nginx 默认会丢弃一些响应头,包括 Access-Control-Allow-Origin 的某些继承场景。尤其是当后端服务自己已经返回了 CORS 响应头,而前端静态文件又恰好就在同一层 Nginx 上,叠加的头会让人看得一头雾水。我建议的原则是:CORS 响应头只在一个地方配置,要么后端处理,要么 Nginx 处理,不要两边都做,否则一旦规则冲突,排查起来非常痛苦。
另外,if ($request_method = 'OPTIONS') 这个写法在 Nginx 里是官方文档明确推荐的少数安全用法之一,它只针对 OPTIONS 方法做分支判断,不会影响其他请求。但要注意,这段配置里的 add_header 只对预检请求生效,实际业务请求的响应头需要在 proxy_pass 之后的 upstream 后端里配置,或者在同一个 location 外再用一层 add_header 补充。
3.3 携带 Cookie 的预检请求:credentials 的坑
这一节专门讲 Cookie,因为这个坑实在太深。前端如果用了 fetch(url, { credentials: 'include' }) 或者 XMLHttpRequest 设置了 withCredentials = true,浏览器要求预检请求和实际请求都满足三个条件:
- 服务端
Access-Control-Allow-Origin不能是*,必须是明确的源。 - 服务端必须显式返回
Access-Control-Allow-Credentials: true。 - 前端必须设置
credentials: 'include'。
三者缺一不可。我之前遇到过一起事故:跨域登录接口在测试环境一切正常,到了生产环境就报错,后来发现是生产 Nginx 配置里 Access-Control-Allow-Origin 写死了 *,而代码里又开了 credentials,浏览器直接拒绝,没有任何回旋余地。
还有一个很多人不知道的细节:当携带 Cookie 时,响应的 Set-Cookie 头里的 SameSite 属性也可能影响跨域场景。如果 Cookie 的 SameSite 是 Lax 或 Strict,第三方请求可能根本不会附带上 Cookie。遇到这种情况,后端通常需要把 Cookie 的 SameSite 设为 None,同时 Secure 必须为 true,否则浏览器同样会拦截。这块配置属于服务端会话管理的范畴,排查顺序应该在 CORS 报错检查完之后紧接着确认。
4. 调试预检请求:浏览器开发者工具里的实战观察
理论再清楚,不会用工具观察也白搭。我在排查跨域问题时,基本上只靠浏览器开发者工具和命令行里的 curl 就能搞定,下面把操作步骤梳理出来。
4.1 如何快速判断预检是否失败
打开浏览器开发者工具的 Network 面板,刷新页面或者重试接口,然后筛选 OPTIONS 请求,你会看到两种情况。
第一种情况,列表里只有一个 OPTIONS 请求,没有后续的 POST 或 GET 请求,说明预检请求失败,浏览器直接中断了后续动作。点开 OPTIONS 请求,查看 Response Headers 里是否有完整的 Access-Control-Allow-* 头;如果响应头不完整,或者根本没有响应,问题出在服务端没有正确处理预检。
第二种情况,列表里先有一个 OPTIONS,紧接着有一个同名同 URL 的 POST 请求,两者都返回正常状态码,说明预检已通过。但这时候如果前端还是显示跨域错误,就要进一步检查 POST 请求的响应头里是否带了 Access-Control-Allow-Origin。有时候预检配置没问题,业务请求的响应头却在网关层被吞掉了,导致浏览器认为服务端没有授权。
4.2 常见预检失败信息与排查技巧
浏览器控制台常见的报错信息就那么几类,每一类对应的原因基本能一眼锁定:
| 报错信息 | 常见原因 | 排查方向 |
|---|---|---|
| Request header field authorization is not allowed by Access-Control-Allow-Headers | 预检请求声明的 Access-Control-Request-Headers 里有服务端未放行的头 |
在 Access-Control-Allow-Headers 中补上对应头,比如 Authorization |
| Method PUT is not allowed by Access-Control-Allow-Methods | 预检请求声明的请求方法不在服务端允许列表 | 在 Access-Control-Allow-Methods 中加上 PUT |
| The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' | 请求携带凭证,但服务端返回的是 * |
改为回显 Origin,并返回 Access-Control-Allow-Credentials: true |
| Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present | OPTIONS 响应里完全没有 CORS 头 | 检查服务端是否拦截了 OPTIONS,或者 Nginx 配置是否在错误的 location 块里 |
| Credentials flag is 'true', but the 'Access-Control-Allow-Credentials' header is not 'true' | 前端开了 credentials,但服务端没有允许凭证 | 服务端返回 Access-Control-Allow-Credentials: true |
排查的核心原则就一条:以开发者工具里实际看到的响应头为准,不要只靠代码逻辑去猜。因为整个链路里可能隔着网关、Nginx、后端框架,每一层都有机会改写请求头或响应头,只看代码往往发现不了问题。
4.3 一键模拟 OPTIONS 请求:命令行调试
有些问题在开发者工具里看不太清楚,尤其是后端开发环境,前端页面还没起来,这个时候用 curl 直接模拟预检请求就很方便。命令如下:
bash复制curl -i -X OPTIONS https://api.example.com/users \
-H "Origin: http://localhost:8080" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization"
-i 表示显示响应头,这是排查的关键。服务端返回什么,一目了然。如果响应头里缺少关键字段,直接去改服务端配置;如果响应头齐全,那就说明问题出在浏览器这一侧,比如前端是否设置了 credentials,或者请求头里带了服务端不允许的字段。
这个方法也特别适合验证 Nginx 或网关层有没有把响应头过滤掉。有一次我遇到一个很奇怪的问题:OPTIONS 请求在浏览器里能看到 204,响应头也完整,但实际 POST 请求就是报跨域错误。用 curl 模拟 POST 后发现,后端服务返回的响应头居然没有 Access-Control-Allow-Origin,原来是我们那边的 Java 网关只给 OPTIONS 请求统一追加了跨域头,却漏掉了对正常请求的处理。这类问题如果光靠前端开发者工具,很容易误判成前端代码的锅。
5. 减少预检请求的实用优化策略
预检请求虽然在功能上必不可少,但它毕竟是两倍的网络开销。尤其在高频调用的内部接口里,每一个 POST JSON 请求都附带一次 OPTIONS,请求延迟翻倍。如果真遇到性能瓶颈,可以从几个方向优化。
5.1 用“简单请求”替代“复杂请求”
最彻底的办法是让请求回归简单请求。比如用 application/x-www-form-urlencoded 替代 application/json 提交表单数据,就不会触发预检。但这个方法局限性很大,JSON 格式的易读性和嵌套功能是表单格式没法比的,所以它更适合那些对请求体格式要求不高的场景,比如基础的表单提交、文件上传的 multipart/form-data 本身就是简单请求格式,天然不会触发预检。
另一种思路是尽量用 GET 请求承载无副作用的操作。如果只是查询数据,没有携带自定义头,浏览器会直接发送,不会有预检。但要注意,把本应使用 POST 的写操作改成 GET 会破坏 HTTP 语义,还可能引发安全风险,所以这个方法我只建议用在纯查询接口上。
5.2 预检结果缓存 Access-Control-Max-Age
更通用的优化手段是让浏览器缓存预检结果。Access-Control-Max-Age 响应头可以告诉浏览器:“这次批准的结果在多少秒内都有效,不用反复来问。”比如设置为 3600,那么一个小时内,同源、同方法、同请求头的跨域请求都不会再发 OPTIONS,直接走正式请求。
设置这个头的时候要注意,不同浏览器对最大缓存时间有自己的上限。Chrome 目前在 7200 秒左右,Firefox 是 86400 秒。你把值设成 864000 也没有意义,浏览器会按自己的上限来截断。实际项目中,我一般设置 3600 秒到 7200 秒之间,既保证了权限变更后的响应速度,也能把预检请求的数量压下去。
需要注意的是,Access-Control-Max-Age 只对预检请求结果生效,不会缓存业务请求本身。另外,一旦服务端修改了允许的请求方法或请求头名单,浏览器可能还会沿用旧的缓存结果,所以测试的时候如果发现配置改了却不生效,可以先把 Access-Control-Max-Age 设为 0 来临时禁用缓存,排查完再改回来。
5.3 避免在响应头里滥用自定义字段
有的团队习惯在请求里加一堆自定义头,比如 X-User-ID、X-Request-ID、X-Trace-ID,用来做链路追踪。每加一个自定义头,预检请求里的 Access-Control-Request-Headers 就会多一项,服务端的 Access-Control-Allow-Headers 就必须相应放行。一旦某个服务端忘了加上新字段,预检就会失败,整个请求直接挂掉,排查起来极其痛苦。
更合理的做法是尽量复用浏览器标准头。比如身份信息放在 Authorization 里,幂等 ID 放在请求体里,链路追踪需要的信息可以放到 URL 查询参数里。这样自定义头数量控制在一到两个,服务端配置相对稳定,以后新增字段也不需要频繁改动跨域策略。
6. 总结预检请求的关键认知与个人经验
预检请求不算一个特别复杂的概念,但和 CORS 配置混在一起后,就变成了前后端联调阶段最常见的拦路虎。我见过太多团队在排查跨域问题时,前端指后端,后端指浏览器,最后才发现问题出在网关层漏配了一个响应头。
我自己实际干过的一件事,值得分享给所有开发同学:在项目开发早期,就把整套跨域请求的响应头约定写成一份文档,里面明确列出允许的源、方法、请求头、Cookie 策略和预检缓存时间,前后端共用这一份契约。这样一来,前端在发请求之前就能对照检查自己的请求头,后端在写接口时也能照单配置,很多坑直接在编码阶段就避开了。尤其是公司内部有多个前端项目和多个后端服务时,统一约定比各写各的省心太多。
最后再分享一个小技巧:如果你在本地联调时被 OPTIONS 搞得心烦,最快的定位方式是把浏览器开发者工具里的 Network 面板打开,先看请求列表里有没有 OPTIONS 和业务请求的先后关系,再有针对性地用 curl 模拟一遍。绝大多数预检问题,在这两步之后都能找到答案。真正藏在框架和网关深处的坑需要耐心逐步加日志定位,但只要理解了浏览器这套“隐形保安”的检查逻辑,跨域请求就再也不会让你束手无策。
