Cyber Security

CORS for Small Web Applications - Decide Which Browser Origins May Read a Response

CORS for Small Web Applications - Decide Which Browser Origins May Read a Response

A browser console says that a request was “blocked by CORS.” The API may still return 200 OK in a terminal, and adding Access-Control-Allow-Origin: * may appear to make the error disappear. What, exactly, did that header permit?

The useful answer is narrower than “cross-domain access.” CORS is a browser protocol for deciding whether code from one origin may access a response from another origin. It is not a general firewall for the endpoint, and it does not decide whether a user is authenticated or allowed to edit a particular record. Keeping those boundaries visible makes both debugging and security review less confusing.

Start with the origin, not just the domain

For ordinary HTTP and HTTPS URLs, an origin is identified by a combination of scheme, host, and port. RFC 6454 explains this origin model as a protection boundary used by user agents. Paths do not create separate origins, but a changed scheme, host, or port can.

  • https://app.example/account and https://app.example/settings are the same origin.
  • http://app.example and https://app.example are different origins because the schemes differ.
  • https://app.example and https://api.example are different origins because the hosts differ.
  • https://app.example and https://app.example:8443 are different origins because the ports differ.

This is why “but both services belong to the same company” does not resolve a CORS error. Organizational ownership is not part of the browser's origin comparison. The browser sees two protection domains; the server must deliberately describe any sharing between them.

CORS controls browser access to the response

The same-origin policy generally prevents a script from reading information belonging to another origin. However, the web has never prohibited every kind of cross-origin sending. Links navigate across sites, images load across sites, and HTML forms can submit to another origin. RFC 6454 distinguishes the stricter boundary around reading from the more permissive history of sending.

CORS is a controlled relaxation of that reading boundary. Suppose JavaScript loaded from https://app.example fetches public data from https://api.example/status. The browser adds an Origin request header:

GET /status HTTP/1.1
Host: api.example
Origin: https://app.example

If the API wants that origin's browser code to access the response, it can answer:

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example
Vary: Origin

{"status":"available"}

The browser compares the response policy with the requesting origin. If the CORS check succeeds, the calling script can use the response. If the required header is absent or does not match, the browser withholds the response from that script.

That last sentence contains an important limitation: a failed CORS check does not prove that the server never received or processed the request. Some cross-origin requests can be sent without a preliminary check. CORS-aware application design must therefore protect server-side actions even when no response is shared with JavaScript.

Preflight asks about a proposed request

Some cross-origin requests use a browser-generated OPTIONS request before the actual request. This is the CORS preflight. It commonly appears when code wants to use a method such as PUT or DELETE, a non-safelisted request header, or a content type outside the CORS safelist. The exact conditions belong to the living Fetch Standard's safelist rules, so a short mnemonic should not be treated as the complete specification.

For a JSON update carrying an Authorization header, a preflight can look like this:

OPTIONS /profile HTTP/1.1
Host: api.example
Origin: https://app.example
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

A narrowly scoped response might be:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: Origin

If the response satisfies the browser's CORS checks, the browser may send the actual PUT. This exchange is a protocol negotiation, not evidence that the user may update the profile. The actual handler must still authenticate the caller, authorize access to the selected profile, validate the body, and apply any request-integrity controls its threat model requires.

Preflight also is not guaranteed to precede every cross-origin request. A safelisted POST, including requests shaped like traditional HTML form submissions, can be sent without it. Designing a destructive endpoint around “the browser will preflight this” leaves the endpoint's real security decision in the wrong layer.

Credentials make the sharing decision more sensitive

Cross-origin fetch() does not include credentials by default. Client code must opt in, for example with credentials: "include", and the server must explicitly permit credentialed response sharing. Under the Fetch Standard's credential rules, the server cannot combine a credentialed response with Access-Control-Allow-Origin: *. It needs a specific allowed origin and this response field:

Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Credentials: true
Vary: Origin

This does not force a browser to send a cookie. Cookie domain, path, Secure, SameSite, and browser third-party-cookie policy remain separate constraints. MDN's credentialed-request guide explicitly notes that normal third-party-cookie policy still applies. A CORS header cannot override it.

