CORS 跨域完全指南:从一次"本地好好的、一上线前端就报跨域错误"看懂浏览器同源策略

2021 年我做一个前后端分离的项目前端是一个 React 应用跑在一个域名上后端是一组 API 部署在另一个域名上前端调后端接口这件事我压根没多想本地开发时我用了脚手架自带的 proxy 前端发请求到 api 开发服务器帮我转发到后端一切顺畅接口调得通数据拿得到我心里很踏实可等我把前端真正打包部署到线上那个域名一串问题冒了出来第一种最先把我打懵本地明明好好的一上线前端控制台就飘红 blocked by CORS policy 接口一个都调不通第二种最难缠我上网一搜说后端加个 Access-Control-Allow-Origin 星号就行我加了普通接口好了可带登录态的接口还报错而且报错信息变成了另一句看不懂的话第三种最头疼我去翻后端日志发现前端发一个 POST 后端居然先收到一个我从没写过的 OPTIONS 请求而且我的业务代码压根没被执行第四种最莫名其妙有个接口前端报 CORS 错我以为是跨域配置问题折腾半天最后发现后端那个接口本身 500 了浏览器却只甩给我一句 CORS 报错把真正的错误盖得严严实实我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为跨域报错就是后端少加了个响应头加上就好这句话把 CORS 当成了一个后端的开关后端允许了跨域就通了可它根本不是这么回事 CORS 这套机制从头到尾是浏览器在执行的不是后端是浏览器在你的 JavaScript 发起一个跨域请求时自作主张地拦下来做安全检查是浏览器根据后端返回的响应头来判断这个跨域请求允不允许把结果交给页面的 JS 后端那几个 Access-Control 开头的响应头不是命令而是声明后端在向浏览器报备我愿意接受来自哪些源的跨域请求真正做决定真正执行拦截的始终是浏览器真正搞懂跨域核心不是背下来该加哪几个响应头而是理解 CORS 是浏览器强制执行的一套安全协议浏览器拦截跨域请求可能先发一个预检请求来探路再根据后端响应头里的声明决定放不放行后端的配置只是在配合浏览器这套流程而不是在指挥它本文从头梳理为什么本地好好的上线就跨域 CORS 到底是谁在执行简单请求和预检请求的区别那个神秘的 OPTIONS 是怎么回事带凭证的跨域为什么不能用通配符以及 CORS 报错掩盖真实错误这些把跨域搞扎实要避开的坑

