Skip to content

필사 모드: CORS Errors and Why You Fix the Server — How the Browser Policy Actually Works, and the Wrong Ways Around It

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

Introduction — curl works, only the browser is blocked

Suppose this line just showed up in your console.

Access to fetch at 'https://api.example.com/v1/orders' from origin
'https://app.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

The first thing most people do is reproduce the same request in a terminal.

curl -i -X GET 'https://api.example.com/v1/orders' \
  -H 'Origin: https://app.example.com' \
  -H 'Authorization: Bearer eyJhbGciOi...'
HTTP/2 200
content-type: application/json; charset=utf-8
content-length: 1842
date: Sun, 26 Jul 2026 04:11:32 GMT
x-request-id: 9f1c2a44-3b8e-4d1a-9c77-2f0a1b6d4e55

{"items":[{"id":"ord_8812","total":49000}, ...]}

It is a 200. The server received the request, let it through authentication, queried the database, and returned JSON. Nothing failed for lack of data. The response is simply missing one line, access-control-allow-origin.

That one line of difference explains the whole of CORS. And this is exactly where most people arrive at their first wrong conclusion: that it is a frontend problem, so it should be fixed in the frontend. There is nothing in the frontend to fix. The browser blocked it because the server never signalled permission, and the server is the only party that can signal permission.

CORS is not server security, it is a relaxation the browser enforces

The same-origin policy is the browser default. Scheme, host, and port must all match for two URLs to be the same origin, and a script cannot read a response from a different origin. https://app.example.com and https://api.example.com are different origins, and so are https://app.example.com and http://app.example.com, and so are https://app.example.com and https://app.example.com:8443.

Think about what would happen without the policy and the reason it exists becomes obvious. If a malicious site could fetch https://mail.example.com/inbox from an open tab and read it, the cookies still sitting in the browser would ride along automatically and the whole mailbox could be scraped in a logged-in state. The same-origin policy stops that.

CORS is a mechanism that loosens this policy. It is not a mechanism that tightens it. When the server declares in a response header that "a script from this origin may read my response," the browser permits the exception. Three facts follow from that.

First, the party that enforces it is the browser. curl, Postman, server-side fetch, and the HTTP client in a mobile app do not implement the same-origin policy, so CORS is irrelevant to them. "We protect the API with CORS" is not a coherent statement. Real attackers do not use a browser.

Second, what is being judged is the act of reading a response, not the act of sending a request. A request with no preflight attached actually reaches the server and executes. The browser simply refuses to hand the response to the script. This fact is decisive when we get to CSRF later.

Third, the place to fix it is always the side that produces the response headers. If that side is our server we fix it; if it is somebody else's API we ask them or route through our own server. Changing a browser setting is self-deception that only holds inside your own browser.

The exact conditions that trigger a preflight

The browser does not send an OPTIONS request ahead of every cross-origin request. Requests of a shape that HTML forms have been able to send for decades go straight out. These are called simple requests, and the condition is that all three of the following hold.

The method must be one of GET, HEAD, or POST. PUT, PATCH, and DELETE always trigger a preflight.

Manually set headers must all be within the safelist. The list is roughly Accept, Accept-Language, Content-Language, Content-Type, and Range. The moment you attach Authorization, a preflight appears. Custom headers such as X-Requested-With or X-Trace-Id do the same. This is the most common cause in practice.

The Content-Type value must be one of application/x-www-form-urlencoded, multipart/form-data, or text/plain. application/json is not on that list. That is why nearly every modern API call that POSTs JSON goes through a preflight.

On top of that, attaching an event listener to XMLHttpRequestUpload, or using a ReadableStream as the request body, also produces a preflight.

An actual preflight looks like this.

OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type

A preflight has no body, carries no cookies, and does not need to pass the server's authentication. What the server has to return is a response like this.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin

Two things break here regularly. One is authentication middleware catching the OPTIONS request too and returning 401. A preflight is treated as failed unless it is a 2xx, so CORS handling must sit ahead of the authentication middleware. The other is redirects. If a preflight response comes back as 301 or 308, the browser does not follow it and fails outright. HTTP-to-HTTPS redirects and trailing-slash redirects both land here.

Access-Control-Max-Age is how long the browser caches the preflight result. There are caps, though. Chrome clips it at 7200 seconds and Safari at a much shorter value. Writing 86400 and expecting no OPTIONS for a whole day will not work out.

What each response header does, and the combinations people get wrong

