CORS in Express and Node

If you've read this far, you understand what CORS headers do and why they exist. Now let's get concrete. This chapter is about setting those headers in Node.js—primarily in Express, which remains the most widely used Node.js web framework, but also in Fastify, Hono, and raw http.createServer. By the end, you'll have working code you can drop into a project, not cargo-culted snippets you found on Stack Overflow at 2 AM.

The cors npm Package: What It Does Under the Hood

The cors package is just middleware that sets response headers. That's it. There's no magic, no special browser communication protocol, no WebSocket handshake. It reads the incoming request, decides which CORS headers to set, and sets them.

Here's roughly what cors() does when called with no options:

// Simplified version of what cors() does internally
function cors(req, res, next) {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
  res.setHeader('Access-Control-Allow-Headers',
    req.headers['access-control-request-headers'] || '');

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Max-Age', '0');
    res.statusCode = 204;
    res.end();
    return;
  }

  next();
}

That's the core logic. It echoes back whatever Access-Control-Request-Headers the browser sends (effectively allowing all headers), sets Access-Control-Allow-Origin: *, and short-circuits OPTIONS requests with a 204.

The real package has more options and edge case handling, but understanding this core helps you debug problems. If your CORS isn't working with the cors package, the issue is almost always: the middleware isn't running for the request in question (ordering problem), or the options you passed don't match what you think they do.

Basic Usage: app.use(cors())

Install it:

npm install cors

The simplest possible usage:

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

const app = express();
app.use(cors());

app.get('/api/public', (req, res) => {
  res.json({ message: 'This is accessible from any origin' });
});

app.listen(8080);

What cors() with no arguments sets on every response:

Access-Control-Allow-Origin: *

And on preflight responses (OPTIONS):

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Headers: <whatever was in Access-Control-Request-Headers>

Let's verify with curl:

# Regular request
curl -v http://localhost:8080/api/public \
  -H "Origin: https://any-website.com"

# You should see:
# < Access-Control-Allow-Origin: *
# Preflight request
curl -v -X OPTIONS http://localhost:8080/api/public \
  -H "Origin: https://any-website.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization"

# You should see:
# < Access-Control-Allow-Origin: *
# < Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
# < Access-Control-Allow-Headers: Content-Type, Authorization

This is fine for genuinely public APIs. For anything with authentication, you need to be more specific.

Configuring Specific Origins, Methods, and Headers

const corsOptions = {
  origin: 'https://app.example.com',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,
};

app.use(cors(corsOptions));

What this sets:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400

Important options breakdown:

OptionWhat it controlsDefault
originAccess-Control-Allow-Origin*
methodsAccess-Control-Allow-MethodsGET,HEAD,PUT,PATCH,POST,DELETE
allowedHeadersAccess-Control-Allow-HeadersReflects Access-Control-Request-Headers
exposedHeadersAccess-Control-Expose-HeadersNone
credentialsAccess-Control-Allow-Credentialsfalse (header omitted)
maxAgeAccess-Control-Max-AgeNone (header omitted)
preflightContinuePass OPTIONS to next handler instead of respondingfalse
optionsSuccessStatusStatus code for OPTIONS responses204

The exposedHeaders option is one people frequently miss. By default, JavaScript can only read a handful of "CORS-safelisted" response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. If your API returns custom headers that the frontend needs to read (like X-Total-Count for pagination), you must expose them:

const corsOptions = {
  origin: 'https://app.example.com',
  exposedHeaders: ['X-Total-Count', 'X-Request-Id'],
};

Without this, response.headers.get('X-Total-Count') returns null in the browser even though the header is clearly visible in the Network tab. I've seen developers spend hours debugging this, convinced the header "isn't being sent" when it's actually being hidden by CORS.

Dynamic Origin Validation with a Function

For multiple allowed origins, pass a function:

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

const corsOptions = {
  origin: function (origin, callback) {
    // Allow requests with no origin (mobile apps, curl, server-to-server)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    }
  },
  credentials: true,
};

app.use(cors(corsOptions));

When the function calls callback(null, true), the cors middleware sets Access-Control-Allow-Origin to the value of the incoming Origin header. It also adds Vary: Origin automatically—this is important for caching correctness.

When the function calls callback(new Error(...)), Express passes the error to the next error handler. The response won't have CORS headers, and the browser will block the request.

