惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Hugging Face - Blog
Hugging Face - Blog
Google DeepMind News
Google DeepMind News
云风的 BLOG
云风的 BLOG
WordPress大学
WordPress大学
Vercel News
Vercel News
Apple Machine Learning Research
Apple Machine Learning Research
T
Tailwind CSS Blog
I
InfoQ
小众软件
小众软件
Recent Announcements
Recent Announcements
博客园 - 【当耐特】
The GitHub Blog
The GitHub Blog
大猫的无限游戏
大猫的无限游戏
美团技术团队
T
The Blog of Author Tim Ferriss
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
酷 壳 – CoolShell
酷 壳 – CoolShell
MongoDB | Blog
MongoDB | Blog
V
V2EX
J
Java Code Geeks
有赞技术团队
有赞技术团队
博客园 - 聂微东
B
Blog RSS Feed
博客园 - 司徒正美

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution
CORS: The Three Headers That Cause 90% of Debugging Pain ...
The Practica · 2026-06-04 · via The Practical Developer

You ship a new API endpoint. Your SPA calls it from app.example.com to api.example.com. Works fine in local dev because both live on localhost:3000 and localhost:4000, and the browser does not enforce CORS for localhost the same way. Then you deploy to staging, and every single request fails with:

Access to fetch at 'https://api.example.com/v1/orders' from origin 'https://app.example.com' has been blocked by CORS policy

Half the team dives into the backend. The other half fires up Postman, where the endpoint works perfectly (no browser, no CORS enforcement). The argument runs for hours. The fix is three HTTP headers.

CORS is not complicated. It is just poorly understood because the browser hides most of the negotiation, the error messages are misleading, and most frameworks ship defaults that work for the simple case and break for every real-world scenario. Here is the mental model that makes CORS boring, the three headers that cover 90% of cases, and a test harness you can run before your frontend team wastes another afternoon.

The Mental Model: CORS Is a Bouncer at the Browser

CORS (Cross-Origin Resource Sharing) is a browser-enforced security policy. It is not a server security mechanism. Your API is perfectly reachable from curl, Postman, or a server-side script. The browser looks at the origin of the page making the request, compares it to the origin of the target API, and if they differ, it demands permission from the API before handing the response to JavaScript.

The flow is straightforward:

  1. Your browser tab loads https://app.example.com. The origin is https://app.example.com.
  2. JavaScript on that page calls fetch('https://api.example.com/orders'). The origin of the target is https://api.example.com.
  3. The browser sees the mismatch and checks: does api.example.com allow requests from app.example.com?
  4. If the API returns the proper Access-Control-Allow-Origin header, the browser hands the response to JavaScript. If not, it throws the error above and blocks the response.

That is it. The server still processes the request. The response still arrives at the browser. The browser just refuses to give it to the page’s JavaScript.

Simple Requests vs. Preflighted Requests

The browser has two request paths. The distinction matters because one path does not trigger a preflight at all, and the other sends an extra OPTIONS request that most backend teams never see coming.

Simple requests are GET, HEAD, or POST with a limited set of content types (text/plain, application/x-www-form-urlencoded, multipart/form-data) and no custom headers. The browser makes the request directly and checks the response headers. If they do not allow the origin, it blocks the response. No preflight.

Preflighted requests are everything else: PUT, DELETE, PATCH, POST with application/json, any request with custom headers like Authorization or X-Request-Id, or requests that include credentials (cookies, HTTP auth). The browser sends an OPTIONS request first, asking the server for permission. Only if the preflight succeeds does it send the real request.

Every JSON API is preflighted. Every API that uses Authorization headers is preflighted. If you are building a modern web app, you are in the preflight path.

# Preflight request (browser sends this automatically)
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
# Preflight response
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 86400

If the preflight response is missing or the headers do not match, the browser cancels the real request and you get a CORS error in the console. No JavaScript error is thrown on the real request endpoint because it never fires.

You need exactly three response headers to handle nearly every CORS scenario. A fourth (credentials) is optional but commonly needed.

1. Access-Control-Allow-Origin

This is the header that says “yes, this origin is allowed to read the response.” It can be a specific origin (https://app.example.com), or the wildcard *.

Access-Control-Allow-Origin: https://app.example.com

The wildcard looks convenient, but it has one killer restriction: you cannot use it with credentials. If your request includes credentials: 'include' (cookies, auth headers), the browser rejects any response with Access-Control-Allow-Origin: *. You must echo back the requesting origin.

// Express middleware for dynamic origin
app.use((req, res, next) => {
  const allowedOrigins = [
    'https://app.example.com',
    'https://staging.app.example.com',
  ];
  const origin = req.headers.origin;
  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }
  next();
});