2021 年我做一个前后端分离的项目:前端是一个 React 应用,跑在一个域名上;后端是一组 API,部署在另一个域名上。前端调后端接口,这件事我压根没多想。本地开发时我用了脚手架自带的 proxy,前端发请求到 /api/xxx,开发服务器帮我转发到后端——一切顺畅,接口调得通、数据拿得到。我心里很踏实:"前端调后端,不就是发个 HTTP 请求嘛,能有什么讲究?"可等我把前端真正打包、部署到线上那个域名,一串问题冒了出来。第一种最先把我打懵:本地明明好好的,一上线,前端控制台就飘红——blocked by CORS policy,接口一个都调不通。第二种最难缠:我上网一搜,说后端加个 Access-Control-Allow-Origin: * 就行,我加了,普通接口好了,可带登录态的接口还是报错,而且报错信息变成了另一句看不懂的话。第三种最头疼:我去翻后端日志,发现前端发一个 POST,后端居然先收到一个我从没写过的 OPTIONS 请求,而且我的业务代码压根没被执行。第四种最莫名其妙:有个接口前端报 CORS 错,我以为是跨域配置问题,折腾半天,最后发现后端那个接口本身 500 了——浏览器却只甩给我一句 CORS 报错,把真正的错误盖得严严实实。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"跨域报错,就是后端少加了个响应头,加上就好"。这句话把 CORS 当成了一个后端的开关:后端"允许"了,跨域就通了。可它根本不是这么回事我脑子里,前端发请求、后端处理返回,中间是直来直去的;跨域报错,无非是后端忘了说一声"我允许"。可真相是:CORS 这套机制,从头到尾是浏览器在执行的,不是后端。是浏览器,在你的 JavaScript 发起一个跨域请求时,自作主张地拦下来做安全检查;是浏览器,根据后端返回的响应头来判断"这个跨域请求允不允许把结果交给页面的 JS"。后端那几个 Access-Control 开头的响应头,不是"命令",而是"声明"——后端在向浏览器报备"我愿意接受来自哪些源的跨域请求"。真正做决定、真正执行拦截的,始终是浏览器。这就解释了我那一连串怪事:本地好好的,是因为开发服务器的 proxy 让浏览器以为请求是同源的,CORS 根本没被触发;那个我没写过的 OPTIONS,是浏览器在正式请求之前,自己悄悄发出去的一次"预检",在问后端"我等下要发的这个请求,你认不认";加了星号还报错,是因为浏览器有一条额外规则——一旦请求要带上 cookie 这类凭证,后端就不许用通配符星号、必须明明白白回一个具体的源。我一直把 CORS 当成后端的事,可它的每一个动作,主语都是浏览器。真正搞懂跨域,核心不是"背下来该加哪几个响应头",而是理解 CORS 是浏览器强制执行的一套安全协议:浏览器拦截跨域请求,可能先发一个预检请求来探路,再根据后端响应头里的"声明"决定放不放行——后端的配置只是在配合浏览器这套流程,而不是在指挥它。这篇文章就把 CORS 跨域这个坑梳理一遍:为什么"本地好好的、上线就跨域"、CORS 到底是谁在执行、简单请求和预检请求的区别、那个神秘的 OPTIONS 是怎么回事、带凭证的跨域为什么不能用通配符,以及 CORS 报错掩盖真实错误这些把跨域搞扎实要避开的坑。

问题背景

这个坑普遍,是因为"前端发请求、后端给响应"这个直觉太朴素了——大多数人第一次遇到 CORS,都觉得它是后端配置出了问题,加个头就完事。它错得隐蔽,是因为本地开发环境几乎一定测不出来:现代前端脚手架默认配了开发代理,把前端请求和后端响应都包装成"同源",CORS 这套机制压根不会启动,你在本地把功能跑得再顺,也碰不到它。它只在前端打包部署到真实域名、和后端不同源时才暴露,而那时往往已经是上线前夜。

把这个现象拆开,错误认知和真相是这样对应的:

  • 现象:本地一切正常,上线后前端报 blocked by CORS policy;加了通配符普通接口好了、带登录态的还报错;后端莫名收到 OPTIONS 请求;CORS 报错背后藏着后端的 500。
  • 错误认知一:以为 CORS 报错是后端少配了响应头,后端"允许"就通了。真相是 CORS 由浏览器强制执行,后端响应头只是供浏览器判断的声明。
  • 错误认知二:以为前端发什么请求,后端就直接收到什么。真相是非简单请求会被浏览器先拦下,自动发一个 OPTIONS 预检请求探路。
  • 错误认知三:以为 Access-Control-Allow-Origin: * 是万能解。真相是请求一旦带凭证(cookie),通配符就失效,必须回具体的源。
  • 真相:CORS 是浏览器为了保护用户、对跨域请求强制执行的一套安全协议。理解它要站在浏览器视角:它何时拦截、何时预检、依据哪些响应头放行——后端配置是在配合这套流程。

一、为什么"本地好好的、上线就跨域"

先说清楚"同源"和"跨域"。浏览器判断两个 URL 是不是"同源",看三样东西:协议、域名、端口,三者全都一样才叫同源,有任何一个不同,就是跨域。

当前页面:https://shop.example.com

https://shop.example.com/api/list   同源   协议域名端口都相同
http://shop.example.com/api/list    跨域   协议不同 https vs http
https://api.example.com/list        跨域   域名不同 shop vs api
https://shop.example.com:8080/list  跨域   端口不同 默认443 vs 8080

