Innovate | Connect | Transform
ContactService statusUSD $

GICT knowledge base

Understanding CORS and Why Browsers Block Cross-Origin Requests

Published

web development

Every developer hits Cross-Origin Resource Sharing (CORS) for the first time the same way. You build a frontend, point it at your shiny new Application Programming Interface (API), and the browser console lights up red. The API works fine when you hit it with curl. Postman has no problem with it. But the browser refuses to let your JavaScript touch the response, and the error message reads like it was written by someone who hates you personally.

In this article, you will learn what CORS actually does, why browsers care so much about origins, what preflight requests are doing behind the scenes, which headers control the whole dance, and how to set up CORS properly on Nginx and Apache. Once you see what is really happening, the errors stop being mysterious.

Prerequisites

Before you start, make sure you have:

  • A Linux machine running Ubuntu 22.04, Debian 12, or a similar distribution.
  • A user account with sudo access.
  • Nginx or Apache installed.
  • Some familiarity with HyperText Transfer Protocol (HTTP) and how requests work.
  • Any modern browser for testing.

What Counts as an Origin

The whole CORS story starts with what a browser considers an origin. An origin is three things glued together: the scheme, the host, and the port. Change any one of them and the browser sees a different origin entirely.

A few examples make it clearer:

  • https://example.com and https://example.com/api are the same origin. The path does not factor in.
  • https://example.com and http://example.com are different. The scheme changed.
  • https://example.com and https://api.example.com are different. The host changed.
  • https://example.com:443 and https://example.com:8443 are different. The port changed.

This matters because every CORS rule, every header, every error message hangs on this definition. If two URLs do not match on all three pieces, the browser treats them as strangers.

The Same-Origin Policy

The same-origin policy has been around since the mid-1990s, and the rule is simple. JavaScript on one origin cannot read responses from another origin unless that other origin gives explicit permission.

The reason makes sense once you walk through a quick scenario. You are logged into your bank in one tab. In another tab, you visit some random website you saw on a forum. Without the same-origin policy, JavaScript on that random site could fire off a request to your bank, ride along on your authenticated cookies, read your account details, and send everything to whoever owns the site. Your bank’s server would have no idea the request was not really you.

The same-origin policy puts a stop to this by blocking that JavaScript from reading the response. The request might still go through, but the attacker cannot see what comes back, which makes the whole thing useless to them.

CORS is the system that loosens this policy in a controlled way. It lets a server say “yes, I trust this other origin, let them read my responses.” Without CORS, every API would have to live on the exact same origin as every frontend that uses it, which would be absurd in modern web development.

How CORS Actually Works

CORS is a conversation between the browser and the server, and it happens entirely through HTTP headers. The browser tells the server where the request is coming from. The server tells the browser what is and is not allowed.

For simple requests, the browser tacks on an Origin header. The server reads it, decides whether to allow it, and responds with an Access-Control-Allow-Origin header that either matches the origin or does not. If it matches, the browser hands the response to your JavaScript. If it does not match, the browser drops the response on the floor.

A basic exchange looks like this.

The browser sends:

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

The server responds:

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

The origins line up, so the browser lets the JavaScript read the data. If the server had not sent that header, the browser would have blocked it.

[!NOTE] Here is the part that surprises a lot of people. The request still reached the server. The server still ran whatever code was attached to that endpoint. Side effects still happened. The browser is not blocking the request, only the response. So a misconfigured CORS setup on a POST endpoint can still create the resource on the backend, even though your frontend thinks the call failed.

Simple Requests vs Preflight Requests

CORS sorts requests into two buckets, and each one behaves differently.

A simple request has to meet all of these conditions:

  • The method is GET, HEAD, or POST.
  • The headers stay within a small allowed list, mostly basics like Accept, Content-Type, and a handful of others.
  • If Content-Type is set, it has to be application/x-www-form-urlencoded, multipart/form-data, or text/plain.

Anything outside those rules triggers a preflight. That includes PUT, DELETE, PATCH, custom headers like Authorization, and the Content-Type: application/json that basically every modern API uses. So in practice, most real-world API calls go through a preflight.

A preflight is an extra request the browser fires off before the actual one, using the OPTIONS method. It is essentially the browser asking “I am about to send this kind of request from this origin, are you cool with that?” The server answers with headers describing what it allows. If the answer is yes, the real request goes out. If the answer is no, the browser cancels the whole thing and you see a CORS error.

A preflight exchange looks like this.

The browser sends:

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization

The server responds:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

The Access-Control-Max-Age header tells the browser how long to remember this answer, so it does not have to ask again for every single request.

The Headers That Matter

A handful of headers do most of the work in CORS, and each one has a specific job.

  • Access-Control-Allow-Origin says which origins are allowed. You can list a specific origin like https://app.example.com or use a wildcard * to allow anything. The wildcard has a catch, which is coming up.
  • Access-Control-Allow-Methods lists which HTTP methods are allowed. Usually something like GET, POST, PUT, DELETE, OPTIONS.
  • Access-Control-Allow-Headers lists which headers the browser can send on the actual request. If your frontend sends a custom header, it has to show up here or the browser blocks the request.
  • Access-Control-Allow-Credentials is a true or false flag. When true, cookies and authorization headers come along for the ride. When this is on, the rules get stricter.
  • Access-Control-Expose-Headers lists response headers that JavaScript is allowed to read. By default, only a few basic ones are exposed, so any custom response header has to be listed here for the frontend to see it.
  • Access-Control-Max-Age controls how long preflight results get cached.

