DEV Community

Cover image for CORS Explained: Why Your API Requests Fail
Rasim Çarkçı
Rasim Çarkçı

Posted on Originally published at moreonlinetools.com

CORS Explained: Why Your API Requests Fail

You fetch an API from JavaScript and get a cryptic "blocked by CORS policy" error. Nothing in your code is wrong — it's the browser enforcing a security rule. Here is exactly what CORS is, why it exists, and how to fix it.

Originally published on MoreOnlineTools Blog.

What Is CORS and Why Does It Exist?

CORS stands for Cross-Origin Resource Sharing. It is a browser security mechanism that controls how web pages load resources from a different origin (domain, protocol, or port) than the page itself.

The underlying rule CORS enforces is called the Same-Origin Policy (SOP). Browsers have applied SOP since the late 1990s: a script on https://example.com cannot by default read responses from https://api.otherdomain.com. Without this rule, any malicious website could silently fetch your bank page, read the response, and steal your account data using your already-logged-in session cookies.

CORS does not block the request itself — your browser still sends it. CORS blocks JavaScript from reading the response when the server has not explicitly said "this origin is allowed."

What Counts as a Different Origin?

Two URLs share the same origin only if all three of these match exactly:

  • Protocol: http vs https are different origins
  • Domain: example.com vs api.example.com are different origins
  • Port: localhost:3000 vs localhost:8080 are different origins

So https://app.example.com and https://api.example.com are cross-origin, even though they share the same root domain. Subdomains always count as different origins.

Simple Requests vs Preflight Requests

Not all cross-origin requests trigger the same CORS flow. Browsers split them into two categories:

Simple Requests

A request is "simple" if it meets all of these conditions:

  • Method is GET, POST, or HEAD
  • Only uses safe headers: Accept, Accept-Language, Content-Language, or Content-Type limited to application/x-www-form-urlencoded, multipart/form-data, or text/plain
  • No event listeners on XMLHttpRequestUpload

For simple requests, the browser sends the request directly and checks the response for Access-Control-Allow-Origin. If the header is missing or the origin does not match, the browser blocks JavaScript from reading the response and logs a CORS error.

Preflight Requests

Any request that is not "simple" — for example, a PUT or DELETE, a request with Authorization or Content-Type: application/json, or a custom header — triggers a preflight.

A preflight is an automatic OPTIONS request the browser sends before the actual request. The browser asks the server: "I want to send a POST with JSON and an Authorization header from this origin. Is that allowed?" The server must respond with the appropriate CORS headers. Only then does the browser proceed with the actual request.

OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.mysite.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
Enter fullscreen mode Exit fullscreen mode

A correct server response to a preflight:

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

Access-Control-Max-Age tells the browser how many seconds to cache the preflight result — avoiding a round-trip on every subsequent request.

The Key CORS Response Headers

Header Purpose Example value
Access-Control-Allow-Origin Which origins may read the response https://app.mysite.com or *
Access-Control-Allow-Methods Which HTTP methods are permitted GET, POST, DELETE, OPTIONS
Access-Control-Allow-Headers Which request headers are permitted Content-Type, Authorization
Access-Control-Allow-Credentials Whether cookies/auth may be included true
Access-Control-Expose-Headers Which response headers JS can read X-Request-Id, X-RateLimit-Remaining
Access-Control-Max-Age Seconds to cache the preflight result 86400

The Wildcard Trap: Why * Is Not Always Enough

Setting Access-Control-Allow-Origin: * seems like the easiest fix — and for public read-only APIs it usually works fine. But there is one critical limitation: you cannot use * with credentials.

If your request includes cookies, HTTP authentication, or a TLS client certificate (i.e. you set credentials: "include" in fetch()), the browser requires an explicit origin, not a wildcard. The server must also explicitly set Access-Control-Allow-Credentials: true. Using * in this scenario results in a CORS error even though the header is present.

// ❌ This combination is ALWAYS rejected by the browser:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

// ✅ Correct for credentialed requests:
Access-Control-Allow-Origin: https://app.mysite.com
Access-Control-Allow-Credentials: true
Enter fullscreen mode Exit fullscreen mode

How to Fix CORS: Backend Configuration

PHP (Native)

