Fix: Access to fetch blocked by CORS policy: No 'Access-Control-Allow-Origin' header

Updated 2026-03-06

The Error

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

You’ll see this in Chrome DevTools console (and every other modern browser). The fetch call rejects with a TypeError: Failed to fetch, and the response body is empty even though the server actually returned data.

GET https://api.example.com/data net::ERR_FAILED 200 (OK)
Uncaught (in promise) TypeError: Failed to fetch

The Fix

  1. If you control the API server (Express/Node.js): Add the cors middleware.
npm install cors
// server.js — Express 4.21, Node.js 22.2
const express = require('express');
const cors = require('cors');

const app = express();

// Allow all origins (development only)
app.use(cors());

// Or restrict to specific origins (production)
app.use(cors({
  origin: ['https://yourapp.com', 'http://localhost:3000'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true
}));

app.get('/data', (req, res) => {
  res.json({ message: 'CORS is working' });
});

app.listen(3001, () => console.log('API running on port 3001'));
  1. If you need to set headers manually (without the cors package):
// Manual CORS headers — works in any Node.js framework
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3000');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.setHeader('Access-Control-Allow-Credentials', 'true');

  // Handle preflight requests
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }
  next();
});
  1. If you don’t control the API server: Use a proxy during development.

With Vite:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
}

Then change your fetch calls to use the proxy path:

// Before (fails with CORS)
fetch('https://api.example.com/data');

// After (proxied through Vite dev server, no CORS)
fetch('/api/data');

With webpack dev server (Create React App or custom webpack):

// package.json
{
  "proxy": "https://api.example.com"
}
  1. For production when you don’t control the API: Set up a server-side proxy. Create a lightweight API route that forwards requests:
// /api/proxy.js — Next.js API route example
export default async function handler(req, res) {
  const response = await fetch('https://api.example.com/data', {
    method: req.method,
    headers: {
      'Content-Type': 'application/json',
      'Authorization': req.headers.authorization
    },
    body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined
  });

  const data = await response.json();
  res.status(response.status).json(data);
}

Your frontend calls /api/proxy instead of the external API. Same-origin request, no CORS.

Why This Happens

CORS (Cross-Origin Resource Sharing) is a browser security mechanism. When JavaScript on http://localhost:3000 makes a request to https://api.example.com, the browser recognizes these are different origins (different protocol, domain, or port) and blocks the response unless the server explicitly opts in.

The browser sends the request. The server receives it, processes it, and returns data. But before the browser hands that data to your JavaScript, it checks the response headers for Access-Control-Allow-Origin. If that header is missing or doesn’t match your origin, the browser throws away the response and raises the CORS error. The server did its job. The browser just won’t let your code see the result.

This is why the error says “200 (OK)” — the HTTP request succeeded. The blocking happens at the browser level, not the network level. This also explains why the same request works perfectly in Postman, curl, or server-side code: those tools don’t enforce CORS. Only browsers do.

For requests with custom headers (like Authorization) or non-simple methods (like PUT or DELETE), the browser sends a preflight request first — an OPTIONS request asking the server “are you okay with this?” If the server doesn’t respond to OPTIONS with the right CORS headers, the actual request never fires. This is why you sometimes see the error on OPTIONS instead of GET.

Edge Cases

Credentials and wildcards don’t mix. If you set Access-Control-Allow-Origin: * but also need to send cookies or auth headers, the browser rejects it. Wildcard origins can’t be used with credentials: true. You must specify the exact origin:

// This combination FAILS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');

// This works
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3000');
res.setHeader('Access-Control-Allow-Credentials', 'true');

On the client side, you also need to opt in:

fetch('https://api.example.com/data', {
  credentials: 'include'  // Required for cookies/auth
});

Multiple origins. The Access-Control-Allow-Origin header only accepts one value (or *). You can’t comma-separate origins. To support multiple allowed origins, check the request’s Origin header dynamically:

const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }
  next();
});

The Vary: Origin header is critical. Without it, CDNs and browser caches may serve a cached response with the wrong origin header to a different requester.

Preflight caching. Preflight OPTIONS requests add latency. Cache them with Access-Control-Max-Age:

res.setHeader('Access-Control-Max-Age', '86400'); // Cache preflight for 24 hours

Chrome caps this at 7200 seconds (2 hours) regardless of what you set, but it still helps.

Opaque responses with no-cors mode. Setting mode: 'no-cors' on a fetch request doesn’t fix CORS — it makes the response opaque, meaning you can’t read any data from it. It’s only useful for fire-and-forget requests like analytics beacons. Don’t use it as a CORS workaround:

// DON'T do this — response.json() will fail
const response = await fetch('https://api.example.com/data', { mode: 'no-cors' });
const data = await response.json(); // TypeError: body stream already read

CORS on static file servers. If you’re serving an API from S3, Cloudflare R2, or Nginx, CORS headers must be configured at that level, not in your application code. For Nginx:

location /api/ {
    add_header 'Access-Control-Allow-Origin' 'https://yourapp.com' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;

    if ($request_method = 'OPTIONS') {
        return 204;
    }
}

The always directive ensures headers are sent even on error responses (4xx, 5xx), which browsers still check for CORS.

See Also