关键来了:本地开发为什么测不出跨域?因为前端脚手架的开发服务器,几乎都默认开了一个 proxy 代理。看一段典型配置:

// vite.config.js —— 本地开发服务器的代理配置
export default {
  server: {
    proxy: {
      // 前端代码里请求 /api/xxx,实际是发给本地开发服务器(同源)
      // 再由开发服务器在"服务器端"转发给真正的后端
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
      },
    },
  },
};

看明白这段配置干了什么:你的前端 JS 发请求到 /api/list,这个地址和前端页面同源(都是 localhost:5173),浏览器一看同源,CORS 机制根本不启动。请求落到本地开发服务器手里,再由开发服务器(一个 Node 进程,不是浏览器)去转发给真正的后端——而服务器与服务器之间的请求,没有 CORS 这回事。于是本地全程顺畅。可一旦你把前端打包、部署到 https://shop.example.com,代理没了,前端 JS 直接对着 https://api.example.com 发请求——真真切切的跨域,浏览器立刻拦下。

这里要建立的第一个、也是最重要的认知是:本地那套开发代理,本质上是用一个"同源的假象"把 CORS 这套机制整个屏蔽掉了,于是你在本地写的、测的、自信满满的代码,从来没有真正面对过跨域这件事。这就是为什么这个坑几乎必然在上线时引爆——不是你上线时改坏了什么,而是上线这个动作,第一次撤掉了那层假象,让你的请求第一次以"跨域"的真实身份去见浏览器。你要从这里立住一个判断:跨不跨域,取决于"浏览器地址栏里的页面"和"请求要去的地址"这两者同不同源,而不取决于你代码里的请求路径写成什么样。本地写 /api 看着像同源,那只是因为中间垫了个代理;上线后同一行代码,请求的真实去向变了,身份就变了。理解了这一层,你就会明白:解决跨域,第一步根本不是去后端加头,而是先搞清楚——你的前端页面在哪个源、它要请求的接口在哪个源、这两个源到底是不是真的跨域。把这两个源摆清楚,后面所有的配置才有意义。

二、CORS 是浏览器的机制,不是后端的

第一版我栽的最大跟头,是认知方向反了:我以为 CORS 是"后端允许不允许跨域"的开关。真相是,CORS 从头到尾是浏览器在执行的。它的完整名字是"跨源资源共享",而它存在的理由是保护用户:如果没有它,你登录了网银,然后随手打开一个恶意网页,那个网页里的 JS 就能拿着你浏览器里的网银 cookie,默默向网银接口发请求、转走你的钱。CORS 就是浏览器立的规矩——一个页面的 JS 想访问另一个源的资源,必须经过浏览器审查

所以这套流程里,各方的角色是这样的:

  • 浏览器:唯一的执法者。它拦截跨域请求,它决定要不要先发预检,它读后端的响应头,它最终决定"把响应交给页面 JS"还是"拦下并报 CORS 错"。
  • 后端:被询问的一方。它通过 Access-Control-* 系列响应头,向浏览器声明自己愿意接受哪些源、哪些方法、哪些请求头。它不能命令浏览器,只能如实报备。
  • 前端 JS:发起者。它正常发请求,但拿不拿得到响应,由浏览器说了算。

有一个细节最能说明"CORS 是浏览器的事":当一个跨域请求被 CORS 拦截,后端其实常常已经收到并处理了这个请求,响应也正常返回了——只是浏览器在拿到响应后,检查响应头发现没有合法的 Access-Control-Allow-Origin,于是把这个响应扣下、不交给你的 JS,并在控制台报错。后端日志里那条请求是 200,前端却报错——这个矛盾的现场,正是"执法者是浏览器"的铁证。下面是后端用来"报备"的那组响应头:

Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600