Never use req.headers.origin without validation. An attacker can set any origin header. If you echo it back unconditionally, you have effectively set Allow-Origin: * while also supporting credentials, which defeats the purpose entirely.

2. Access-Control-Allow-Methods

For preflight responses only, this tells the browser which HTTP methods the real request is allowed to use.

Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH

If your preflight is for a DELETE request and the response omits DELETE from the methods list, the browser blocks the real request. Match this header to the methods your endpoint actually exposes.

For preflight responses only, this tells the browser which custom headers the real request is allowed to include.

Access-Control-Allow-Headers: content-type, authorization, x-request-id

This catches most teams off guard. Your frontend sends Authorization: Bearer <token> and Content-Type: application/json. The preflight response must list both of those in Access-Control-Allow-Headers, or the browser never sends the real request.

// Server-side check: log what the preflight is asking for
if (req.method === 'OPTIONS') {
  console.log('Preflight request headers:', req.headers['access-control-request-headers']);
  // Now you know exactly what to list in Allow-Headers
}

4. Access-Control-Allow-Credentials (bonus, but essential)

When your API uses cookies for authentication (SameSite, session cookies, or HTTP-only cookies) or you pass credentials: 'include' in your fetch calls, you need this header set to true.

Access-Control-Allow-Credentials: true

Setting this to true has three consequences:

  • Access-Control-Allow-Origin cannot be * (must be an explicit origin).
  • Access-Control-Allow-Origin must match the requesting origin exactly (no pattern matching, no subdomain wildcards - the spec says exact string match).
  • The server must set the Vary: Origin header so caches know the response depends on the origin.
// Complete Express CORS middleware for credentialed requests
app.use((req, res, next) => {
  const origin = req.headers.origin;
  const allowedOrigins = [
    'https://app.example.com',
    'https://admin.example.com',
  ];

  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id');
    res.setHeader('Access-Control-Max-Age', '86400');
    return res.status(204).end();
  }

  next();
});

Most teams stop after the Access-Control headers and wonder why their CDN occasionally serves CORS errors to some users.

The problem is caching. If api.example.com sits behind a CDN (CloudFront, Cloudflare, Fastly) and your Access-Control-Allow-Origin header varies per requesting origin, the CDN might cache a response meant for https://app.example.com and serve it to https://admin.example.com, which was never in the allow list.

The fix is Vary: Origin:

Vary: Origin

This tells caches to store separate copies of the response for each distinct Origin request header. Without it, your CDN serves stale CORS headers to the wrong clients.

Set Vary: Origin on every response whose Access-Control-Allow-Origin is dynamic. It costs nothing and prevents a category of bug that is almost impossible to debug because it reproduces intermittently and only for some users.

A Test Harness You Can Run Before You Deploy

Stop guessing whether your CORS configuration works. Here is a script that sends the exact same requests your browser will send: a preflight OPTIONS, a credentialed GET, and a POST with JSON. It validates the response headers and fails with a clear message if something is wrong.

// test-cors.mjs - run with: node test-cors.mjs
const API_BASE = process.env.API_URL || 'https://api.example.com';
const TEST_ORIGIN = process.env.TEST_ORIGIN || 'https://app.example.com';

