JSON API CORS: Preflight Requests, Credentials & Server-Side Configuration
Last updated:
Setting Content-Type: application/json on a fetch request automatically triggers a CORS preflight — unlike HTML form submissions, which are "simple requests" that browsers allow cross-origin without pre-checking. The browser sends an HTTP OPTIONS request first; if your server doesn't respond with the correct Access-Control-Allow-Origin and Access-Control-Allow-Headers headers, the actual JSON request never fires. In Express, the fix is one line: app.use(cors({ origin: "https://app.example.com" })). In Next.js App Router, add a middleware.ts that intercepts OPTIONS and returns a 204 with CORS headers. In nginx, add add_header Access-Control-Allow-Origin "https://app.example.com" inside the relevant location block and handle OPTIONS with return 204. The critical gotchas are: credentialed requests (withCredentials / credentials: 'include') require an explicit origin — the wildcard * is forbidden — and Access-Control-Allow-Credentials: true must appear on both the preflight and actual responses. Set Access-Control-Max-Age: 86400 to cache the preflight for 24 hours (Chrome caps at 7200 s) and eliminate the 50–200 ms round-trip penalty on every uncached API call.
Why JSON APIs Always Trigger CORS Preflight
The CORS specification divides cross-origin requests into two categories: "simple requests" that browsers have always permitted (forms predated CORS), and everything else. A simple request must use GET, HEAD, or POST, must not include custom headers like Authorization, and must use one of three content types: text/plain, application/x-www-form-urlencoded, or multipart/form-data. HTML forms use those content types — that is why a form POST to a different origin works without CORS configuration.
The moment you add Content-Type: application/json, you exit the safe list. The browser treats the request as non-simple and must verify the server's permission before sending it. It does this by dispatching an automatic OPTIONS preflight with three headers: Origin, Access-Control-Request-Method, and Access-Control-Request-Headers. The same applies if you add any custom header like Authorization, X-API-Key, or any X- prefix header — each one trips the preflight.
// This fetch() triggers a preflight because of Content-Type: application/json
fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // ← non-simple content type
'Authorization': 'Bearer token123', // ← non-simple header (also triggers)
},
body: JSON.stringify({ name: 'Alice' }),
});
// Browser automatically sends this OPTIONS preflight first:
// OPTIONS /users HTTP/1.1
// Host: api.example.com
// Origin: https://app.example.com
// Access-Control-Request-Method: POST
// Access-Control-Request-Headers: content-type, authorization
// Server must respond with:
// HTTP/1.1 204 No Content
// Access-Control-Allow-Origin: https://app.example.com
// Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
// Access-Control-Allow-Headers: Content-Type, Authorization
// Access-Control-Max-Age: 86400
// Only THEN does the browser send the actual POST.
// This form POST does NOT trigger preflight (simple request):
fetch('https://api.example.com/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // simple
body: 'name=Alice&role=user',
});
// Non-simple triggers:
// ✗ Content-Type: application/json
// ✗ Content-Type: text/xml
// ✗ Authorization header (any value)
// ✗ X-Custom-Header (any custom header)
// ✗ PUT, PATCH, DELETE methods
// ✗ ReadableStream request bodyUnderstanding this distinction explains why a plain GET to an API endpoint might work without CORS configuration while a POST with a JSON body fails — the GET may qualify as a simple request depending on your headers, while the JSON POST never does. The solution is always server-side: configure your server to respond correctly to the preflight OPTIONS request.
Basic CORS Configuration for JSON APIs
Correct CORS configuration requires handling both the OPTIONS preflight and adding headers to actual responses. The three most common server environments for JSON APIs are Express (Node.js), Next.js App Router route handlers, and nginx as a reverse proxy. Each has a canonical pattern.
// ── Express: using the cors npm package ──────────────────────────────
// npm install cors @types/cors
import express from 'express';
import cors from 'cors';
const app = express();
// Simple: allow one origin for all routes
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400, // preflight cached 24h (Chrome caps at 7200s)
}));
app.use(express.json());
app.get('/api/users', (req, res) => {
res.json({ users: [] });
});
// ── Express: manual CORS headers (no package) ─────────────────────────
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
// Respond to preflight immediately — do not pass to route handlers
if (req.method === 'OPTIONS') {
res.status(204).end();
return;
}
next();
});
// ── Next.js App Router: route handler headers ─────────────────────────
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
return NextResponse.json({ users: [] }, { headers: corsHeaders });
}
export async function POST(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ created: body }, { status: 201, headers: corsHeaders });
}
// ── nginx: reverse proxy CORS headers ────────────────────────────────
// nginx.conf (inside server block)
/*
location /api/ {
# Handle preflight OPTIONS request
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
return 204;
}
# Add CORS headers to actual responses
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
proxy_pass http://localhost:3000;
}
*/The always flag in nginx's add_header directive is important — without it, headers are only added to responses with 2xx and 3xx status codes, which means error responses (4xx, 5xx) won't include CORS headers and the browser will show a confusing "CORS error" instead of the actual error status. For JSON API design patterns including error response formatting, see the linked guide.
Credentialed Requests: withCredentials and Cookies
Credentialed CORS requests — those that include cookies, HTTP authentication, or TLS client certificates — have stricter rules than regular CORS. The wildcard Access-Control-Allow-Origin: * is explicitly forbidden when credentials are involved. Both the client and server must opt into credential sharing, and both must do so correctly or the browser blocks the response.
// ── Client: opt into credential sharing ──────────────────────────────
// fetch API
fetch('https://api.example.com/profile', {
method: 'GET',
credentials: 'include', // sends cookies + auth headers cross-origin
});
// XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/profile');
xhr.withCredentials = true; // equivalent to credentials: 'include'
xhr.send();
// axios
import axios from 'axios';
axios.get('https://api.example.com/profile', { withCredentials: true });
// ── Server: must reflect explicit origin, NOT wildcard ─────────────────
// WRONG — will be rejected by browser when credentials: 'include'
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Browser error: "The value of the 'Access-Control-Allow-Origin' header
// must not be the wildcard '*' when credentials flag is true"
// CORRECT — reflect the requesting origin from your allowlist
const origin = req.headers.origin;
const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Prevent caching of the Vary-by-Origin response
res.setHeader('Vary', 'Origin');
}
// ── Express + cors package: credentialed config ───────────────────────
app.use(cors({
origin: (origin, callback) => {
const allowed = ['https://app.example.com', 'https://admin.example.com'];
if (!origin || allowed.includes(origin)) {
callback(null, true);
} else {
callback(new Error('CORS: origin not allowed'));
}
},
credentials: true, // sets Access-Control-Allow-Credentials: true
// Note: setting credentials: true with origin: '*' throws an error
}));
// ── Cookie requirements for cross-origin requests ─────────────────────
// Cookies must also be configured for cross-origin sharing:
// SameSite=None — required for cross-site cookies
// Secure — required when SameSite=None (HTTPS only)
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'None', // allows cross-origin cookie sending
domain: '.example.com', // shared across subdomains
});
// ── Preflight for credentialed requests ───────────────────────────────
// The OPTIONS preflight must also return:
// Access-Control-Allow-Origin: https://app.example.com (explicit, not *)
// Access-Control-Allow-Credentials: true
// If the preflight returns *, the subsequent credentialed request is blocked
// even if the actual response returns the correct explicit origin.The Vary: Origin header is important whenever you reflect the requesting origin dynamically. Without it, a CDN or shared cache might serve a response with Access-Control-Allow-Origin: https://app.example.com to a request from https://admin.example.com, causing that request to fail. Adding Vary: Origin tells caches to store separate responses per origin value.
Preflight Request Optimization
Without preflight caching, every cross-origin JSON API call costs two HTTP round trips — the OPTIONS preflight and the actual request. On a connection with 100 ms latency, that is 200 ms of overhead per call before any application logic runs. The Access-Control-Max-Age response header eliminates this overhead for repeated callers by telling the browser how long to cache the preflight result.
// ── Access-Control-Max-Age: configure preflight caching ──────────────
// Browser caches this preflight result for the given number of seconds
// Cache key: (origin, method, request-headers) tuple
// Server response headers for preflight:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400 // 24 hours
// Browser cache caps:
// Chrome: max 7200 seconds (2 hours) — sends the header to server but caps
// Firefox: max 86400 seconds (24 hours) — respects the full value
// Safari: max 600 seconds (10 minutes)
// For maximum cache hit rate, set 86400 — each browser uses its own cap
// ── Express: set maxAge in cors config ───────────────────────────────
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400, // cors package sets Access-Control-Max-Age
}));
// ── Dedicated OPTIONS handler for performance ─────────────────────────
// Respond to OPTIONS before any heavy middleware (auth, body parsing)
// Place this before other route registrations
app.options('*', cors()); // cors package handles OPTIONS + cache headers
// Manual approach: early exit for OPTIONS
app.use((req, res, next) => {
if (req.method !== 'OPTIONS') return next();
// Preflight: respond immediately, skip auth middleware, body parsers, etc.
const origin = req.headers.origin ?? '';
const allowed = ['https://app.example.com', 'https://admin.example.com'];
if (allowed.includes(origin)) {
res.set({
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
});
}
res.status(204).end();
});
// ── Cache invalidation: when does the preflight re-fire? ──────────────
// Preflight cache is invalidated when ANY of these change:
// - The Origin value
// - The HTTP method
// - The set of request headers
// If you add a new header (e.g., X-Request-ID), the cache misses
// and a new preflight fires even within the Max-Age window.
// ── Measuring preflight overhead ─────────────────────────────────────
// DevTools Network tab: look for a row with Method = OPTIONS
// Timing breakdown shows: DNS + TCP + TLS + TTFB + Content
// A cached preflight: the OPTIONS row disappears from the Network tab
// Uncached preflight on 50ms RTT connection: adds ~50-100ms per unique call
// ── Next.js: ensure OPTIONS is handled in middleware ──────────────────
// middleware.ts — runs before route handlers, responds early to OPTIONS
export function middleware(request: Request) {
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
}A subtle point: the preflight cache is per (origin, method, headers) tuple, not per URL. If your SPA calls ten different endpoints with the same method and headers from the same origin, only one preflight is cached — all ten endpoints benefit. But if different calls use different sets of headers (some with Authorization, some without), those are separate cache entries and each fires its own preflight.
CORS in Production: Allowed Origins List
Production CORS configuration should never use a static wildcard. Instead, maintain an explicit allowlist and validate the Origin header dynamically against it. This section covers environment-specific configuration, regex-based domain matching, and secure dynamic origin reflection.
// ── Environment-based allowlist ───────────────────────────────────────
// config/cors.ts
export function getAllowedOrigins(): string[] {
const base = [
'https://app.example.com',
'https://admin.example.com',
];
if (process.env.NODE_ENV !== 'production') {
return [...base, 'http://localhost:3000', 'http://localhost:3001'];
}
// In production, also allow origins from environment variable:
// CORS_ALLOWED_ORIGINS=https://staging.example.com,https://preview.example.com
const extra = (process.env.CORS_ALLOWED_ORIGINS ?? '')
.split(',')
.map(o => o.trim())
.filter(Boolean);
return [...base, ...extra];
}
// ── Exact match validation ────────────────────────────────────────────
import { getAllowedOrigins } from './config/cors';
export function isOriginAllowed(origin: string | undefined): boolean {
if (!origin) return false; // no Origin header (server-to-server call)
return getAllowedOrigins().includes(origin);
}
// ── Regex matching for wildcard subdomains ────────────────────────────
// Allow any subdomain of example.com (e.g., tenant-a.example.com)
const ORIGIN_PATTERN = /^https://[a-z0-9-]+.example.com$/;
function isOriginAllowedRegex(origin: string | undefined): boolean {
if (!origin) return false;
// Exact allowlist takes priority (prevents regex bypass attempts)
if (getAllowedOrigins().includes(origin)) return true;
// Regex for wildcard subdomain matching
return ORIGIN_PATTERN.test(origin);
}
// ── Express middleware with dynamic reflection ────────────────────────
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowed = isOriginAllowedRegex(origin);
if (allowed && origin) {
res.setHeader('Access-Control-Allow-Origin', origin); // reflect exact origin
res.setHeader('Vary', 'Origin'); // required for caches
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
}
// If origin is not allowed, send NO Access-Control headers
// — browser will block the response, which is the intended behavior
if (req.method === 'OPTIONS') {
res.status(204).end();
return;
}
next();
});
// ── Security: never echo the Origin blindly ───────────────────────────
// WRONG: reflects any origin, defeating CORS entirely
res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*');
// CORRECT: validate against allowlist first
const origin = req.headers.origin;
if (origin && getAllowedOrigins().includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
// ── Logging blocked origins in production ─────────────────────────────
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && !isOriginAllowedRegex(origin)) {
console.warn('CORS: blocked origin', {
origin,
method: req.method,
path: req.path,
ip: req.ip,
});
}
next();
});Regex-based subdomain matching requires care: anchor the pattern with ^ and $, require https:// explicitly, and restrict the subdomain character set to prevent bypass attempts like https://evil.example.com.attacker.com. The regex /^https:\/\/[a-z0-9-]+\.example\.com$/ would block that because it requires the string to end immediately after .example.com.
Next.js App Router CORS Configuration
Next.js App Router provides two integration points for CORS: a global middleware.ts file that runs on every matching request before route handlers, and per-route OPTIONS exports inside individual route handler files. The middleware approach is recommended for consistent, DRY CORS configuration across all API routes.
// ── middleware.ts: global CORS for all /api routes ────────────────────
// Place at project root (next to app/ directory)
import { NextRequest, NextResponse } from 'next/server';
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://admin.example.com',
...(process.env.NODE_ENV !== 'production' ? ['http://localhost:3000'] : []),
];
function getCorsHeaders(origin: string): Record<string, string> {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
};
}
export function middleware(request: NextRequest) {
const origin = request.headers.get('origin') ?? '';
const isAllowed = ALLOWED_ORIGINS.includes(origin);
// Handle preflight OPTIONS — respond immediately, don't reach route handlers
if (request.method === 'OPTIONS') {
if (isAllowed) {
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(origin),
});
}
// Disallowed origin: return 403 for preflight
return new NextResponse(null, { status: 403 });
}
// For actual requests: continue to route handler, then add CORS headers
const response = NextResponse.next();
if (isAllowed) {
Object.entries(getCorsHeaders(origin)).forEach(([key, value]) => {
response.headers.set(key, value);
});
}
return response;
}
export const config = {
// Only run middleware on API routes, not on page routes or static files
matcher: ['/api/:path*', '/((?!_next/static|_next/image|favicon.ico).*)'],
};
// ── Per-route: app/api/users/route.ts ────────────────────────────────
// Use when only specific routes need CORS (not recommended for large APIs)
import { NextRequest, NextResponse } from 'next/server';
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
// Must export OPTIONS to handle preflight
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
const data = { users: [{ id: 1, name: 'Alice' }] };
return NextResponse.json(data, { headers: corsHeaders });
}
export async function POST(request: NextRequest) {
const body = await request.json();
// ... create user logic
return NextResponse.json({ created: true, id: 2 }, {
status: 201,
headers: corsHeaders,
});
}
// ── next.config.ts: headers() for static CORS (limited) ──────────────
// Only for simple cases — does not support dynamic origin reflection
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
{ key: 'Access-Control-Max-Age', value: '86400' },
],
},
];
},
};
export default nextConfig;
// Note: next.config headers() does NOT handle OPTIONS preflight automatically.
// You still need to export OPTIONS from each route.ts that needs preflight support.The middleware.ts approach is significantly cleaner than exporting an OPTIONS function from every route file. It also ensures that any new API route added to the project automatically inherits the CORS configuration — no risk of forgetting to add the headers to a new route. If you use the next.config.ts headers() approach, be aware that Next.js does not automatically generate an OPTIONS route from it — preflights will 404 unless you also add an OPTIONS export to each route file.
Common CORS Errors and Fixes
CORS error messages in the browser console are often misleading — they describe the symptom (header missing or wrong) but not the root cause (preflight not configured, duplicate header, proxy stripping headers). This section decodes the most common error messages and maps them to server-side fixes.
// ── Error 1: Missing Access-Control-Allow-Origin ─────────────────────
// Browser: "No 'Access-Control-Allow-Origin' header is present on the
// requested resource."
// Cause: Server doesn't return the header at all — usually because
// OPTIONS preflight is not handled, or header is only added
// conditionally and the condition failed.
// Fix: Ensure the header is returned on EVERY response, including errors:
// Express — wrong: only 2xx responses get the header
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
// Express — correct for errors: add to error handler too
app.use((err, req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
res.status(err.status || 500).json({ error: err.message });
});
// nginx: use 'always' to include on 4xx/5xx responses
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
// ── Error 2: Wildcard + credentials conflict ──────────────────────────
// Browser: "The value of the 'Access-Control-Allow-Origin' header
// in the response must not be the wildcard '*' when the
// request's credentials mode is 'include'."
// Cause: Server returns *, client uses credentials: 'include'
// Fix: Reflect the exact requesting origin from your allowlist
// WRONG
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// CORRECT
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
// ── Error 3: Header not in Access-Control-Allow-Headers ──────────────
// Browser: "Request header field X-Custom-Header is not allowed by
// Access-Control-Allow-Headers in preflight response."
// Cause: You're sending a header the server didn't declare in the preflight
// Fix: Add the header to Access-Control-Allow-Headers
res.setHeader('Access-Control-Allow-Headers',
'Content-Type, Authorization, X-Custom-Header, X-Request-ID');
// ── Error 4: Method not in Access-Control-Allow-Methods ──────────────
// Browser: "Method PATCH is not allowed by Access-Control-Allow-Methods"
// Fix: Add PATCH to the methods list
res.setHeader('Access-Control-Allow-Methods',
'GET, POST, PUT, PATCH, DELETE, OPTIONS');
// ── Error 5: Duplicate Access-Control-Allow-Origin header ─────────────
// Browser: "The 'Access-Control-Allow-Origin' header contains multiple
// values 'https://app.example.com, https://app.example.com'."
// Cause: Header set in both middleware AND route handler
// Fix: Set CORS headers in exactly one place — middleware OR route handler
// Check for duplicate: curl -I https://api.example.com/users
// If you see the header twice, find all places setting it and remove one
// ── Error 6: CORS error on redirect ──────────────────────────────────
// Browser: "Redirect from 'https://api.example.com/users' to
// 'https://api.example.com/users/' has been blocked by CORS"
// Cause: Server returns 301/302 redirect; redirect response lacks CORS headers
// Fix A: Avoid the redirect — ensure the URL matches exactly (trailing slash)
// Fix B: Add CORS headers to the redirect response itself
// ── Error 7: Preflight response has wrong status ──────────────────────
// Cause: OPTIONS handler returns 200 with a body, some proxies reject non-204
// Fix: Return 204 No Content for OPTIONS (no body)
if (req.method === 'OPTIONS') {
res.status(204).end(); // NOT res.status(200).json({})
}
// ── Debugging checklist ────────────────────────────────────────────────
// 1. DevTools > Network > find the OPTIONS request (if any)
// - If missing: preflight was cached or not triggered (check Content-Type)
// - If present but 404/405: OPTIONS not handled on server
// - If present but wrong headers: fix the OPTIONS handler response
// 2. Check the ACTUAL request (after OPTIONS): does it have CORS headers?
// 3. curl -X OPTIONS -H "Origin: https://app.example.com" // -H "Access-Control-Request-Method: POST" // -H "Access-Control-Request-Headers: Content-Type" // -v https://api.example.com/users
// — inspect the response headers directly, bypassing browser cachingThe curl command at the bottom is invaluable for debugging — it lets you inspect the raw preflight response without browser caching or DevTools indirection. If curl shows the correct headers but the browser still blocks the request, check for a proxy or CDN between the browser and server that may be stripping or modifying response headers. For safe JSON parsing in TypeScript for API responses, see the linked guide.
Key Terms
- CORS (Cross-Origin Resource Sharing)
- A browser security mechanism that restricts cross-origin HTTP requests initiated from JavaScript. By default, browsers block responses to cross-origin requests unless the server explicitly permits them through CORS response headers. CORS is enforced by the browser — the server receives the request regardless, but the browser hides the response from JavaScript if CORS headers are absent or incorrect. CORS does not protect the server from requests; it protects the user from malicious scripts on one origin accessing data from another origin without permission. The mechanism uses
Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers, andAccess-Control-Allow-Credentialsresponse headers to communicate the server's permissions. - Preflight Request
- An automatic HTTP
OPTIONSrequest that browsers send before any non-simple cross-origin request to verify the server permits it. The browser includes three request headers:Origin(the requesting origin),Access-Control-Request-Method(the intended HTTP method), andAccess-Control-Request-Headers(the custom request headers). The server must respond with matchingAccess-Control-Allow-*headers. If the preflight fails — wrong status, missing headers, or mismatched values — the browser blocks the actual request without sending it. Preflight results can be cached by settingAccess-Control-Max-Age, eliminating the round-trip overhead for repeat callers within the cache duration. - Simple Request
- A cross-origin request that browsers send directly without a preflight, because it matches criteria that predate CORS and were already permitted by earlier browser security models. A request is simple if it uses GET, HEAD, or POST; does not include custom headers beyond a safe set (
Accept,Accept-Language,Content-Language,Content-Type); and ifContent-Typeis present, it must be one oftext/plain,application/x-www-form-urlencoded, ormultipart/form-data. HTML form submissions use these content types — that is why forms can POST cross-origin without CORS configuration. SettingContent-Type: application/jsonimmediately exits the simple request category and triggers a preflight. Similarly, adding anyAuthorizationor custom header triggers preflight regardless of the method. - Credentialed Request
- A cross-origin request that includes credentials — cookies, HTTP authentication headers, or TLS client certificates. To make a credentialed request, the client must set
credentials: 'include'in fetch options orwithCredentials = trueon XHR. The CORS specification applies stricter rules to credentialed requests: the server'sAccess-Control-Allow-Originheader must be an explicit origin (never*), andAccess-Control-Allow-Credentials: truemust be present in both the preflight and actual responses. Cookies sent cross-origin must also be configured withSameSite=None; Securefor browsers to include them. These restrictions exist to prevent malicious scripts from silently reading authenticated API responses. - Origin
- The combination of scheme, host, and port that identifies where a request originates. The format is
scheme://host:port— for example,https://app.example.com(port 443 implicit) orhttp://localhost:3000. Two URLs have the same origin only if all three components match exactly:https://example.comandhttp://example.comare different origins (different scheme);https://app.example.comandhttps://www.example.comare different origins (different host);https://example.com:443andhttps://example.com:8443are different origins (different port). CORS origin matching is always exact — there is no wildcard subdomain support in theAccess-Control-Allow-Originheader itself; dynamic server-side validation with a regex is required for subdomain matching. - Access-Control-Max-Age
- A CORS response header returned by the server in a preflight (
OPTIONS) response, specifying how many seconds the browser may cache the preflight result for that (origin, method, headers) combination. While cached, subsequent requests with the same parameters skip the preflight entirely, eliminating the extra HTTP round trip. The value is server-specified, but browsers enforce their own caps: Chrome caches at most 7200 seconds (2 hours); Firefox respects up to 86400 seconds (24 hours); Safari caps at 600 seconds (10 minutes). SettingAccess-Control-Max-Age: 86400maximizes cache duration across all browsers within their individual caps. The cache is invalidated if any of the origin, method, or request headers set changes. - CORS Wildcard
- The value
*(asterisk) used inAccess-Control-Allow-Origin: *, which permits any origin to read the response. The wildcard is appropriate for fully public, unauthenticated APIs — for example, a public JSON data feed or an open REST API without user-specific data. The wildcard is explicitly forbidden when the request includes credentials (credentials: 'include'orwithCredentials: true) — the browser will block the response even ifAccess-Control-Allow-Credentials: trueis also present. Similarly,Access-Control-Allow-Headers: *does not include theAuthorizationheader — you must listAuthorizationexplicitly. For any API that handles authenticated users or sensitive data, replace the wildcard with an explicit origin allowlist and dynamic reflection.
FAQ
Why does my JSON API get a CORS error but my form POST doesn't?
The difference is whether the request qualifies as a CORS "simple request." HTML form submissions use application/x-www-form-urlencoded or multipart/form-data — content types the CORS spec explicitly allows without a preflight check. When you use fetch() with Content-Type: application/json, the browser must send an OPTIONS preflight first to confirm the server allows it. If your server doesn't handle OPTIONS or doesn't return the correct CORS headers, the JSON request is blocked while the form POST was never checked. The fix is entirely server-side: configure your server to respond to OPTIONS preflight requests with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers.
How do I allow CORS for multiple origins in Express?
Use the cors npm package with a custom origin function: app.use(cors({ origin: (origin, cb) => { const allowed = ["https://a.com", "https://b.com"]; cb(null, !origin || allowed.includes(origin)); } })); The !origin check allows server-to-server requests without an Origin header. For regex subdomain matching: /^https:\/\/[a-z0-9-]+\.example\.com$/.test(origin). Never echo the Origin header blindly without validation — that defeats CORS entirely. Set Vary: Origin when reflecting dynamic origins so caches don't serve the wrong origin to other callers.
Why does withCredentials fail with Access-Control-Allow-Origin: *?
The CORS specification explicitly forbids the wildcard * combined with credentialed requests. When credentials: 'include' (or withCredentials: true) is set, the server must return an explicit origin: Access-Control-Allow-Origin: https://app.example.com — not *. Additionally, Access-Control-Allow-Credentials: true must appear on both the preflight and the actual response. Both requirements must be met simultaneously. The fix: validate the incoming Origin header against your allowlist and reflect it back explicitly rather than using the wildcard.
What is a preflight request and how do I reduce the overhead?
A preflight is an automatic OPTIONS request the browser sends before any non-simple cross-origin request. It adds one full HTTP round trip — typically 50–200 ms — before the actual request can fire. Reduce the overhead by setting Access-Control-Max-Age: 86400 in the preflight response, which caches the result for 24 hours (Chrome caps at 7200 s). After the first preflight, subsequent requests with the same method and headers from the same origin skip OPTIONS entirely. Handle OPTIONS before any heavy middleware (auth, body parsing) to minimize server processing time for each uncached preflight.
How do I configure CORS in Next.js App Router middleware?
Create a middleware.ts file at the project root. Inside, check the origin header against your allowlist, handle OPTIONS by returning a 204 immediately, and attach CORS headers to all other responses via NextResponse.next(). Export a config object with matcher: '/api/:path*' to scope the middleware to API routes only. This is cleaner than exporting an OPTIONS function from every route file — new routes automatically inherit CORS without any extra work.
Can I disable CORS in development but enable it in production?
Yes. In Express with the cors package, set origin: true when process.env.NODE_ENV !== 'production' — this reflects back whatever Origin the client sends, allowing all origins without the wildcard restriction (so credentialed requests also work). In production, switch to your explicit allowlist. In Next.js middleware, use const isAllowed = process.env.NODE_ENV !== 'production' || allowedOrigins.includes(origin);. Alternatively, keep a consistent allowlist and add http://localhost:3000 to a development-only environment variable. Never deploy origin: true or Access-Control-Allow-Origin: * to production if your API handles authenticated users.
Why does my CORS header appear in the response but the browser still blocks the request?
Several causes: (1) Credentials conflict — if credentials: 'include' is set on the client and the server returns Access-Control-Allow-Origin: *, the browser blocks it even though the header is present. Use an explicit origin and add Access-Control-Allow-Credentials: true. (2) Duplicate headers — if middleware and route handler both set the header, browsers receive a comma-separated double value and reject it. Check with curl -I to see raw headers. (3) Missing header on preflight — you may be returning CORS headers on actual responses but not on the OPTIONS preflight response the browser checks first. (4) Proxy stripping headers — an nginx reverse proxy or CDN between browser and server may be stripping CORS response headers. Verify with curl directly against the origin server.
Further reading and primary sources
- MDN: Cross-Origin Resource Sharing (CORS) — Comprehensive MDN reference covering the CORS protocol, preflight requests, credentialed requests, and all CORS headers
- Fetch Living Standard: CORS protocol — The WHATWG Fetch specification defining the exact CORS algorithm browsers implement
- Express cors npm package — Official cors middleware for Express — configuration options, dynamic origin, and credentials support
- Next.js: Middleware — Next.js App Router middleware documentation — matcher patterns, response modification, and request interception
- Will It CORS? — Interactive CORS decision tool — input your request parameters and see whether a preflight will fire and what headers are required