A note on the !origin check: browsers always send the Origin header on cross-origin requests, but they don't send it on same-origin requests or for non-browser clients. If you're calling your API from Postman, curl, or a mobile app, origin will be undefined. Rejecting those requests would break your non-browser clients. Whether you want to allow originless requests depends on your security model.

For wildcard subdomain matching:

const corsOptions = {
  origin: function (origin, callback) {
    if (!origin) return callback(null, true);

    try {
      const url = new URL(origin);
      if (url.hostname === 'example.com' ||
          url.hostname.endsWith('.example.com')) {
        return callback(null, true);
      }
    } catch (e) {
      // Invalid origin URL
    }

    callback(new Error(`Origin ${origin} not allowed by CORS`));
  },
};

Per-Route CORS Configuration

You don't have to apply the same CORS policy to every route. The cors middleware can be applied per-route:

const publicCors = cors(); // Allow all origins
const restrictedCors = cors({
  origin: 'https://app.example.com',
  credentials: true,
});

// Public endpoints: any origin
app.get('/api/health', publicCors, (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/api/public-data', publicCors, (req, res) => {
  res.json({ data: '...' });
});

// Restricted endpoints: specific origin only
app.get('/api/users', restrictedCors, (req, res) => {
  res.json([{ id: 1, name: 'Alice' }]);
});

app.post('/api/users', restrictedCors, (req, res) => {
  // ...
});

There's a subtlety here with preflights. When the browser sends an OPTIONS request to /api/users, Express needs to handle it. If you're using per-route CORS, you need to also handle OPTIONS for those routes:

// Handle preflight for restricted routes
app.options('/api/users', restrictedCors);
app.get('/api/users', restrictedCors, (req, res) => { /* ... */ });
app.post('/api/users', restrictedCors, (req, res) => { /* ... */ });

Or enable preflight across all routes:

// Handle preflight for all routes (safe—OPTIONS by itself doesn't do anything)
app.options('*', cors());

Handling Preflight: The OPTIONS Problem

This is the #1 Express CORS mistake. Let's spell it out.

When a browser needs to preflight a request, it sends an OPTIONS request to the same URL. If your Express app doesn't have a handler for OPTIONS on that URL, Express returns a 404. The 404 has no CORS headers. The browser sees a failed preflight and blocks the actual request.

In DevTools, you'll see:

OPTIONS /api/users 404 (Not Found)

Followed by the CORS error on the actual request that never gets sent.

If you use app.use(cors()) as global middleware, this is handled for you—the middleware runs on all requests including OPTIONS, sets the headers, and responds with 204.

If you use per-route CORS, you must explicitly handle OPTIONS:

// Option 1: Handle OPTIONS globally
app.options('*', cors());

// Option 2: Handle OPTIONS per route
app.options('/api/users', cors({ origin: 'https://app.example.com' }));

Let's test that preflights work:

curl -v -X OPTIONS http://localhost:8080/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: DELETE" \
  -H "Access-Control-Request-Headers: Authorization"

# Expected: 204 with CORS headers
# If you get 404: OPTIONS isn't handled
# If you get 200 with HTML: Express is sending the GET handler response

Common Express CORS Mistakes

I've debugged enough Express CORS configurations to have a greatest hits list. Here they are, in order of frequency.

Mistake 1: Middleware Order

// WRONG: routes registered before CORS middleware
app.get('/api/users', (req, res) => { res.json([]); });
app.use(cors()); // Too late—requests to /api/users already have a handler
// RIGHT: CORS middleware before routes
app.use(cors());
app.get('/api/users', (req, res) => { res.json([]); });

Express middleware runs in the order it's registered. If your route handler runs before the CORS middleware, the response won't have CORS headers. Put cors() at the top of your middleware stack.

Mistake 2: Not Handling OPTIONS

Already covered above, but it bears repeating. If you see OPTIONS 404 in your network tab, you're not handling preflight requests.

Mistake 3: Double Headers from Express and a Reverse Proxy

If both your Express app and your Nginx proxy add CORS headers, the browser receives duplicate headers and rejects the response. Pick one layer to handle CORS and strip headers from the other. See the previous chapter for the Nginx proxy_hide_header approach.

Check with curl:

curl -v https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  2>&1 | grep -c "Access-Control-Allow-Origin"

# If this prints "2", you have double headers

Mistake 4: Using credentials: true with origin: '*'