async function testCors() {
  const results = [];

  // Test 1: Preflight (OPTIONS)
  {
    const res = await fetch(`${API_BASE}/v1/orders`, {
      method: 'OPTIONS',
      headers: {
        Origin: TEST_ORIGIN,
        'Access-Control-Request-Method': 'POST',
        'Access-Control-Request-Headers': 'content-type, authorization',
      },
    });
    const origin = res.headers.get('access-control-allow-origin');
    const methods = res.headers.get('access-control-allow-methods');
    const headers = res.headers.get('access-control-allow-headers');
    const maxAge = res.headers.get('access-control-max-age');

    results.push({
      name: 'Preflight OPTIONS returns 204',
      pass: res.status === 204,
    });
    results.push({
      name: 'Access-Control-Allow-Origin matches test origin',
      pass: origin === TEST_ORIGIN,
    });
    results.push({
      name: 'Access-Control-Allow-Methods is present',
      pass: !!methods,
      actual: methods,
    });
    results.push({
      name: 'Access-Control-Allow-Headers includes content-type and authorization',
      pass: headers?.toLowerCase().includes('content-type') &&
            headers?.toLowerCase().includes('authorization'),
      actual: headers,
    });
    results.push({
      name: 'Access-Control-Max-Age is set',
      pass: !!maxAge,
      actual: maxAge,
    });
  }

  // Test 2: Credentialed GET request
  {
    const res = await fetch(`${API_BASE}/v1/orders`, {
      method: 'GET',
      headers: { Origin: TEST_ORIGIN },
      credentials: 'include',
    });
    const origin = res.headers.get('access-control-allow-origin');
    const credentials = res.headers.get('access-control-allow-credentials');
    const vary = res.headers.get('vary');

    results.push({
      name: 'GET returns credentials header when origin is allowed',
      pass: credentials === 'true',
      actual: credentials,
    });
    results.push({
      name: 'GET Access-Control-Allow-Origin is not wildcard (required for credentials)',
      pass: origin !== '*',
      actual: origin,
    });
    results.push({
      name: 'Vary: Origin is set for cache correctness',
      pass: vary?.toLowerCase().includes('origin'),
      actual: vary,
    });
  }

  // Test 3: POST with JSON body (preflighted)
  {
    const res = await fetch(`${API_BASE}/v1/orders`, {
      method: 'POST',
      headers: {
        Origin: TEST_ORIGIN,
        'Content-Type': 'application/json',
        Authorization: 'Bearer test-token',
      },
      body: JSON.stringify({ item: 'test' }),
    });
    const origin = res.headers.get('access-control-allow-origin');

    results.push({
      name: 'POST JSON request allowed by CORS',
      pass: origin === TEST_ORIGIN,
      actual: origin,
    });
  }

  // Report
  let passed = 0;
  for (const r of results) {
    const icon = r.pass ? 'PASS' : 'FAIL';
    if (r.pass) passed++;
    console.log(`[${icon}] ${r.name}`);
    if (!r.pass && r.actual !== undefined) {
      console.log(`       Expected something, got: ${r.actual}`);
    }
  }
  console.log(`\n${passed}/${results.length} tests passed`);
  process.exit(passed === results.length ? 0 : 1);
}

testCors().catch(console.error);

Run this script against your staging environment before you merge. If it fails, you know exactly which header is wrong. No more “let me try a different fetch option” back-and-forth with the frontend team.

The Three CORS Lies Developers Tell Themselves

“CORS is a server problem.” No. CORS is a browser problem that the server helps solve. The browser enforces the policy, but the server controls the policy by sending the right headers. If you treat CORS as the backend team’s problem only, you miss the client-side implications: your fetch code must set the right credentials mode, and your frontend must handle the opaque error that CORS produces (a TypeError with no status code or body).

“Just set the wildcard and move on.” The wildcard works until you need cookies, auth headers, or any kind of credentialed request. At that point you have to rewrite the middleware anyway. Start with the explicit origin list from day one.

“CORS is a development-only concern.” CORS is enforced identically in development, staging, and production. The only difference is your origin list. If your dev config uses * and prod uses explicit origins, you will ship CORS bugs that only appear after deployment. Use the same code path everywhere. Just change the list of allowed origins per environment.

Production Checklist

Before you ship your CORS configuration, verify:

  • Access-Control-Allow-Origin echoes back the validated origin (not *) when credentials are needed
  • Preflight OPTIONS requests return 204 with proper Allow-Methods and Allow-Headers
  • Access-Control-Allow-Headers lists every custom header the frontend sends (including content-type)
  • Access-Control-Allow-Credentials: true is present when using cookies or credentials: 'include'
  • Vary: Origin is set on every response with a dynamic CORS origin
  • The CORS middleware runs before any route handler or authentication middleware (preflight must succeed without auth)
  • Your CDN or reverse proxy is configured to pass through OPTIONS requests (some CDNs strip them)
  • The test script above passes against your staging environment

The last point is the most important. A passing test script eliminates the guesswork. When the frontend team says “CORS is broken,” run the script. If the script passes, the headers are right and the problem is in the fetch call. If the script fails, you know exactly which header to fix.

A note from Yojji

Getting CORS right means understanding how the browser, the server, and the CDN interact across every environment your application runs in. It is the kind of cross-layer debugging that separates a team that ships confidently from one that burns hours on “it works on my machine.” Yojji’s engineers build API stacks with environment-aware middleware, automated CORS validation, and deployment pipelines that catch these header mismatches before they reach production.