Zachary
Skip to main content

Zachary

CORS Explained for Frontend Developers

CORS explained concept cover

CORS is one of those topics that sounds far more hostile than it is. Developers see a browser error, the request fails, and suddenly it feels like the server, the frontend, and the browser are all fighting each other. The truth is simpler. CORS is a browser security policy about which origins are allowed to access which resources. Once you understand that, the error messages stop feeling mystical and start feeling diagnostic.

I have been building full stack applications from Portmore, Jamaica for a while now, and CORS issues come up in almost every project that involves a frontend talking to a separate API. The pattern repeats: someone adds a custom header, switches to POST with JSON, or introduces credentials, and suddenly the request that worked five minutes ago is blocked. This article is the guide I wish I had early on, with real code examples and the exact fixes for the most common problems.

The first thing to understand about CORS

CORS is not the server blocking the request in some abstract way. It is the browser enforcing rules around cross origin access. That distinction matters. Your API may technically respond, but the browser can still refuse to expose that response to your frontend code if the right headers are missing or mismatched.

This is why CORS bugs often confuse people. They are seeing network activity and still getting blocked behavior. The browser is the gatekeeper. The server sends the response, the browser inspects the CORS headers on that response, and if they do not satisfy the policy, the browser hides the response from JavaScript. The server never even knows the browser rejected it.

That means CORS is always solved on the server side by sending the right response headers. No amount of frontend code can override the browser's enforcement. You cannot set Access-Control-Allow-Origin from the client. The server has to cooperate.

What counts as a different origin

Origin includes protocol, domain, and port. If any one of those changes, you are dealing with a different origin. That means localhost on one port talking to another port is cross origin. Http and https versions are different too. Once you internalize that, a lot of local development confusion disappears.

Here are some examples that catch people off guard:

  • http://localhost:3000 talking to http://localhost:4000 is cross origin (different port)
  • http://example.com talking to https://example.com is cross origin (different protocol)
  • https://app.example.com talking to https://api.example.com is cross origin (different subdomain)
  • https://example.com talking to https://example.com is same origin (everything matches)

I always check the exact requesting origin and the exact target origin before anything else. Small mismatches waste a lot of debugging time. The browser console will usually tell you the requesting origin in the error message, so start there.

Why preflight requests happen

Simple requests versus preflighted requests

Some requests are simple enough that the browser can send them directly and then check whether the response allows access. A simple request uses GET, HEAD, or POST with standard content types like application/x-www-form-urlencoded, multipart/form-data, or text/plain, and only uses safe headers.

Others, especially ones using methods like PUT, DELETE, or PATCH, custom headers like Authorization or X-Request-ID, or content type application/json, require a preflight request first. That preflight is an OPTIONS request asking the server what is permitted.

If the server does not answer that preflight correctly, the real request may never proceed in the way you expect. The browser will show a CORS error, and you will see the OPTIONS request in the network tab but no follow up request.

Why this matters in day to day work

Developers often add a custom header, switch content type, or send credentials, then wonder why a previously working request now fails. Those changes can alter the CORS path. The browser is not being random. It is enforcing a stricter handshake because the request is no longer "simple."

Understanding the boundary between simple and preflighted requests saves hours of confusion. If your request was working and you changed one thing that made it non-simple, that is almost certainly why the preflight appeared.

Express CORS middleware configuration

The most common server side setup I use is the cors npm package with Express. Here is a production ready configuration that handles most cases properly:

const express = require('express');
const cors = require('cors');

const app = express();

const corsOptions = {
  origin: ['https://myapp.com', 'https://staging.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  credentials: true,
  maxAge: 86400
};

app.use(cors(corsOptions));

A few things to notice here. The origin is an array of specific origins, not a wildcard. The credentials flag is set to true, which means the server will send Access-Control-Allow-Credentials: true. And maxAge tells the browser to cache the preflight response for 24 hours so it does not repeat the OPTIONS request on every call.

If you need dynamic origin checking, you can pass a function instead:

const corsOptions = {
  origin: function (origin, callback) {
    const allowed = ['https://myapp.com', 'https://staging.myapp.com'];
    if (!origin || allowed.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true
};

The !origin check allows requests with no origin header, which includes server to server requests and some browser scenarios like same origin navigation. Whether you want that depends on your API's use case.

Handling preflight OPTIONS requests

If you are not using the cors package and want to handle preflight manually, you need to intercept OPTIONS requests and respond with the right headers:

app.options('*', (req, res) => {
  res.set('Access-Control-Allow-Origin', req.headers.origin);
  res.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH');
  res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization');
  res.set('Access-Control-Allow-Credentials', 'true');
  res.set('Access-Control-Max-Age', '86400');
  res.status(204).end();
});

The 204 status code means "no content," which is the correct response for a preflight. The browser reads the headers, confirms the actual request is allowed, and then sends it. If any header is missing or mismatched, the preflight fails and the real request never fires.

On the frontend, making a fetch request with credentials looks like this:

const response = await fetch('https://api.myapp.com/data', {
  method: 'POST',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ' + token
  },
  body: JSON.stringify({ name: 'test' })
});

The credentials: 'include' tells the browser to send cookies and authorization headers with the request. Without this, the browser will not attach cookies even if the server allows credentials.

CORS with cookies and auth tokens

Credentialed requests are where most teams run into serious trouble. When you send credentials: 'include' from the frontend, several strict rules kick in:

  • Access-Control-Allow-Origin cannot be *. It must be the exact requesting origin.
  • Access-Control-Allow-Credentials must be true.
  • The Set-Cookie header must include SameSite=None; Secure for cross origin cookies to work in modern browsers.

Here is a complete cookie setup for an Express API that needs to send auth cookies cross origin:

app.post('/login', (req, res) => {
  // ... authenticate user ...
  res.cookie('session', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'None',
    maxAge: 7 * 24 * 60 * 60 * 1000,
    domain: '.myapp.com'
  });
  res.json({ success: true });
});

The httpOnly flag prevents JavaScript from reading the cookie, which is a security best practice. The secure flag requires HTTPS. And sameSite: 'None' is required for the browser to send this cookie on cross origin requests. Without all three of these settings aligned, cookies will silently fail to send or be set, and you will spend hours wondering why authentication works in Postman but not in the browser.

I have seen teams debug this for days before realizing the issue was a missing SameSite attribute. Chrome is especially strict about this and will block the cookie without even showing a helpful error in some cases.

Proxy patterns for development

During local development, you can avoid CORS entirely by proxying API requests through your dev server. This makes the browser think the API is on the same origin as the frontend.

In a Next.js project, you can add rewrites to next.config.js:

module.exports = {
  async rewrites() {
    return [
      {
        source: '/api/:path*',
        destination: 'http://localhost:4000/api/:path*'
      }
    ];
  }
};

With Vite, the proxy configuration goes in vite.config.js:

export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:4000',
        changeOrigin: true
      }
    }
  }
};