// WRONG: this combination is forbidden by the spec
app.use(cors({
  origin: '*',         // Or just omitting origin (defaults to *)
  credentials: true,
}));

The CORS spec explicitly forbids Access-Control-Allow-Origin: * when Access-Control-Allow-Credentials: true is set. The cors package actually handles this correctly—if you pass origin: true, it reflects the request origin. But if you pass origin: '*' and credentials: true, the browser will reject every credentialed request.

// RIGHT: use a specific origin or dynamic validation
app.use(cors({
  origin: 'https://app.example.com',
  credentials: true,
}));

// Also RIGHT: origin: true reflects the request's Origin header
app.use(cors({
  origin: true,
  credentials: true,
}));

Mistake 5: Forgetting exposedHeaders

Your API returns X-Total-Count: 42 in the response. Your frontend JavaScript tries to read it:

const response = await fetch('https://api.example.com/api/users');
console.log(response.headers.get('X-Total-Count')); // null!

The header is there—you can see it in DevTools under Response Headers. But JavaScript can't access it because CORS hides all non-safelisted response headers by default.

app.use(cors({
  origin: 'https://app.example.com',
  exposedHeaders: ['X-Total-Count'],
}));

Now response.headers.get('X-Total-Count') returns "42".

Mistake 6: Error Responses Without CORS Headers

Your API throws an unhandled error. Express sends a 500 response. But the error handler doesn't set CORS headers. The browser sees a response without Access-Control-Allow-Origin and shows... a CORS error. Not the 500. Not the stack trace. Just "blocked by CORS policy."

Make sure your error handler includes CORS headers, or make sure the CORS middleware runs before the error can prevent it. With app.use(cors()) as the first middleware, this is usually handled—the headers are set before any route logic runs.

But watch out for errors thrown in middleware before cors:

// WRONG: body parser might throw before cors runs
app.use(express.json({ limit: '1kb' })); // throws on large bodies
app.use(cors());

// RIGHT: cors first
app.use(cors());
app.use(express.json({ limit: '1kb' }));

Node.js Without Express: Manual CORS Headers

If you're using raw http.createServer (or migrating off Express), here's how to handle CORS manually:

const http = require('http');

const ALLOWED_ORIGIN = 'https://app.example.com';

const server = http.createServer((req, res) => {
  // Set CORS headers on every response
  const origin = req.headers.origin;
  if (origin === ALLOWED_ORIGIN) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }

  // Handle preflight
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '86400');
    res.writeHead(204);
    res.end();
    return;
  }

  // Your actual route handling
  if (req.method === 'GET' && req.url === '/api/users') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify([{ id: 1, name: 'Alice' }]));
    return;
  }

  res.writeHead(404);
  res.end('Not found');
});

server.listen(8080, () => console.log('Listening on :8080'));

For multiple origins:

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://staging.example.com',
]);

// In the request handler:
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.has(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Vary', 'Origin');
}

The key things people forget in manual implementations:

  1. Vary: Origin when dynamically setting the allowed origin
  2. Handling OPTIONS explicitly—it won't be handled by your GET/POST handlers
  3. Setting CORS headers before ending the responseres.setHeader must come before res.writeHead or res.end

Fastify CORS Plugin

Fastify uses the @fastify/cors plugin:

npm install @fastify/cors
const fastify = require('fastify')({ logger: true });

