网站跨域CORS错误怎么解决?服务器响应头与预检请求配置

前端调接口时报错 Access to XMLHttpRequest at 'https://api.example.com/xxx' from origin 'https://www.example.com' has been blocked by CORS policy,接口在 Postman 里明明是通的,浏览器却死活不让过——这就是跨域(CORS)问题。它最大的特点就是"后端没错、前端没错、浏览器在拦",而且原因往往藏在响应头和预检请求里,光看前端代码很难定位。

这篇文章把 CORS 讲清楚:先解释同源策略和"为什么会跨域",再讲清 CORS 的三个关键响应头(Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers),然后重点区分简单请求与预检请求(OPTIONS)——后者是绝大多数 CORS 配错的根源。接着分别给出 Nginx 层和后端框架(Spring、Express、Django)的配置方法,覆盖"带 Cookie"这种最容易踩坑的场景,最后给出一张常见报错信息的解读表和几条安全红线。

先记住一句核心:跨域是浏览器的安全策略,解药在服务端的响应头里。前端能做的很有限,真正的修复动作几乎都在后端或反向代理那一侧。

一、同源策略:跨域从哪来

同源策略(Same-Origin Policy)是浏览器的安全机制:一个页面的脚本,默认只能访问同源的资源。同源指三段完全一致:

协议 + 域名 + 端口  三者都相同才算同源

https://www.example.com:443/page
   ↑协议      ↑域名        ↑端口

只要有一个不同,就是跨域:

页面地址 请求地址 是否同源 原因
https://a.com https://a.com/api 同源 完全一致
https://a.com https://b.com/api 跨域 域名不同
https://a.com http://a.com/api 跨域 协议不同
https://a.com https://a.com:8080/api 跨域 端口不同
https://www.a.com https://api.a.com 跨域 子域名也不同源

注意:跨域限制只在浏览器里生效。用 curl、Postman、服务端之间调用都没有这个限制,所以"后端测试通过、浏览器报错"是完全正常的。

二、CORS 的三个关键响应头

CORS(Cross-Origin Resource Sharing,跨域资源共享)是通过响应头来"授权"跨域的机制。核心是这几个头,由服务端返回:

响应头 作用 示例
Access-Control-Allow-Origin 允许哪些来源访问 https://www.example.com 或 *
Access-Control-Allow-Methods 允许哪些方法 GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers 允许哪些请求头 Content-Type, Authorization
Access-Control-Allow-Credentials 是否允许带 Cookie true
Access-Control-Max-Age 预检结果缓存多久(秒) 86400
Access-Control-Expose-Headers 允许前端读取哪些响应头 X-Total-Count

其中 Access-Control-Allow-Origin 是必填的,没有它浏览器一定拒绝。它的值要么是具体的来源,要么是 *(允许所有来源)。

三、简单请求与预检请求:关键分水岭

CORS 请求分两类,这是理解一切 CORS 问题的关键。

简单请求:同时满足以下条件,浏览器直接发请求,响应里带上 Allow-Origin 即可。

  • 方法是 GET、HEAD、POST 之一;
  • 请求头只用了"安全头"(如 Accept、Accept-Language、Content-Language、Content-Type 的少数值);
  • Content-Type 是 application/x-www-form-urlencoded、multipart/form-data、text/plain 之一。

预检请求(Preflight):不满足简单请求条件时,浏览器先发一个 OPTIONS 请求问服务器"我能不能这么调",服务器返回允许信息后,浏览器才发真正的请求。

浏览器 ──OPTIONS /api (预检)──> 服务器
        <──允许信息(Allow-*)──
浏览器 ──真正的请求──> 服务器
        <──响应──

触发预检的常见情况:

  • 用了 PUT、DELETE、PATCH 等方法;
  • Content-Type: application/json(这是最常见的触发原因);
  • 带了自定义头(如 Authorization、X-Token)。

结论:绝大多数"AJAX 调 JSON 接口"都是预检请求,所以服务端必须能正确处理 OPTIONS 请求并返回正确的 CORS 头,否则浏览器第一步就被拦,真实请求根本发不出去。CORS 配错的根源,八成是 OPTIONS 没处理好。

