Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

CORS Explained: Why the Browser Blocks Your API Call (and Postman Doesn't)

About Post

The API works in Postman. It works with curl. The backend developer swears it works. Then the frontend calls it from the browser and the console fills with red:

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

Cue the classic debate. The frontend says the API is broken. The backend says the frontend is broken. Someone suggests a browser extension that "disables CORS". Nobody is quite sure what CORS is.

Let's fix that, because once the mental model clicks, these errors take two minutes to solve instead of an afternoon.

The one sentence that explains everything

CORS is not your server blocking the request. It's the browser refusing to hand the response to your JavaScript.

That's why Postman and curl work: they aren't browsers, so they don't enforce it. In many cases the request actually reached your server, ran, and returned a response. The browser just looked at the response headers, didn't find permission to share it with the page, and threw it away.

Which means CORS is not a firewall. It does nothing to protect your API from scripts, bots or attackers using curl. It protects users, from other websites running code in their browser.

Why browsers do this: the same-origin policy

Imagine you're logged in to your bank in one tab. In another tab you open a random website. If any page could freely call any URL with your cookies and read the response, that random site could fetch your bank statement in the background and send it home.

The same-origin policy stops that. By default, JavaScript can only read responses from the same origin as the page, where origin means scheme + host + port. All three must match:

PageRequest toSame origin?
https://app.example.comhttps://app.example.com/apiYes
https://app.example.comhttps://api.example.comNo (different host)
http://localhost:5173http://localhost:8000No (different port)
http://example.comhttps://example.comNo (different scheme)

That localhost row is why every React or Vue developer meets CORS in week one: the dev server and the API run on different ports.

CORS (Cross-Origin Resource Sharing) is the controlled exception. The server can say, through response headers, "I'm fine with pages from this origin reading my responses."

The headers that matter

  • Access-Control-Allow-Origin: which origin may read the response. Either one exact origin or *.
  • Access-Control-Allow-Methods: which methods are allowed for cross-origin calls (in a preflight response).
  • Access-Control-Allow-Headers: which request headers the page may send, such as Authorization or Content-Type.
  • Access-Control-Allow-Credentials: whether cookies may be included.
  • Access-Control-Max-Age: how long the browser may cache a preflight answer.
  • Access-Control-Expose-Headers: which response headers JavaScript is allowed to read beyond the basic ones (useful for things like pagination headers).

The preflight: the request you didn't send

Here's the part that confuses people most. You send one POST, but the network tab shows two requests, and the first one is an OPTIONS.

For anything that isn't a "simple" request, the browser asks permission first. A request stays simple only if it uses GET, HEAD or POST, sends only basic headers, and has a content type of form data or plain text. In practice, almost every API call fails that test, because Content-Type: application/json and an Authorization header both trigger a preflight.

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

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: authorization, content-type
Access-Control-Max-Age: 3600

Only if that answer is a success with the right headers does the browser send the real POST. If the preflight fails, your actual request never leaves the browser.

Credentials: where * stops working

If your frontend sends cookies (for example, a single-page app using Laravel Sanctum's cookie-based auth), two things must be true:

  • The client opts in: fetch(url, { credentials: 'include' }), or withCredentials: true in Axios.
  • The server responds with Access-Control-Allow-Credentials: true and an exact origin. Access-Control-Allow-Origin: * is not allowed together with credentials. The browser will reject it.

APIs that use bearer tokens in the Authorization header don't need cookie credentials at all, which is one reason token-based mobile and SPA setups often have fewer CORS headaches.

Fixing it in Laravel

Laravel handles CORS for you with the built-in HandleCors middleware, which is enabled by default. In Laravel 12 the config file isn't in your project until you publish it:

php artisan config:publish cors

Then set it to match your frontends:

// config/cors.php
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['*'],
    'allowed_origins' => [
        env('FRONTEND_URL', 'http://localhost:5173'),
    ],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 3600,
    'supports_credentials' => true, // only if you use cookies
];

Run php artisan config:clear (or re-cache) after changing it. A cached config with the old values has fooled more than one developer into thinking their fix didn't work.

The sneaky cases

  • The CORS error that's really a 500. If the request dies in a way that skips the CORS headers (a fatal error, a gateway timeout, a web server error page), the browser reports a CORS error instead of the real one. Check the response status in the network tab and your Laravel log before touching CORS settings.
  • A path that isn't covered. Your config says api/*, but the frontend is calling /oauth/token or /broadcasting/auth. Add the path.
  • Preflight hitting auth or a redirect. If something answers the OPTIONS request with a 401 or a 302 to the login page, the preflight fails. This is common when a proxy or web server config intercepts it.
  • Duplicate headers. Nginx adds Access-Control-Allow-Origin and Laravel adds it too. Two values is invalid, and the browser rejects it. Handle CORS in one place.
  • mode: 'no-cors' isn't a fix. It gives you an "opaque" response that your code can't read. The error disappears, and so does your data.

Debugging rule: open the network tab, find the OPTIONS request first, and read its status and response headers. Most of the time the answer is right there, not in the console message.

Why the "allow everything" fix is a bad habit

Setting allowed_origins to * makes the error go away, and for a truly public, read-only API without cookies, it's fine. For an app with logins, list your real frontends explicitly. CORS isn't your main security layer, but there's no reason to tell every website on the internet that it's welcome to read your API through your users' browsers.

Remember this

  • CORS is enforced by the browser, not your server. Postman working proves nothing.
  • Origin = scheme + host + port. Different port, different origin.
  • JSON bodies and auth headers trigger a preflight OPTIONS request.
  • Cookies need credentials: 'include', supports_credentials, and an exact origin.
  • Configure it in one place: config/cors.php in Laravel.

If you'd like the official deep dive, MDN's CORS guide is excellent.

What's the most misleading CORS error you've ever chased, and what turned out to be the real cause?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close