Credentialed sharing deserves a smaller allowlist because the readable response may contain user-specific information. If an endpoint is genuinely public and uses no credentials, * can accurately express public browser-readable access. The wildcard is not inherently a bug; it is inappropriate when the intended audience is narrower than every origin.

Do not give CORS jobs it cannot perform

Several security questions sit next to CORS but remain independent:

  • Authentication: Who is making the request? A session, token, client certificate, or another mechanism answers this.
  • Authorization: May that identity perform this operation on this object? The application must decide for every protected action.
  • CSRF protection: Did a browser send an unwanted state-changing request with ambient credentials? Tokens, appropriate cookie settings, and request-context checks address this threat.
  • CORS: May browser code from this origin access this cross-origin response?

PortSwigger warns that CORS is not CSRF protection. A cross-origin form can send certain requests without gaining permission to read the response. Conversely, a correctly allowed frontend origin still must not gain records that its signed-in user is unauthorized to view.

CORS also does not constrain a direct HTTP client in the way it constrains browser JavaScript. A command-line client, backend service, or automated script can call an internet-reachable endpoint without enforcing a browser's CORS response check. Authentication, authorization, rate limits, and network controls must stand on their own.

Build the allowlist as policy, not string decoration

When more than one frontend is legitimate, the server can compare the incoming Origin against an exact allowlist and return that value only when it matches. Blindly copying any supplied Origin into Access-Control-Allow-Origin turns apparent validation into permission for every origin.

Loose suffix tests are also easy to misunderstand. A check that merely ends with example.com may accept an unintended host such as notexample.com. Scheme and port matter too. Exact serialized origins are easier to audit:

https://app.example
https://admin.example
https://preview.example:8443

The serialized value null can represent opaque or privacy-sensitive origins in some browser contexts. It should not be added casually as a shortcut for local files or sandboxed documents. If a product truly needs it, that trust decision deserves its own threat analysis rather than being folded into “allow development.”

When the server chooses an allowed origin dynamically, Vary: Origin tells caches that the response selection depends on the request's Origin. Without that distinction, a shared cache could reuse response metadata chosen for one origin when serving another. This field supports cache correctness; it does not replace the allowlist.

Test the policy from both sides

A terminal is useful for inspecting how the server responds to a proposed origin and preflight:

curl -i https://api.example/status \
  -H 'Origin: https://app.example'

curl -i -X OPTIONS https://api.example/profile \
  -H 'Origin: https://app.example' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: authorization, content-type'

These commands do not prove that a browser will expose the final response. curl displays what the server sends; it does not implement the browser's same-origin policy. Complete the test in a real browser from each intended frontend origin.

A small test matrix should include:

  1. An allowed origin and the expected method and headers.
  2. An unlisted origin, which must not receive an allow-origin value granting access.
  3. A lookalike host and the wrong scheme or port.
  4. Missing Origin, according to the endpoint's normal non-CORS behavior.
  5. Credentialed and non-credentialed requests as separate cases.
  6. A denied method and denied custom header in preflight.
  7. Success, validation errors, authentication failures, and server errors, because inconsistent headers can make only some responses unreadable.
  8. Responses through any CDN or reverse proxy, checking that Vary: Origin and the selected allow-origin value survive correctly.

The browser console can explain why its CORS check failed, while the Network panel can show whether a preflight occurred and which response headers arrived. Server logs answer a different question: what the endpoint actually received and processed. Reading all three views avoids diagnosing a browser policy error as an API outage, or overlooking a server-side action merely because JavaScript could not read its response.

Make the boundary explicit

A careful CORS policy begins with an inventory: which browser origins need which responses, whether credentials are involved, and which methods and request headers are genuinely required. Public resources can state public access. Private integrations should use exact origins and the smallest practical surface. Endpoints that do not need cross-origin browser access do not need permissive CORS headers at all.

The central question is not “How do I remove the CORS error?” It is “Which browser code should be able to read this response, and which independent controls protect the operation itself?” Once those are answered separately, CORS becomes a precise sharing protocol rather than a mysterious security switch.

References