CORS: Cross-Origin Requests Explained
What CORS actually is (a browser relaxation, not server security), preflight, credentialed requests, and the config that finally makes it work.
CORS is not a server-side security feature
Same-origin policy is a BROWSER rule: scripts on site A may not read responses from site B. CORS is the browser's controlled way to relax that rule when the SERVER agrees. A non-browser client (curl, Postman, a server-side script) never sends CORS checks — the API is not protected by CORS.
| Status | Preflight? | Example |
|---|---|---|
| 简单请求(GET/HEAD/POST + 少量头) | 否 | 普通表单 POST、JSON GET |
| 自定义头 / PUT/DELETE / Content-Type 为 JSON | 是 | POST application/json + Authorization |
| 携带 Cookie(credentials) | 是 | 且必须精确匹配 Origin,不能是 * |
# 预检请求的样子(浏览器自动发的 OPTIONS)
OPTIONS /api/orders HTTP/1.1
Origin: https://www.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization,Content-Type
# 服务端同意了,回这套头:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: Authorization,Content-Type
Access-Control-Max-Age: 86400 ← 预检结果缓存一天
The credentialed trap
The moment the request carries credentials (cookies, Authorization), the browser demands more: Access-Control-Allow-Origin must echo the EXACT origin (wildcard * is rejected), and you must send Access-Control-Allow-Credentials: true. This is the most common 'why did it work in Postman but not the browser' case.
# 后端 Nginx 的标准 CORS 配置(支持带凭证)
location /api/ {
# 回显来源而不是 *(配合正则白名单)
if ($http_origin ~* '^https://(www|admin)\.example\.com$') {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
}
add_header Access-Control-Allow-Methods 'GET,POST,PUT,DELETE,OPTIONS' always;
add_header Access-Control-Allow-Headers 'Authorization,Content-Type' always;
if ($request_method = OPTIONS) {
add_header Content-Length 0;
return 204;
}
proxy_pass http://api;
}
- Browsers do not apply CORS to <img> / <script> tags (they load, but scripts can't read the response) — that's why JSONP worked and why CSP matters
- Special headers like Cookie, Authorization, X-Requested-With trigger preflight — expect the OPTIONS round-trip on every session
- If the OPTIONS is handled by a framework filter, make sure it runs BEFORE auth (a 401 on preflight = dead API from the browser)
CORS 不是服务端安全功能
同源策略是浏览器的规则:A 站点的脚本不得读取 B 站点的响应。CORS 是浏览器在服务端同意时,对同源策略的一种受控放宽。非浏览器客户端(curl、Postman、服务端脚本)根本不做 CORS 检查——API 并不会被 CORS 保护。
| 情形 | 要预检吗? | 例子 |
|---|---|---|
| 简单请求(GET/HEAD/POST + 少量头) | 否 | 普通表单 POST、JSON GET |
| 自定义头 / PUT/DELETE / Content-Type 为 JSON | 是 | POST application/json + Authorization |
| 携带 Cookie(credentials) | 是 | 且必须精确匹配 Origin,不能是 * |
# 预检请求的样子(浏览器自动发的 OPTIONS)
OPTIONS /api/orders HTTP/1.1
Origin: https://www.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization,Content-Type
# 服务端同意了,回这套头:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: Authorization,Content-Type
Access-Control-Max-Age: 86400 ← 预检结果缓存一天
带 Cookie 时的那个陷阱
一旦请求带凭证(cookie、Authorization),浏览器要求更多:Allow-Origin 必须回显精确的来源(通配符 * 会被拒绝),并必须显式回 Allow-Credentials: true。这是最高频的「Postman 能通浏览器不行」案例。
# 后端 Nginx 的标准 CORS 配置(支持带凭证)
location /api/ {
# 回显来源而不是 *(配合正则白名单)
if ($http_origin ~* '^https://(www|admin)\.example\.com$') {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
}
add_header Access-Control-Allow-Methods 'GET,POST,PUT,DELETE,OPTIONS' always;
add_header Access-Control-Allow-Headers 'Authorization,Content-Type' always;
if ($request_method = OPTIONS) {
add_header Content-Length 0;
return 204;
}
proxy_pass http://api;
}
- 浏览器对 <img>/<script> 标签不做 CORS(能加载但脚本读不到响应)——这是 JSONP 当年能成的原因,也是 CSP 存在的原因
- Cookie、Authorization、X-Requested-With 等特殊头会触发预检——每个会话都先有一次 OPTIONS 往返
- OPTIONS 由框架过滤器处理时,要确保它在鉴权之前执行(预检返回 401 = 从浏览器看 API 就是死的)