这里要建立的认知是:把 CORS 的执法者从"后端"纠正成"浏览器",是搞懂跨域的总开关,这一个认知一摆正,后面所有现象都顺了。为什么后端日志显示请求成功、前端却报错?因为执法的是浏览器,它在响应回来后才扣下结果。为什么用 curl、用 Postman 调同一个接口完全正常、唯独浏览器里报跨域?因为 curl 和 Postman 不是浏览器,它们不执行 CORS,这套机制对它们根本不存在。为什么后端把头都加齐了还报错?因为最终判断权在浏览器,你得回到浏览器的视角去看它到底在不满意哪一条。一旦你接受"后端的 Access-Control 头只是声明、浏览器才是裁判",你排查跨域的方法就变了:你不再是盲目地往后端堆响应头,而是打开浏览器的网络面板,看浏览器实际发出了什么请求、收到了什么响应头、它具体抱怨哪一条不匹配——你开始站在执法者的肩膀上看问题。这个视角,比记住任何一个响应头的拼写都重要。

三、简单请求与预检请求:那个神秘的 OPTIONS

浏览器执行 CORS 时,把跨域请求分成两类,处理方式完全不同。一类叫简单请求,浏览器直接发、附带响应再判断;另一类叫非简单请求,浏览器要先发一个预检请求探路

一个请求要算"简单请求",得同时满足几个苛刻条件:方法是 GETHEADPOST 之一;自定义请求头不超出一个很短的白名单;Content-Type 只能是 text/plainapplication/x-www-form-urlencodedmultipart/form-data 三者之一。任何一条不满足,就是非简单请求。这就解释了第三个怪现象——现代前端发 POST 几乎都用 Content-Type: application/json,而 application/json 不在那三个之列,于是它必然是非简单请求,必然触发预检。

预检,就是浏览器在发真正的请求之前,自动、自作主张地先发一个 OPTIONS 请求过去,问后端:"我等一下要发一个带着这些方法、这些头的跨域请求,你认不认?"后端用响应头回答,浏览器看了回答,才决定要不要发真正的请求。整个流程是这样的:

所以后端必须正确处理这个 OPTIONS 请求——它不会进入你的业务逻辑,你得让框架在它身上回好那组 Access-Control 头。用 Express 举例,正确的 CORS 中间件要这样写:

// Express:手写一个能正确处理预检的 CORS 中间件
const ALLOW_ORIGINS = ['https://shop.example.com'];

function cors(req, res, next) {
  const origin = req.headers.origin;
  if (ALLOW_ORIGINS.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }
  res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');

  // 关键:OPTIONS 预检请求,直接回 204,不要走进业务逻辑
  if (req.method === 'OPTIONS') {
    res.statusCode = 204;
    return res.end();
  }
  next();
}

app.use(cors);

这里要建立的认知是:那个你从没写过、却出现在后端日志里的 OPTIONS 请求,不是 bug,不是攻击,它是浏览器 CORS 流程里一个正大光明的、必经的步骤——预检。理解预检的关键,是理解它为什么存在:对于那些可能"改数据"的请求(带 JSON body 的 POST、PUT、DELETE),浏览器很谨慎,它不愿意先把这个有副作用的请求真的发出去、再来判断该不该发——万一后端不接受跨域,数据可能已经被改了。所以它先发一个绝对安全、无副作用的 OPTIONS 去问一声,得到肯定答复,才放真正的请求出去。这是一种"先问后做"的安全设计。这个认知会改变你两件事:第一,你不会再把日志里的 OPTIONS 当成怪事,而会主动确认后端框架有没有正确接住它——很多跨域故障,根子就是 OPTIONS 没被处理,撞进了某个需要登录鉴权的中间件,被打回 401,浏览器一看预检失败,真实请求根本不发。第二,你会意识到一个 POST 接口在浏览器眼里其实是"两次请求":一次预检、一次真实请求,排查时要把这两条都在网络面板里看清楚,别只盯着真实请求那一条。

四、带凭证的跨域:为什么通配符不管用