Access-Control-Allow-Origin can hold exactly one value. Listing several origins separated by commas is not valid. To allow several origins, the server has to look at the request's Origin header and echo that exact value back only when it is on an allowlist.

Access-Control-Expose-Headers widens the set of response headers a script can read. By default it can read only seven: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. If you send the pagination total down as X-Total-Count and the frontend sees null, it is almost always this header that was left out. It shows up as a header that logs perfectly on the server and is invisible only on the client, which makes it unusually hard to track down.

The combination people get wrong most often is credentials plus wildcards. For a request that carries cookies via credentials: 'include', the following rules apply. Access-Control-Allow-Origin may not be an asterisk, the asterisk in Access-Control-Allow-Headers and Access-Control-Allow-Methods becomes invalid, and so does the asterisk in Access-Control-Expose-Headers. Everything has to be listed explicitly. There is a reason for the rule. Allowing an asterisk would let any site read a response authenticated with the user's cookies, which amounts to the same-origin policy no longer existing.

So the following code is dangerous.

// Do not do this — it lets every origin read authenticated responses
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*')
  res.setHeader('Access-Control-Allow-Credentials', 'true')
  next()
})

Reflecting Origin verbatim only dodges the no-wildcard rule on a technicality; in substance it has allowed every origin. An attacker site fires a fetch, the user's session cookie rides along, and the response is read as-is. You must check against an allowlist.

const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com'])

app.use((req, res, next) => {
  const origin = req.headers.origin

  // Always attach Vary, even for an origin that is not allowed
  res.setHeader('Vary', 'Origin')

  if (origin && ALLOWED.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin)
    res.setHeader('Access-Control-Allow-Credentials', 'true')
    res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count, RateLimit-Remaining')
  }

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE')
    res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
    res.setHeader('Access-Control-Max-Age', '7200')
    return res.status(204).end() // finish before the auth middleware
  }

  next()
})

When you build an allowlist out of regular expressions, escape the dots and anchor the end of the string. A sloppy pattern written to allow a whole family of subdomains will happily pass a value like https://example.com.attacker.io. Account takeovers caused by exactly this mistake have been disclosed several times. Where possible, comparing against a set of strings rather than a regular expression is safer.

Origin: null should not go on the allowlist either. It is the value attached by sandboxed iframes, local files, and some redirect situations, and an attacker can produce it at will just by opening one sandboxed iframe on their own page.

Leaving out Vary poisons the cache

If the response headers vary by Origin but Vary: Origin is absent, a CDN or reverse proxy in the middle will reuse the response based on the URL alone. Then this happens: a response produced for a request from the admin origin lands in the cache, and the next request for that same URL from the app origin gets back a response carrying Access-Control-Allow-Origin: https://admin.example.com. The app sees a CORS error, and it keeps seeing it until the cache expires.

The symptom is particularly nasty. It does not reproduce, sometimes a refresh fixes it, and it happens only to users in one region. The reverse case exists too. If a response fetched server-side without an Origin gets cached, a response with no CORS headers at all is stored, and every browser request after that is blocked.

So a server that reflects Origin must attach Vary: Origin unconditionally. Attach it even for origins that are not allowed.

Decoding each error message

Browser console messages point at the cause more precisely than people expect. Here are the ones you see often.

Key phrase in the console messageActual causeWhere to fix
No Access-Control-Allow-Origin header is presentServer did not send the header. Includes dying with a 5xx before reaching the CORS middlewareServer response headers. Check the status code with curl first
Response to preflight request does not have HTTP ok statusOPTIONS returned 401, 404, 405, or 500Move CORS handling ahead of auth middleware. Register the OPTIONS route
Redirect is not allowed for a preflight requestThe OPTIONS response was a 301 or 308Keep forced-HTTPS and trailing-slash redirects off OPTIONS
Request header field authorization is not allowedThat header is missing from Access-Control-Allow-HeadersThe allowed-headers list in the preflight response
Method PATCH is not allowed by Access-Control-Allow-MethodsMissing entry in the allowed-methods listThe allowed-methods list in the preflight response
must not be the wildcard when credentials mode is includeSending cookies while using an asteriskUse an explicit origin value. Add an allowlist check
contains multiple values, but only one is allowedProxy and application each attach the headerAttach it in exactly one place. Usually configured on both nginx and the app
Origin null is not allowedfile protocol, sandboxed iframe, or a request after a redirectUse a local dev server. Do not put null on the allowlist