This approach means your frontend calls /api/users instead of http://localhost:4000/api/users, and the dev server forwards the request. No CORS headers needed during development, and your production setup can use proper CORS configuration since the frontend and API will likely be on different origins in production.

I use this on every project because it eliminates an entire class of development friction. The proxy only exists in dev, so it does not affect production behavior at all.

The mistakes I see most often

A very common mistake is setting Access-Control-Allow-Origin: * while also setting Access-Control-Allow-Credentials: true. The spec explicitly forbids this combination. The browser will reject it with a clear error message, but people still try it because wildcard feels like it should mean "allow everything."

Another frequent issue is fixing the main response headers but forgetting the preflight response entirely. The OPTIONS handler needs the same CORS headers as your regular endpoints. If your GET endpoint returns the right headers but your server returns a 404 or no headers for OPTIONS, preflight fails and nothing works.

I also see teams configure CORS on the application layer but have a reverse proxy like Nginx stripping or overwriting the headers. If you are behind a load balancer or CDN, check whether it is modifying response headers. Cloudflare, AWS ALB, and Nginx all have settings that can interfere with CORS headers if not configured carefully.

Sometimes the frontend code is blamed when the real issue is a server configuration mismatch. Other times the backend seems fine, but the frontend is sending headers or cookies that change the policy requirements. Both sides matter, and the network tab is your best diagnostic tool.

Common error messages and their fixes

Here are the CORS errors I see most often in the browser console, and what each one actually means:

"No 'Access-Control-Allow-Origin' header is present on the requested resource" means the server response does not include the CORS header at all. Fix: add the header to the server response, either through middleware or manually.

"The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'" means you are using credentials: 'include' on the frontend but the server is returning Access-Control-Allow-Origin: *. Fix: return the specific requesting origin instead of the wildcard.

"Request header field authorization is not allowed by Access-Control-Allow-Headers" means your preflight response does not list Authorization in the allowed headers. Fix: add Authorization to the Access-Control-Allow-Headers value in your OPTIONS response.

"Method PUT is not allowed by Access-Control-Allow-Methods" means your preflight does not include PUT in the allowed methods. Fix: add it to Access-Control-Allow-Methods.

In every case, the browser error message tells you exactly which header is missing or wrong. Read it carefully before guessing.

My debugging workflow for CORS issues

I start in the browser network tab, not in guesswork. I inspect the request origin, the method, the headers, and whether a preflight happened. Then I compare that with the server response headers, especially Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Allow-Credentials.

My step by step process:

  1. Open the browser dev tools Network tab and reproduce the failing request
  2. Look for an OPTIONS request. If there is one, click it and check the response headers
  3. Check whether the response includes Access-Control-Allow-Origin matching the requesting origin
  4. If credentials are involved, verify Access-Control-Allow-Credentials: true is present
  5. If the OPTIONS succeeds but the actual request fails, check the actual response headers too
  6. If no CORS headers appear at all, the issue is on the server or a proxy is stripping them

I also try to separate transport from browser policy. Can the server be reached at all? Does it respond? Is the browser hiding the response due to policy? You can verify the server side by using curl or Postman, which do not enforce CORS. If the request works in curl but fails in the browser, the issue is definitely CORS headers, not connectivity.

The practical mindset that keeps CORS manageable

The best mindset is to stop treating CORS as a random error and start treating it as a contract between the browser, the frontend origin, and the server configuration. When each side is understood, CORS becomes annoying at times but very explainable.

Set your CORS configuration once during project setup, test it with both simple and credentialed requests, and document the allowed origins for your team. If you do that, CORS will rarely surprise you again.

If you are building frontend and backend systems together, check out my custom software work, read the API design article, or contact me if you want help solving the architecture around these problems instead of only patching symptoms.

Need help building something similar?

I design and build premium websites, product experiences, and custom software with a strong focus on clarity, performance, and real business outcomes.