The Wildcard Plus Credentials Trap

The wildcard Access-Control-Allow-Origin: * looks like a neat shortcut. Just allow everything, problem solved. And for genuinely public APIs that do not care who is calling, it works fine.

But the wildcard has a hard rule attached. If you also set Access-Control-Allow-Credentials: true, the wildcard stops working. The browser will reject the response. Period.

This combination has wasted more developer hours than almost any other CORS issue. Someone enables CORS with a wildcard, things look great. Then they turn on credentials so cookies work, and suddenly nothing loads. The error message does not exactly spell out what went wrong, so the developer ends up reading 12 Stack Overflow answers before figuring it out.

The fix is to read the Origin header on each incoming request, check it against a list of origins you actually trust, and echo it back in Access-Control-Allow-Origin. A bit more work, but it is the only way to use credentials properly.

Configure CORS in Nginx

Nginx does not ship a dedicated CORS module, but you do not need one. The standard add_header directive does the job. The only tricky part is handling preflight requests cleanly.

Open the site configuration.

sudo nano /etc/nginx/sites-available/example.com

Add CORS handling to the relevant location block. The example below allows requests from https://app.example.com.

nginx
location /api/ {
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
        add_header 'Access-Control-Max-Age' 86400;
        add_header 'Content-Length' 0;
        return 204;
    }
    add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    proxy_pass http://backend;
}

The if block catches preflight OPTIONS requests and answers them right there, without bothering the backend. The always keyword on the headers makes sure they go out even on error responses, which CORS requires.

Test the configuration.

sudo nginx -t

Reload Nginx.

sudo systemctl reload nginx

[!WARNING] Nginx if blocks have a reputation for behaving oddly in certain contexts. The pattern above works fine for CORS, but do not start stacking complex logic inside it. For anything fancier, handle CORS in the application instead.

Configure CORS in Apache

Apache handles CORS through mod_headers, which is enabled by default on most installs.

Make sure the module is on.

sudo a2enmod headers

Open the site configuration.

sudo nano /etc/apache2/sites-available/example.com.conf

Add the CORS directives inside the relevant Location block.

apache

    Header set Access-Control-Allow-Origin "https://app.example.com"
    Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
    Header set Access-Control-Allow-Headers "Content-Type, Authorization"
    Header set Access-Control-Allow-Credentials "true"
    Header set Access-Control-Max-Age "86400"
    
    RewriteEngine On
    RewriteCond %{REQUEST_METHOD} OPTIONS
    RewriteRule ^(.*)$ $1 [R=204,L]

Test the configuration.

sudo apache2ctl configtest

Reload Apache.

sudo systemctl reload apache2

Handle CORS in the Application Instead

Web server configuration works for the simple cases, but real applications usually need more control. Different origins for different routes, dynamic origin checks, special handling for authenticated endpoints. All of that gets ugly fast in Nginx or Apache config files.

This is why most frameworks ship CORS middleware. Express has the cors package. Django has django-cors-headers. Laravel has built-in CORS handling since version 7. The middleware route is cleaner, easier to test, and easier to change when requirements shift.

The pattern is roughly the same in every framework:

  1. Read the Origin header from the request.
  2. Compare it against a list of allowed origins, either hardcoded or loaded from config.
  3. If it is allowed, set the right CORS headers on the response.
  4. If the method is OPTIONS, return a 204 with the headers and stop right there.
  5. For real requests, keep processing and let the headers ride along on the response.

Doing CORS in the application also lets you log blocked requests, which is genuinely useful when a frontend developer swears their setup should be working and you need to know why the server is rejecting it.

Common CORS Errors and What They Actually Mean

CORS errors in the browser console all look pretty similar, but they mean different things. Once you can read them, fixing them gets fast.

  • “No ‘Access-Control-Allow-Origin’ header is present on the requested resource.” The server sent nothing. Either CORS is not configured, or your config does not apply to this path. Check the location or route matching first.
  • “The ‘Access-Control-Allow-Origin’ header has a value ‘https://other.com’ that is not equal to the supplied origin.” The server is sending a CORS header, just the wrong one. Usually a hardcoded origin that needs to be updated, or a config that is matching the wrong rule.
  • “The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard ‘*’ when the request’s credentials mode is ‘include’. “ The classic wildcard plus credentials problem. You need to echo the specific origin instead of using *.
  • “Request header field X-Custom-Header is not allowed by Access-Control-Allow-Headers.” Your frontend is sending a header the server does not list as allowed. Add it to Access-Control-Allow-Headers on the server side.
  • “Method PUT is not allowed by Access-Control-Allow-Methods.” Same idea, but for the method. Add it to the allowed methods list.

Conclusion

You now know what an origin is, why the same-origin policy exists, how CORS works as a controlled way around it, the difference between simple and preflight requests, and how to set things up on Nginx, Apache, or in the application itself. You also know how to read the error messages without panicking.

CORS has a bad reputation, mostly because the errors are unfriendly and the rules feel arbitrary at first. But the underlying logic is solid. Browsers protect users from cross-origin attacks by default, and CORS gives servers a precise way to open up access where they actually want it. Configure it once, document the allowed origins somewhere your team will find them, and most of the headaches go away.

Related guides

Account services are unavailable until the production backend is configured.