现在解释第二个怪现象:加了 Access-Control-Allow-Origin: *,普通接口好了,带登录态的接口还报错。问题出在"凭证"上。这里的凭证,指的是 cookie、HTTP 认证信息这类身份数据。

默认情况下,浏览器发跨域请求时,是不会带上 cookie 的——这又是 CORS 的一道保护。如果你的接口靠 cookie 维持登录态,你必须在前端显式声明"这个跨域请求要带凭证":

// 前端:跨域请求要带 cookie,必须显式声明 credentials
fetch('https://api.example.com/profile', {
  method: 'GET',
  credentials: 'include',   // 不写这个,跨域请求不会带上 cookie
})
  .then((res) => res.json())
  .then((data) => console.log(data));

// 用 axios 则是:axios.get(url, { withCredentials: true })

而一旦请求带了凭证,浏览器会启用一条更严格的规则:后端的 Access-Control-Allow-Origin 绝对不能是通配符星号,必须是一个具体的、明确的源;同时后端还必须额外回一个 Access-Control-Allow-Credentials: true。少任何一条,浏览器都会拦下,并且报一句和之前不一样的错。后端要这样配:

// 后端:支持带凭证的跨域,Allow-Origin 必须回具体的源
function corsWithCredentials(req, res, next) {
  const origin = req.headers.origin;
  // 用一份白名单,逐个比对,命中谁就回谁 —— 绝不能回 '*'
  if (ALLOW_ORIGINS.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
  // ……其余 Methods / Headers 同前
  if (req.method === 'OPTIONS') {
    res.statusCode = 204;
    return res.end();
  }
  next();
}

为什么带凭证就禁用通配符?因为通配符的意思是"我接受来自任何源的跨域请求"。这对不带身份的公开数据没问题;可一旦允许带 cookie,就等于说"任何一个网站的 JS,都能带着用户的登录 cookie 来调我的接口"——这正是 CORS 要防的攻击。所以浏览器强制:你想要凭证,就必须把"任何"换成一个你点名认可的具体源。注意配了具体源以后,后端最好再回一个 Vary: Origin,提醒中间的缓存"这个响应是随 Origin 变化的",别把给 A 源的响应缓存了再发给 B 源。

这里要建立的认知是:Access-Control-Allow-Origin 的通配符星号,不是"更方便的写法",它是一个有明确语义边界的选择——它的含义是"这是一份对全世界公开、不涉及任何用户身份的资源"。当你的接口确实是公开数据(比如一个公开的汇率 API),用星号是恰当的;可一旦接口和"用户是谁"挂钩、要靠 cookie 认证,星号就从"方便"变成了"危险",浏览器于是直接禁用它。这背后是一个值得记住的安全原则:身份凭证和宽泛授权,绝不能同时存在。你要么是一份不认身份、对谁都一样的公开资源(可以用星号、但不能带凭证),要么是一份认身份的私有资源(可以带凭证、但必须精确点名信任的源)——浏览器不允许你既享受星号的省事、又享受带凭证的便利。所以正确的做法不是去找"怎么让星号配合凭证",而是老老实实维护一份可信源的白名单,请求来了,拿它的 Origin 在白名单里查,命中了就把它本身原样回给 Allow-Origin。这份白名单,就是你对"我信任谁"这个问题的明确回答,它本就该明确,不该用一个星号糊弄过去。

五、预检缓存与那几个容易配错的响应头

每个非简单请求前面都垫一个 OPTIONS,等于每次业务请求都变成两个来回,有性能损耗。Access-Control-Max-Age 就是来解决这个的——它告诉浏览器:这次预检的结果,你可以缓存多少秒,这段时间内同样的请求别再预检了

# 后端在 OPTIONS 响应里加上:
Access-Control-Max-Age: 600

# 含义:600 秒内,对同一个接口的同类请求,浏览器不再发预检,
# 直接发真实请求。能显著减少 OPTIONS 往返。
# 注意:各浏览器对这个值有上限(比如有的封顶在 7200 秒),
# 设一个超大的值也会被截断到上限。

另外几个真实项目里反复配错的响应头,逐个说清楚。Access-Control-Allow-Headers:它得列全前端真实请求里所有的自定义头。前端如果带了 Authorization、带了自定义的 X-Request-Id,这个响应头里就必须都写上,漏一个,预检就不通过。Access-Control-Allow-Methods:得包含你真实请求要用的方法。还有一个特别隐蔽的——前端默认只能读到几个基本响应头,如果后端把数据放在一个自定义响应头里(比如分页总数放在 X-Total-Count),前端 JS 是读不到的,除非后端显式用 Access-Control-Expose-Headers 把它"暴露"出来:

// 后端:让前端能读到自定义的响应头
res.setHeader('X-Total-Count', '328');
// 不加下面这行,前端 res.headers.get('X-Total-Count') 拿到的是 null
res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count');

如果你用 Nginx 在网关层统一处理 CORS,要特别小心 OPTIONS 的分支:

# Nginx 网关层统一加 CORS,注意单独拦住 OPTIONS
location /api/ {
    set $cors_origin "";
    if ($http_origin ~* "^https://shop\.example\.com$") {
        set $cors_origin $http_origin;
    }
    add_header Access-Control-Allow-Origin  $cors_origin always;
    add_header Access-Control-Allow-Credentials true always;
    add_header Access-Control-Allow-Headers "Content-Type,Authorization" always;

    # 预检请求在网关层直接返回 204,不回源到后端
    if ($request_method = OPTIONS) {
        add_header Access-Control-Max-Age 600;
        return 204;
    }
    proxy_pass http://backend;
}

这里要建立的认知是:CORS 的这些响应头,不是一组"复制粘贴一遍就万事大吉"的咒语,它们每一个都对应着浏览器流程里一个具体的检查点,你得知道每个头在回答浏览器的哪个问题。Allow-Headers 回答的是"前端这次带的这些自定义头,你后端认不认",所以它必须和前端实际发的头对齐,前端加一个新头,这里就得跟着加。Max-Age 回答的是"这个预检结果能信多久",它是一个性能旋钮,设了它就少很多 OPTIONS 往返。Expose-Headers 最容易被忘,它回答的是反方向的问题——"后端响应里的这些自定义头,允不允许前端 JS 读到",不配它,你把数据塞在自定义响应头里发回去,前端拿到的是一片空白,而这件事不会报任何错,排查起来格外费劲。把这些头逐个理解成"浏览器问的一个问题、后端给的一个回答",你配 CORS 时就不再是抄模板,而是在和浏览器对话:它会问什么、我该答什么、我漏答了哪一条——CORS 配置从一件玄学,变成一件你能逐条推演的确定的事。

六、工程里那些 CORS 的坑

把 CORS 配通之后,还有几个真实项目里反复咬人的坑。

第一个,也是最坑的——CORS 报错会掩盖真正的错误。这就是第四个怪现象:接口报 CORS 错,你以为是跨域配置,查半天,真相是后端那个接口本身 500 了。为什么?因为后端一旦在业务里抛异常、走进 500 错误流程,常常就来不及、或根本没有把那组 Access-Control-Allow-Origin 响应头加上;浏览器收到一个没有合法 CORS 头的响应,只会报"CORS policy"——它不关心、也不告诉你后端其实是 500。排查口诀:遇到 CORS 报错,先用 curl 或 Postman 直接打那个接口,它们不执行 CORS,能让你看到后端到底回的是 200 还是 500、body 是什么。如果 curl 显示 500,那这压根不是跨域问题。

# 遇到 CORS 报错,第一步:绕开浏览器,直接打接口看真相
# curl 不执行 CORS,能暴露后端的真实状态码和响应体
curl -i -X POST https://api.example.com/order \
  -H "Content-Type: application/json" \
  -H "Origin: https://shop.example.com" \
  -d '{"sku": "A001"}'

# 如果这里返回 500,那就不是跨域问题,是接口本身崩了
# 如果返回 200 且带着 Access-Control-Allow-Origin,那才是浏览器侧的事

第二个,错误响应也得带 CORS 头。承接上一条,要让前端能看到后端 4xx/5xx 的真实错误信息,你的 CORS 中间件必须保证无论业务成功还是失败,响应都带上 CORS 头——所以 CORS 中间件要放在中间件链的最前面,且不能被异常跳过。第三个,别在前端用改 host、装浏览器插件关闭同源策略的方式"解决"跨域——那只是让你自己这台机器看着正常,真实用户的浏览器照样拦。第四个,OPTIONS 预检不要被鉴权拦截:预检请求不带业务 cookie、不带 Authorization,如果你的鉴权中间件排在 CORS 处理之前,预检会被打成 401,整个跨域就崩了——鉴权中间件必须放行 OPTIONS。

这里要建立的认知是:CORS 最阴险的地方,在于它是一个"翻译器",而且是个会把各种不同的原话都翻译成同一句"CORS policy"的蹩脚翻译器。后端 500 了,它翻译成 CORS 错;预检被鉴权拦了,它翻译成 CORS 错;后端真的少配了响应头,它还是翻译成 CORS 错。浏览器出于安全考虑,不愿意把跨域响应的细节透露给页面 JS,于是这些性质完全不同的故障,在你的控制台里长着同一张脸。这就是为什么排查 CORS 不能只盯着控制台那行红字——那行字信息量极低,它只告诉你"浏览器拦了",没告诉你"为什么拦"。真正的排查方法是绕开这个蹩脚翻译器:用 curl、用 Postman 直接打接口,这些工具不执行 CORS,能让你看到未经翻译的原话——后端到底回了几号状态码、带没带 CORS 头、body 里有没有真实的错误堆栈。一个好习惯是:任何时候在浏览器里看到 CORS 报错,第一反应不是去改后端配置,而是先 curl 一下,用三十秒确认这到底是"真跨域问题"还是"被 CORS 伪装起来的别的问题"。把这个习惯养成,你就不会再为一个其实是 500 的 bug,在 CORS 配置里白白折腾半天。

关键概念速查

概念 说明 关键点
同源 协议、域名、端口三者完全相同 任一不同即跨域,与代码里的路径写法无关
CORS 执法者 跨源资源共享机制由浏览器强制执行 后端响应头只是声明,裁判始终是浏览器
本地代理掩盖 开发服务器 proxy 把请求伪装成同源 CORS 不触发,问题被推迟到上线才暴露
简单请求 方法与 Content-Type 都在白名单内 浏览器直接发,无需预检
预检请求 OPTIONS 非简单请求前浏览器自动发的探路请求 后端须接住并回 204,不能走进业务或鉴权
application/json POST JSON 不在简单请求白名单 必然触发预检,后端必须处理 OPTIONS
带凭证跨域 前端 credentials include 才会带 cookie Allow-Origin 禁用通配符,须回具体源
Allow-Credentials 带凭证时后端必须额外返回该头为 true 配具体源时建议同时回 Vary: Origin
Expose-Headers 允许前端 JS 读取的自定义响应头 不配则自定义响应头前端读到 null
Max-Age 预检结果的缓存秒数 减少 OPTIONS 往返,各浏览器有上限

避坑清单

  1. 别以为本地正常就没跨域问题。开发代理把请求伪装成同源,CORS 不触发,问题会在上线撤掉代理时才爆发。
  2. 把 CORS 理解成浏览器的机制,不是后端开关。后端响应头只是声明,拦不拦由浏览器判定。
  3. 后端必须正确处理 OPTIONS 预检请求,直接回 204,不要让它走进业务逻辑或被鉴权中间件拦成 401。
  4. 知道 application/json 的 POST 一定触发预检。现代前端请求几乎都是非简单请求,后端务必接住 OPTIONS。
  5. 带 cookie 的跨域请求,前端要显式声明 credentials(fetch 用 include、axios 用 withCredentials)。
  6. 带凭证时 Allow-Origin 绝不能用通配符,必须回具体的源,并额外返回 Access-Control-Allow-Credentials: true。
  7. 用白名单管理可信源,拿请求的 Origin 比对后原样回填,别用通配符糊弄身份相关的接口。
  8. 前端要读的自定义响应头,后端须用 Expose-Headers 暴露,否则前端读到 null 且不报错。
  9. CORS 中间件放在最前面,保证错误响应也带 CORS 头,否则后端 4xx/5xx 会被伪装成 CORS 报错。
  10. 遇到 CORS 报错先用 curl 直打接口。curl 不执行 CORS,能立刻分清这是真跨域问题还是后端 500 被掩盖。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我以为 CORS 是后端的一个开关,跨域报错就是后端少加了个响应头,加上就通。可 CORS 这套机制,从拦截、到预检、到最终放不放行,执法者自始至终是浏览器;后端那几个 Access-Control 响应头,不是命令,只是向浏览器报备的声明。我把方向搞反了——一直以为是后端在"允许",其实是浏览器在"审查"。问题从来不在"该加哪个头",而在我没看清这场审查里,谁是法官、谁是被询问的人。

真正搞懂跨域,工作量不在"背下那一串响应头",而在一次视角的搬家:把自己从"后端开发者"的位置,搬到"浏览器"的位置去看这件事。一旦你站在浏览器的视角,该怎么做就都顺了——它何时判定跨域、何时自作主张发预检、它读哪些响应头、它为什么对带 cookie 的请求格外严格、它把多少种不同的故障都翻译成同一句 CORS 报错。每一条规则,站在"浏览器要保护用户"这个出发点上,都不再是需要死记的条文,而是能推演出来的必然。难的是先承认:跨域不是后端的配置题,是浏览器的安全题。

我后来常拿小区门禁来想这件事。你住的楼是一个"源",隔壁楼是另一个"源"。隔壁楼的人想进你家送个东西,门口的保安(浏览器)会拦下他:对那些无所谓的事(简单请求,比如就在门口放下一份公开传单),保安可能让他直接去、回头再核对;可对那些要进屋、可能动你家东西的事(非简单请求),保安会先自己跑一趟,上楼问你一声"楼下有人要带这些东西上来,你认不认"(预检),你点头了他才放人。而你家钥匙(cookie 凭证)这种东西,保安管得最严——他绝不会允许"凡是来的人都能带着你家钥匙进门"(通配符 + 凭证),你必须明明白白报出一个名字,他才放那个人。保安不是你家的开关,他是替你把关的人;CORS 里的浏览器,就是这个保安。

这类问题最咬人的地方,在于它在本地开发时几乎永远是"对"的:脚手架的代理默默垫在中间,把每个请求都包装成同源,CORS 这套机制根本没机会启动,你把功能跑得再顺,也从没真正面对过跨域。它只在前端打包、部署到真实域名、和后端分属两个源时才露出獠牙,而那往往已是上线当天。所以别等线上一片飘红才想起跨域:做前后端分离项目的第一天,就该把"前端在哪个源、接口在哪个源、它们跨不跨域"这件事摆到台面上,把 CORS 配置当成和接口本身同等重要的东西来设计、来验证——最好在一个不开代理的环境里真刀真枪测一遍。把这件事在写第一个接口时就想清楚,你才算真正跳出了那个人人都会遇到、却人人都栽一次的"本地好好的、上线就跨域"。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

RAG 检索质量评估完全指南:从一次"向量库明明命中了、答案却驴唇不对马嘴"看懂召回率与评测集

2026-5-22 17:31:44

技术教程

LLM 流式响应 SSE 解析完全指南:从一次"JSON.parse 偶尔报错、答案中间莫名少一段"看懂 chunk 边界

2026-5-22 17:45:05

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索