The one at the top of the list is the most common and the most misread. The message can mean "the server has no CORS configuration," but very often it means "the server died with a 500 and never reached the middleware that attaches the header." When a CORS error appears you must always look at the real status code in the network tab first, or check with curl. Spending hours chasing a 500 while believing it is a CORS problem is a common experience.

Proxy workarounds — when they are legitimate and when they are not

If you make it same-origin, CORS never happens at all. So a proxy always works. The question is when that is design and when it is avoidance.

There are legitimate cases. First, when a third-party API does not send CORS headers and we cannot fix that server. Second, when an API key has to stay hidden. A key shipped to the browser is a published key, so in that case routing through a server is not a workaround but the only correct structure. Third, when you already run a gateway or BFF that groups several backends under one origin. Fourth, when a dev server proxies the API in a development environment.

// vite.config.js — during development, just make it same-origin
export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api-dev.example.com',
        changeOrigin: true,
      },
    },
  },
}

The illegitimate cases are just as clear. Standing up a proxy because it feels tedious to add three header lines to a backend we control is a choice to add one permanent layer of infrastructure and permanent latency. Using a public CORS proxy service in production means leaking user tokens and data to a third-party server, and when that server goes down our service goes down with it.

mode: 'no-cors' is not a solution either. The error disappears, but an opaque response comes back and you can read neither the status code nor the body. It is useless unless you are loading an image or a script purely for its side effect, yet it is easy to mistake the vanished error for a fix.

The advice to turn off browser security

Search for this and one piece of advice always comes up near the top: launch Chrome with the --disable-web-security flag. It is bad for three reasons.

The same-origin policy disappears for every tab in that profile. Every other tab you have open while developing becomes defenceless. Once it becomes a habit, you start turning the flag on in your everyday browser too.

You end up developing in an environment that does not exist in production. Credentials-plus-wildcard combinations, missing Vary, missing exposed headers — all of it stays hidden and then detonates at once in staging or production.

It postpones the problem and solves nothing. You have to fix the server headers before release anyway, and by then you also have to unwind all the misunderstandings that piled up in the meantime.

The alternative is simple: use the dev server proxy, or add the development origin to the server's allowlist. The latter is better, because it gets validated through the same path as production.

CORS does not stop CSRF

This is the most dangerous misconception. "We configured CORS, so other sites cannot call our API" is wrong. CORS blocks reading a response; it does not block a request from being executed.

Suppose an attacker page contains this form.

<!-- evil.example.com — CORS does not block this request at all -->
<form action="https://bank.example.com/transfer" method="POST">
  <input name="to" value="attacker" />
  <input name="amount" value="1000000" />
</form>
<script>
  document.forms[0].submit()
</script>

A form submission is a POST with Content-Type application/x-www-form-urlencoded, so it is a simple request. There is no preflight. The browser sends the request with the cookies for bank.example.com attached, and the server processes it as a request from a logged-in user and executes the transfer. The attacker cannot read the response, but does not need to. The money has already moved.

What stops CSRF is a different set of devices.

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

SameSite=Lax does not attach the cookie to a POST that came from a cross-site context. It is also the default in modern browsers, so a substantial share of classic CSRF is already blocked. You do need to know that it works at site granularity, though. app.example.com and api.example.com are different origins but the same site, so SameSite does not distinguish between them. If one subdomain is compromised, the defence is gone.

That is why state-changing requests also carry a CSRF token. The server issues a value, the client sends it back in the request body or a custom header, and the server compares them. Requiring a custom header forces a preflight, which is an incidental defence in itself, but that alone is thin ground to stand on. Declaring that you accept only JSON and rejecting form-family Content-Type values is an auxiliary measure of the same kind.

To put it plainly, CORS and CSRF are problems that point in opposite directions. CORS is about someone reading your data; CSRF defence is about someone writing in your name. Configuring one does not resolve the other.

Closing — what the browser told you is a server problem

When you meet a CORS error, the order is this. Send the same request with curl and check the real status code and response headers. If it is a 500, this is not a CORS problem but a server error. If it is a 200 with no header, it is the server's CORS configuration. If the request triggers a preflight, check the OPTIONS response separately. Then look up the key phrase from the console message in the table above.

A CORS error is the browser telling you about a defect in your server configuration, and switching off the signal is not the same as fixing the defect. The longer you spend looking for the answer in frontend code, browser flags, or extensions, the further you are from it. There is exactly one place to fix: the server that produces the response headers.

현재 단락 (1/121)

Suppose this line just showed up in your console.

작성 글자: 0원문 글자: 15,011작성 단락: 0/121