创见博客
OPTIONS 预检请求是什么
七崽爱吃小饼干2026/10/11阅读 0

一个再普通不过的跨域请求,打开 Network 却先看到一个 OPTIONS:

text
OPTIONS /api/article/save    204
PUT     /api/article/save    200

你没写过 OPTIONS,请求却多了一次;有时它还会直接报错:

text
Request Method: OPTIONS
Status Code: 405 Method Not Allowed
Access to fetch at 'https://api.b.com' from origin 'https://a.com'
has been blocked by CORS policy: Response to preflight request
doesn't pass access control check.

这个自动冒出来的 OPTIONS 就是预检请求(preflight request)。它不是你的代码发的,是浏览器发的,用来在正式请求落地前先问服务器"准不准"。

一、预检到底是什么

浏览器发起跨域请求时,只要命中"非简单请求",就会先自动发一个 OPTIONS 请求去探路,问服务器是否允许稍后那次真正的跨域访问。服务器同意后,浏览器才发送真正的请求。

text
               浏览器                     服务器
  ① 预检   OPTIONS /api/save  ─────────▶  我可以用 PUT 和
           Origin: https://a.com            X-Token 吗?
           Access-Control-Request-Method: PUT
           Access-Control-Request-Headers: x-token
                              ◀─────────  Access-Control-Allow-Methods: PUT
  ② 实际   PUT /api/save      ─────────▶  真正带业务数据的请求

关键点:预检失败时,真正的请求根本不会发出。这一点是理解它价值的前提。

二、为什么需要预检

同源策略出现的背景是:像 <form>、<img>、<script> 这类标签本来就能跨域发请求,而且请求会真的到达服务器、真的执行。同源策略只能阻止读取响应,拦不住请求本身。

也就是说,一个跨域 POST 完全可能已经把服务器数据改了,攻击者只是读不到返回结果。CORS 出现后:

  • 简单请求:浏览器照样把请求发出去,CORS 只是不让你读响应——副作用已经发生了。
  • 非简单请求:浏览器先发 OPTIONS 预检,服务器明确同意后才发真正请求。副作用被提前拦住了。

所以预检的目的不是保护响应数据,而是在可能产生副作用的请求真正落地之前,给服务器一次否决的机会,同时确认服务器确实支持跨域,避免被跨站利用。

三、什么时候触发预检

不是所有跨域请求都会预检,只有"非简单请求"才会。简单请求需同时满足:

  • 方法是 GET、HEAD 或 POST;
  • 请求头只在安全列表内:Accept、Accept-Language、Content-Language、Content-Type、Range 等;
  • Content-Type 只能是 application/x-www-form-urlencoded、multipart/form-data、text/plain;
  • 没有注册 XMLHttpRequestUpload 事件监听,请求里也没用到 ReadableStream。

只要有一项不满足,就触发预检。最常见的几种:

js
// 1. 用了非简单方法
fetch(url, { method: 'PUT' })

// 2. Content-Type 是 application/json
fetch(url, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(data),
})

// 3. 带了自定义头
fetch(url, { headers: { 'X-Token': 'abc' } })

这也是为什么"带 token 的跨域接口"几乎必然会看到 OPTIONS——Authorization 或 X-Token 属于自定义头,直接命中预检。

四、请求头和响应头

预检请求携带(由浏览器自动加上):

http
OPTIONS /api/save HTTP/1.1
Origin: https://a.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, x-token
  • Origin:请求来自哪个源。
  • Access-Control-Request-Method:接下来真正请求要用的方法。
  • Access-Control-Request-Headers:接下来真正请求会带的自定义头。

服务器要回(缺一项都可能预检失败):

http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://a.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, X-Token
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
  • Access-Control-Allow-Origin:允许的来源。带 Cookie 时不能是 *,必须回具体源。
  • Access-Control-Allow-Methods / Access-Control-Allow-Headers:必须覆盖实际要用的方法和头。
  • Access-Control-Allow-Credentials:是否允许携带凭证(Cookie)。为 true 时上面两项和 Allow-Origin 都不能用通配符。
  • Access-Control-Max-Age:预检结果缓存秒数,缓存期内同一请求不再重复预检。

预检响应本身必须是 2xx 状态码并带上面这些头,否则浏览器判定失败。

五、服务端怎么处理

预检是 OPTIONS 方法,如果服务端没显式处理,很容易被路由拦成 405。以 Next.js Route Handler 为例:

ts
// app/api/save/route.ts
export async function OPTIONS() {
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': 'https://a.com',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, X-Token',
      'Access-Control-Allow-Credentials': 'true',
      'Access-Control-Max-Age': '86400',
    },
  })
}

export async function PUT(req: Request) {
  // 真正的业务逻辑,同样要带上 CORS 响应头
  return Response.json({ ok: true }, {
    headers: { 'Access-Control-Allow-Origin': 'https://a.com' },
  })
}

注意两点:

  1. OPTIONS 只是探路,不应执行业务逻辑,直接返回 CORS 头即可。
  2. 真正请求(这里是 PUT)的响应也要带 CORS 头,否则浏览器阶段仍然会拦。

六、常见坑

  • Allow-Headers 漏了头:实际请求带了 X-Token,但服务端只写了 Content-Type,预检直接失败。
  • 带凭证却回了 *:credentials: 'include' 时 Allow-Origin: * 无效,必须回具体源。
  • 预检没返回 2xx:405、403 都会让预检失败,哪怕实际接口逻辑是对的。
  • 网关/中间件拦掉 OPTIONS:Nginx、鉴权中间件常把 OPTIONS 当成未授权请求拦掉,需要放行。
  • 改动响应头导致缓存失效:Access-Control-Max-Age 到期后要重新预检,调大它能减少请求数。

七、速查表

项目说明
谁发起浏览器自动发起,不是业务代码
方法OPTIONS
触发条件非简单请求(非 GET/HEAD/POST、非安全 Content-Type、有自定义头等)
请求头Origin、Access-Control-Request-Method、Access-Control-Request-Headers
必需响应头Allow-Origin、Allow-Methods、Allow-Headers(带凭证时还有 Allow-Credentials)
结果缓存Access-Control-Max-Age
失败后果真正的请求不会发出,控制台报 CORS 错误
场景是否预检
GET,无自定义头否
POST + application/x-www-form-urlencoded否
POST + application/json是
任意方法 + X-Token 等自定义头是
PUT / DELETE是

小结

  • 预检是浏览器自动发的 OPTIONS 请求,用来在正式跨域请求前征求服务器同意。
  • 它能拦住的是请求副作用,而不只是响应读取,这是它和简单请求的本质差别。
  • 触发条件就是"非简单请求":非简单方法、非安全 Content-Type、自定义头。
  • 服务端必须显式处理 OPTIONS 并返回完整 CORS 头,且真正的请求响应也要带头。
评论
0/100