fastify.register(require('@fastify/cors'), {
  origin: ['https://app.example.com', 'https://staging.example.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,
});

fastify.get('/api/users', async (request, reply) => {
  return [{ id: 1, name: 'Alice' }];
});

fastify.listen({ port: 8080 });

Fastify's CORS plugin supports the same dynamic origin function:

fastify.register(require('@fastify/cors'), {
  origin: (origin, cb) => {
    if (!origin || ALLOWED_ORIGINS.has(origin)) {
      cb(null, true);
    } else {
      cb(new Error('Not allowed'), false);
    }
  },
  credentials: true,
});

Fastify handles OPTIONS preflight automatically when the plugin is registered. No extra configuration needed.

One nice Fastify feature: the @fastify/cors plugin supports per-route overrides via the delegator option, letting you apply different CORS policies to different route prefixes without multiple plugin registrations.

Hono CORS Middleware

Hono is popular for Cloudflare Workers, Deno, and Bun. Its CORS middleware is built in:

import { Hono } from 'hono';
import { cors } from 'hono/cors';

const app = new Hono();

// Global CORS
app.use('/*', cors({
  origin: 'https://app.example.com',
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
  exposeHeaders: ['X-Total-Count'],
  credentials: true,
  maxAge: 86400,
}));

app.get('/api/users', (c) => {
  return c.json([{ id: 1, name: 'Alice' }]);
});

export default app;

Multiple origins in Hono:

app.use('/*', cors({
  origin: ['https://app.example.com', 'https://staging.example.com'],
}));

Or with a function:

app.use('/*', cors({
  origin: (origin) => {
    if (origin.endsWith('.example.com')) {
      return origin;
    }
    return null; // Disallowed
  },
}));

Hono's middleware handles preflight automatically. Since Hono is framework-agnostic (runs on Workers, Deno, Bun, Node), the same CORS configuration works across all runtimes.

Per-route CORS in Hono:

// Public endpoints
app.use('/api/public/*', cors());

// Restricted endpoints
app.use('/api/admin/*', cors({
  origin: 'https://admin.example.com',
  credentials: true,
}));

Full Working Example with Tests

Here's a complete Express server with CORS configured properly and curl commands to test every aspect of it:

// server.js
const express = require('express');
const cors = require('cors');

const app = express();

const ALLOWED_ORIGINS = [
  'https://app.example.com',
  'https://staging.example.com',
];

app.use(cors({
  origin: function (origin, callback) {
    if (!origin || ALLOWED_ORIGINS.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed`));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Total-Count', 'X-Request-Id'],
  credentials: true,
  maxAge: 86400,
}));

app.use(express.json());

app.get('/api/users', (req, res) => {
  const users = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ];
  res.set('X-Total-Count', users.length);
  res.set('X-Request-Id', 'req-abc-123');
  res.json(users);
});

app.post('/api/users', (req, res) => {
  const { name } = req.body;
  res.status(201).json({ id: 3, name });
});

// Error handler that preserves CORS headers
// (cors middleware already set them, so they're on the response)
app.use((err, req, res, _next) => {
  console.error(err.message);
  if (err.message.includes('not allowed')) {
    res.status(403).json({ error: err.message });
  } else {
    res.status(500).json({ error: 'Internal server error' });
  }
});

const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Now test it:

# Test 1: Simple GET from allowed origin
curl -v http://localhost:8080/api/users \
  -H "Origin: https://app.example.com"

# Expected response headers:
#   Access-Control-Allow-Origin: https://app.example.com
#   Access-Control-Allow-Credentials: true
#   Access-Control-Expose-Headers: X-Total-Count, X-Request-Id
#   X-Total-Count: 2
#   X-Request-Id: req-abc-123
#   Vary: Origin
# Test 2: GET from disallowed origin
curl -v http://localhost:8080/api/users \
  -H "Origin: https://evil.com"

# Expected: 403 with no Access-Control-Allow-Origin header
# Test 3: Preflight for POST with Authorization header
curl -v -X OPTIONS http://localhost:8080/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization"

# Expected response headers:
#   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: Content-Type, Authorization
#   Access-Control-Allow-Credentials: true
#   Access-Control-Max-Age: 86400
# Test 4: Actual POST (after preflight succeeds)
curl -v -X POST http://localhost:8080/api/users \
  -H "Origin: https://app.example.com" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer fake-token-123" \
  -d '{"name": "Charlie"}'

# Expected: 201 with CORS headers and {"id":3,"name":"Charlie"}
# Test 5: Request without Origin (non-browser client)
curl -v http://localhost:8080/api/users

# Expected: 200 with data, no CORS headers (no Origin = no CORS)
# Test 6: Check for double headers (should be exactly 1)
curl -s -D - http://localhost:8080/api/users \
  -H "Origin: https://app.example.com" \
  -o /dev/null | grep -c "Access-Control-Allow-Origin"

# Expected: 1 (if you see 2, you have a double header problem)

Testing Your CORS Configuration

Beyond curl, here are other ways to verify your CORS setup:

Browser DevTools

  1. Open your frontend app in the browser
  2. Open DevTools → Network tab
  3. Check "Preserve log" and "Disable cache"
  4. Make a request that should trigger CORS
  5. Look for the OPTIONS preflight request (if applicable)
  6. Click on the request and examine Response Headers
  7. Verify Access-Control-Allow-Origin matches your frontend's origin

A Minimal HTML Test File

Create a file cors-test.html and open it in a browser:

<!DOCTYPE html>
<html>
<body>
<pre id="output">Testing CORS...</pre>
<script>
  async function testCORS() {
    const output = document.getElementById('output');
    try {
      const res = await fetch('http://localhost:8080/api/users', {
        credentials: 'include',
        headers: {
          'Authorization': 'Bearer test-token',
        },
      });
      const data = await res.json();
      const totalCount = res.headers.get('X-Total-Count');
      output.textContent = JSON.stringify({
        status: res.status,
        totalCount,
        data,
      }, null, 2);
    } catch (err) {
      output.textContent = `CORS Error: ${err.message}\nCheck DevTools console for details.`;
    }
  }
  testCORS();
</script>
</body>
</html>

Open this file via a local web server (not file://file:// origins behave differently). A quick way:

npx serve -p 3000 .
# Then open http://localhost:3000/cors-test.html

This makes a credentialed cross-origin request from http://localhost:3000 to http://localhost:8080. If your CORS configuration includes http://localhost:3000 in the allowed origins, it works. If not, you'll see the error in the console and in the page.

Automated Testing

For CI/CD, write integration tests that verify CORS headers:

// cors.test.js (using Node's built-in test runner)
const { test } = require('node:test');
const assert = require('node:assert');

const BASE = 'http://localhost:8080';

test('returns CORS headers for allowed origin', async () => {
  const res = await fetch(`${BASE}/api/users`, {
    headers: { 'Origin': 'https://app.example.com' },
  });
  assert.strictEqual(
    res.headers.get('access-control-allow-origin'),
    'https://app.example.com'
  );
  assert.strictEqual(
    res.headers.get('access-control-allow-credentials'),
    'true'
  );
});

test('does not return CORS headers for disallowed origin', async () => {
  const res = await fetch(`${BASE}/api/users`, {
    headers: { 'Origin': 'https://evil.com' },
  });
  assert.strictEqual(
    res.headers.get('access-control-allow-origin'),
    null
  );
});

test('handles preflight correctly', async () => {
  const res = await fetch(`${BASE}/api/users`, {
    method: 'OPTIONS',
    headers: {
      'Origin': 'https://app.example.com',
      'Access-Control-Request-Method': 'POST',
      'Access-Control-Request-Headers': 'Content-Type, Authorization',
    },
  });
  assert.strictEqual(res.status, 204);
  assert.strictEqual(
    res.headers.get('access-control-allow-origin'),
    'https://app.example.com'
  );
  assert.ok(
    res.headers.get('access-control-allow-methods').includes('POST')
  );
  assert.ok(
    res.headers.get('access-control-allow-headers').includes('Authorization')
  );
});

test('exposes custom headers', async () => {
  const res = await fetch(`${BASE}/api/users`, {
    headers: { 'Origin': 'https://app.example.com' },
  });
  const exposed = res.headers.get('access-control-expose-headers');
  assert.ok(exposed.includes('X-Total-Count'));
  assert.ok(exposed.includes('X-Request-Id'));
});

Run with:

# Start server in background, run tests, then stop
node server.js &
SERVER_PID=$!
sleep 1
node --test cors.test.js
kill $SERVER_PID

CORS configuration is one of those things that's easy to break accidentally (a dependency update, a new middleware, a deployment change). Automated tests catch regressions before your users do.

Summary

FrameworkPackage/PluginPreflight handlingMultiple origins
ExpresscorsAutomatic with app.use(cors()), manual with per-routeFunction in origin option
Fastify@fastify/corsAutomaticArray or function in origin option
Honohono/cors (built-in)AutomaticArray or function in origin option
Raw Node.jsManualMust handle OPTIONS explicitlyManual check against allowlist

The recipe for getting CORS right in Node.js:

  1. Put CORS middleware first in the middleware stack
  2. Use specific origins, not *, for anything with credentials
  3. Handle OPTIONS explicitly if using per-route CORS
  4. Set maxAge to reduce preflight traffic
  5. Set exposedHeaders for any custom response headers your frontend reads
  6. Set Vary: Origin when dynamically choosing the allowed origin (the cors package does this for you)
  7. Test with curl before testing in the browser—curl shows you the raw headers without the browser's interpretation layer
  8. Write automated tests for your CORS configuration—it breaks more often than you'd think