四、Nginx 层配置 CORS

如果 API 由 Nginx 反代,可以在 Nginx 统一加 CORS 头。注意几个坑:add_header 只在特定状态码下生效(默认 2xx/3xx),OPTIONS 的 204 要单独处理;add_header 有继承规则(子层级有 add_header 会覆盖父级)。

server {
    listen 80;
    server_name api.example.com;

    location /api/ {
        # 允许的来源:正式环境建议写具体域名,不要用 *
        add_header 'Access-Control-Allow-Origin' 'https://www.example.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Token' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Max-Age' '86400' always;

        # 预检请求:直接返回 204,不打后端
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' 'https://www.example.com' always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Token' always;
            add_header 'Access-Control-Allow-Credentials' 'true' always;
            add_header 'Access-Control-Max-Age' '86400' always;
            add_header 'Content-Length' 0;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            return 204;
        }

        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

要点:

  • always 参数很重要:它让这些头在所有状态码(包括 4xx、5xx)下都返回,否则报错响应里没有 CORS 头,前端会看到一个"CORS 错误"而不是真正的错误码,掩盖了真实问题。
  • OPTIONS 用 return 204:预检请求不该打到后端,Nginx 直接回 204 最快。注意 if 在 location 里的使用限制,更规范的写法是把 CORS 头放在 http 或 server 层的 add_header,但要注意继承覆盖问题。
  • 改完校验重载:nginx -t && nginx -s reload。

多个来源时可用 map 动态返回:

map $http_origin $cors_origin {
    default                            "";
    "https://www.example.com"          $http_origin;
    "https://m.example.com"            $http_origin;
    "~^https://.*\.example\.com$"      $http_origin;
}
# 然后 add_header 'Access-Control-Allow-Origin' $cors_origin always;

五、后端框架层配置 CORS

很多团队更喜欢在后端统一处理 CORS,方式因框架而异。

Spring Boot(全局配置):

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://www.example.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

Express(Node.js):

const cors = require('cors');
app.use(cors({
  origin: 'https://www.example.com',     // 不要用 '*' 配合 credentials
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  credentials: true,
  maxAge: 86400
}));

Django:

# settings.py
INSTALLED_APPS += ['corsheaders']
MIDDLEWARE = ['corsheaders.middleware.CorsMiddleware', *MIDDLEWARE]
CORS_ALLOWED_ORIGINS = ["https://www.example.com"]
CORS_ALLOW_CREDENTIALS = True

注意:如果同时在 Nginx 和后端都加了 CORS 头,会出现重复的 Access-Control-Allow-Origin,浏览器同样会拒绝("header contains multiple values")。所以要二选一,或者明确让某一层不加。

六、带 Cookie 的跨域(credentials)

这是最容易翻车的场景。当前端设置 withCredentials = true(axios 的 withCredentials: true,fetch 的 credentials: 'include')要携带 Cookie 时:

  • 服务端必须返回 Access-Control-Allow-Credentials: true;
  • 服务端 Access-Control-Allow-Origin 必须是具体域名,绝不能是 *,否则浏览器直接拒绝;
  • Cookie 本身也要满足 SameSite 策略(现代浏览器默认 SameSite=Lax,跨站需 SameSite=None; Secure)。
// 前端
fetch('https://api.example.com/user', {
  method: 'GET',
  credentials: 'include'   // 携带 Cookie
});
# 服务端:注意不能用 *
add_header 'Access-Control-Allow-Origin' 'https://www.example.com' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;

一句话:* 和 credentials: true 天生互斥,凡是要带 Cookie,来源就必须写死具体域名。

七、常见 CORS 报错信息解读

浏览器控制台的报错措辞不同,指向的问题也不同。

报错信息(节选) 含义 修复方向
No 'Access-Control-Allow-Origin' header is present 响应完全没带 CORS 头 服务端加 Allow-Origin
...header contains multiple values '*, *' 重复设置了 CORS 头 Nginx 和后端去掉一层
The value of the 'Access-Control-Allow-Origin' header ... must not be '*' 带 Cookie 却用了 * 改成具体域名
Method PUT is not allowed by Access-Control-Allow-Methods 预检返回的方法不含 PUT 补全 Allow-Methods
Request header field authorization is not allowed 自定义头未被允许 加到 Allow-Headers
Response to preflight request doesn't pass OPTIONS 预检失败 处理 OPTIONS 请求
Redirect is not allowed for a preflight request OPTIONS 被 3xx 重定向 别对 OPTIONS 做跳转

排查 CORS 的固定动作:打开 DevTools Network,先看那条 OPTIONS 请求(如果有),看它的响应头里有没有正确的 Access-Control-Allow-*,再看真实请求的响应头。99% 的问题都能这样定位。

# 用 curl 模拟预检请求,绕过浏览器直接看响应头
curl -i -X OPTIONS https://api.example.com/api/user \
  -H 'Origin: https://www.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type, authorization'

八、安全注意事项

  • 不要长期使用 Access-Control-Allow-Origin: *:它意味着任何网站都能跨域读取你的接口数据,配合 credentials 更是被浏览器禁止。生产环境应白名单具体来源。
  • Allow-Credentials: true 必须配对具体 Origin:这是浏览器强制的安全约束,不是可选项。
  • Allow-Headers 不要写成 * 又期望自定义头生效:带 credentials 时 * 在部分规范下不生效,建议显式列出。
  • 优先用同源或反向代理:把前端和 API 放在同一域名下(如前端 www.a.com、API 走 www.a.com/api 反代到后端),从根本上避免跨域,比配 CORS 更省心也更安全。

风险提示:修改 Nginx 或后端 CORS 相关配置前先备份;add_header 的继承规则容易造成"某个 location 的 CORS 头莫名消失",改完务必用 curl -i 从多个路径验证响应头是否如预期,并在 nginx -t 通过后再 reload。

常见问题

Q:接口在 Postman 里能通,浏览器就报 CORS,是谁的问题?
是浏览器在拦。Postman/curl 不受同源策略约束,浏览器受。修复必须在服务端响应头上做,前端改代码解决不了(除非改用代理)。

Q:为什么我的请求先发了一个 OPTIONS?能不发吗?
那是预检请求。当方法不是简单方法、Content-Type 是 application/json、或带了自定义头时,浏览器就会先预检。可以:改用简单请求条件(如 Content-Type 用表单类型)、减少自定义头,或服务端正确处理 OPTIONS 并设 Access-Control-Max-Age 缓存预检结果。

Q:Access-Control-Allow-Origin 设了 * 还是报错?
先确认响应里是不是重复出现了该头(Nginx 和后端都加了),重复会导致拒绝;再确认前端是否带 credentials,带 Cookie 时 * 一律无效,必须写具体域名。

Q:报错说 Redirect is not allowed for a preflight request,怎么办?
说明 OPTIONS 预检被 301/302 跳转了。常见于 HTTP 跳 HTTPS、带斜杠跳转。要让 OPTIONS 请求直接返回 204,或保证跳转后仍是 OPTIONS 且带 CORS 头。

Q:add_header 加了 CORS 头,但报错时前端看不到 CORS 头?
因为默认 add_header 只在 2xx/3xx 生效,4xx/5xx 响应不带这些头,于是前端报的是 CORS 错误而非真实错误码。给 add_header 加 always 参数即可让它在所有状态码下返回。

总结

跨域是浏览器的同源策略在起作用,根源在服务端响应头缺失或错误;判定标准是"协议+域名+端口"三者是否完全一致。

核心响应头是 Access-Control-Allow-Origin(必填),配合 Allow-Methods、Allow-Headers、Allow-Credentials;触发预检(OPTIONS)的典型情况是 JSON 请求、PUT/DELETE 方法和自定义头。

配 CORS 的最大坑是预检请求:服务端必须能正确处理 OPTIONS 并返回 CORS 头,否则真实请求根本发不出;配 Nginx 时给 add_header 加 always、用 return 204 处理预检。

带 Cookie 时 Allow-Origin 必须写具体域名、绝不能是 *;同一份 CORS 头不要在 Nginx 和后端重复设置,否则会因重复值被拒。

最省心的方案是把前端和 API 放同源(反向代理),从源头消除跨域;确需跨域则白名单来源、显式列头、用 curl 复验响应头。