<?php
$allowed_origins = [
    'https://app.mysite.com',
    'https://staging.mysite.com',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';

if (in_array($origin, $allowed_origins, true)) {
    header("Access-Control-Allow-Origin: {$origin}");
    header('Access-Control-Allow-Credentials: true');
}

header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Max-Age: 86400');

// Handle preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}
Enter fullscreen mode Exit fullscreen mode

Node.js / Express

const cors = require('cors');

app.use(cors({
    origin: ['https://app.mysite.com', 'https://staging.mysite.com'],
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    maxAge: 86400,
}));
Enter fullscreen mode Exit fullscreen mode

Nginx

add_header Access-Control-Allow-Origin "https://app.mysite.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;

if ($request_method = OPTIONS) {
    add_header Access-Control-Max-Age 86400;
    return 204;
}
Enter fullscreen mode Exit fullscreen mode

Apache (.htaccess)

Header always set Access-Control-Allow-Origin "https://app.mysite.com"
Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type, Authorization"
Header always set Access-Control-Allow-Credentials "true"
Enter fullscreen mode Exit fullscreen mode

Multiple Allowed Origins (Dynamic Validation)

You cannot list multiple origins in a single Access-Control-Allow-Origin header — it only accepts one value or *. The correct pattern is to validate the incoming Origin header against a server-side allowlist and echo it back if it matches:

// Generic pattern (any language)
IF request.origin IN allowlist:
    response.header("Access-Control-Allow-Origin", request.origin)
    response.header("Vary", "Origin")  // Important for CDN caching
ELSE:
    response.header("Access-Control-Allow-Origin", "null")  // deny
Enter fullscreen mode Exit fullscreen mode

Note the Vary: Origin header — without it, a CDN or proxy might cache a response intended for one origin and serve it to a different one.

CORS Is Not a Security Feature for the Server

This is the most common misconception about CORS. CORS does not protect your API from unauthorized access. It only controls what browsers do with responses. A curl request, a Postman request, or a server-to-server request completely ignores CORS — browsers are the only enforcement point.

If you need to restrict who can call your API, use proper authentication (JWT, API keys, OAuth) and authorization checks on the server side. CORS is for protecting users' browsers from malicious websites reading cross-site responses — not for protecting your API from unauthorized callers.

Common CORS Errors and What They Mean

Error message Root cause Fix
No 'Access-Control-Allow-Origin' header is present Server never sends the header Add the header to your server/backend
The value of 'Access-Control-Allow-Origin' is not equal to the supplied origin Header is present but origin does not match Use dynamic origin echo or correct whitelist
Credential flag is true, but the 'Access-Control-Allow-Credentials' header is not 'true' Using credentials without the header Add Access-Control-Allow-Credentials: true and remove wildcard
Request header field Authorization is not allowed by Access-Control-Allow-Headers The preflight rejected the Authorization header Add Authorization to Access-Control-Allow-Headers
Method PUT is not allowed by Access-Control-Allow-Methods Method missing from the preflight response Add the method to Access-Control-Allow-Methods

CORS in Local Development

During development you often hit CORS errors because your frontend runs on localhost:3000 (Vite, Create React App, etc.) while your backend runs on localhost:8000. Different ports = different origins.

The cleanest solution is to add http://localhost:3000 to your server's CORS allowlist in development mode. An alternative is to use your bundler's proxy feature:

// vite.config.js
export default {
    server: {
        proxy: {
            '/api': {
                target: 'http://localhost:8000',
                changeOrigin: true,
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

With a dev proxy, the browser thinks all requests go to localhost:3000 — no cross-origin, no CORS. This is the approach used by most modern frontend frameworks.

Key Takeaways

  • CORS is enforced by browsers only — servers and tools like curl are unaffected
  • It restricts reading responses, not sending requests
  • Simple requests go through directly; non-simple requests trigger an OPTIONS preflight
  • Access-Control-Allow-Origin: * cannot be used together with credentials
  • Always echo the specific origin dynamically and include Vary: Origin when using an allowlist
  • CORS does not replace authentication — it is a browser-level protection for users, not an API access control mechanism

Want to check whether your server sends correct CORS and security headers? Try our free HTTP Security Headers Analyzer — paste any URL and get an instant breakdown of every header, including Access-Control-Allow-Origin. Runs entirely in your browser.

Top comments (0)