CORS (Cross-Origin Resource Sharing) is an HTTP-header-based mechanism that allows a server to declare which origins — other than its own — are permitted to load its resources. Browsers enforce the Same-Origin Policy by default, blocking cross-origin fetch() and XMLHttpRequest calls unless the server explicitly opts in via CORS headers. CORS errors are the single most common class of network-related bugs in frontend development — one 2023 Stack Overflow analysis found CORS questions account for over 40,000 posts, making it the most-asked HTTP topic on the platform.
How Does CORS Work?
When a browser makes a cross-origin request, it adds an Origin header. The server responds with Access-Control-Allow-Origin (and potentially other CORS headers). The browser then decides whether JavaScript can access the response. If the headers are missing or don't match, the browser blocks the response — the request still reaches the server, but JavaScript cannot read the result.
Browser Server
│ │
│ GET /api/data │
│ Origin: https://app.example.com │
│ ──────────────────────────────────────► │
│ │
│ 200 OK │
│ Access-Control-Allow-Origin: https://app.example.com
│ ◄────────────────────────────────────── │
│ │
✓ Browser allows JS to read responseCORS Headers Reference
| Response Header | Purpose | Example |
|---|---|---|
| Access-Control-Allow-Origin | Which origin(s) can access | https://app.example.com or * |
| Access-Control-Allow-Methods | Allowed methods (preflight) | GET, POST, PUT, DELETE |
| Access-Control-Allow-Headers | Allowed request headers (preflight) | Content-Type, Authorization |
| Access-Control-Allow-Credentials | Allow cookies/auth | true |
| Access-Control-Expose-Headers | Which response headers JS can read | X-Request-Id, X-Total-Count |
| Access-Control-Max-Age | Preflight cache duration (seconds) | 86400 |
| Request Header (browser-set) | Purpose |
|---|---|
| Origin | The requesting origin (always sent on cross-origin requests) |
| Access-Control-Request-Method | Method for upcoming request (preflight only) |
| Access-Control-Request-Headers | Custom headers for upcoming request (preflight only) |
Simple Requests vs Preflight Requests
Not all cross-origin requests trigger a preflight. The browser skips the OPTIONS check for "simple" requests that meet all of these criteria:
| Criteria | Simple (no preflight) | Triggers preflight |
|---|---|---|
| Method | GET, HEAD, POST | PUT, DELETE, PATCH |
| Content-Type | application/x-www-form-urlencoded, multipart/form-data, text/plain | application/json and any other value |
| Headers | Accept, Accept-Language, Content-Language, Content-Type (above), Range | Authorization, X-Custom-*, or any other non-safelisted header |
Key insight: Most modern API calls use Content-Type: application/json or an Authorization header, so they always trigger a preflight. Your server must handle OPTIONS requests.
What Does a Preflight Look Like?
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, AuthorizationHTTP/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: 86400Only after the preflight succeeds does the browser send the actual request. If the OPTIONS response is missing CORS headers or returns a non-2xx status, the actual request is never sent.
Credentialed Requests (Cookies & Auth)
By default, cross-origin fetch() does not send cookies. To include them, you must set credentials: 'include' and the server must return specific headers:
fetch('https://api.example.com/me', {
credentials: 'include', // Send cookies cross-origin
});Access-Control-Allow-Origin: https://app.example.com # Must be exact origin, NOT *
Access-Control-Allow-Credentials: true # Required
Vary: Origin # Required for correct cachingThe wildcard trap: When credentials: 'include' is used, the server cannot use Access-Control-Allow-Origin: *. It must echo the exact requesting origin. This also applies to Access-Control-Allow-Headers and Access-Control-Allow-Methods — wildcards are treated literally (as the string "*") when credentials are involved.
Setting CORS Headers Manually
Here is a complete, production-ready CORS handler pattern for any HTTP server:
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
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');
res.setHeader('Access-Control-Max-Age', '86400');
}
// Handle preflight
if (req.method === 'OPTIONS') {
res.status(204).end();
return;
}
next();
});const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
function corsHeaders(request: Request): Headers {
const origin = request.headers.get('Origin') ?? '';
const headers = new Headers();
if (ALLOWED_ORIGINS.has(origin)) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Vary', 'Origin');
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
headers.set('Access-Control-Allow-Credentials', 'true');
headers.set('Access-Control-Max-Age', '86400');
}
return headers;
}
export default {
async fetch(request: Request): Promise<Response> {
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders(request) });
}
const response = await handleRequest(request);
// Append CORS headers to actual response
const cors = corsHeaders(request);
for (const [key, value] of cors) {
response.headers.set(key, value);
}
return response;
},
};CORS in Next.js
Next.js offers three approaches depending on your needs:
Approach 1: next.config.js headers (static)
Best for public APIs where you want * or a fixed origin. Applied at the routing layer before your code runs.
module.exports = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
],
},
];
},
};Approach 2: Route Handler (dynamic origin)
Best when you need to validate the origin dynamically (e.g., credentialed requests).
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
function getCorsHeaders(request: Request) {
const origin = request.headers.get('origin') ?? '';
if (!ALLOWED_ORIGINS.has(origin)) return {};
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
};
}
export async function OPTIONS(request: Request) {
return new Response(null, { status: 204, headers: getCorsHeaders(request) });
}
export async function GET(request: Request) {
const data = { message: 'Hello' };
return Response.json(data, { headers: getCorsHeaders(request) });
}Approach 3: Middleware (global)
Best when you want CORS on all routes or complex matching logic.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
]);
export function middleware(request: NextRequest) {
const origin = request.headers.get('origin') ?? '';
const isAllowed = ALLOWED_ORIGINS.has(origin);
// Handle preflight
if (request.method === 'OPTIONS') {
return new NextResponse(null, {
status: 204,
headers: isAllowed
? {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
Vary: 'Origin',
}
: {},
});
}
const response = NextResponse.next();
if (isAllowed) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
response.headers.set('Vary', 'Origin');
}
return response;
}
export const config = {
matcher: '/api/:path*',
};CORS in TanStack Start
TanStack Start runs on Vinxi (which uses H3/Nitro under the hood). You have two approaches:
Approach 1: Vinxi route rules (declarative)
The simplest option — add route rules directly in your Vite config:
import { defineConfig } from '@tanstack/react-start/config';
export default defineConfig({
server: {
routeRules: {
'/api/**': {
cors: true,
headers: {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
},
},
},
});Approach 2: Middleware (dynamic)
For dynamic origin validation or credentialed requests:
import { createMiddleware } from '@tanstack/react-start';
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
export const corsMiddleware = createMiddleware().server(async ({ request, next }) => {
const origin = request.headers.get('origin') ?? '';
const isAllowed = ALLOWED_ORIGINS.has(origin);
// Handle preflight
if (request.method === 'OPTIONS' && isAllowed) {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
},
});
}
const result = await next();
if (isAllowed) {
result.headers.set('Access-Control-Allow-Origin', origin);
result.headers.set('Access-Control-Allow-Credentials', 'true');
result.headers.set('Vary', 'Origin');
}
return result;
});Approach 3: H3 utilities (programmatic)
Since TanStack Start uses H3, you can use its built-in CORS utilities in API routes:
import { handleCors } from 'h3';
export default defineEventHandler((event) => {
// handleCors returns true for preflight (OPTIONS) — early return
if (handleCors(event, {
origin: ['https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
maxAge: 86400,
})) {
return;
}
return { message: 'Hello' };
});CORS with Third-Party Scripts
Third-party scripts introduce unique CORS challenges. Here are the most common scenarios:
Google Ads & Google Tag Manager
Google scripts are served from googletagmanager.com, googlesyndication.com, and googleadservices.com. Key rules:
- Do not add
crossoriginto Google script tags — Google's servers don't always returnAccess-Control-Allow-Origin, which will break loading entirely - Google Ads conversion tracking uses iframes, creating cross-origin contexts — these are managed by Google's SDK and don't require your CORS configuration
- If you need error tracking on Google scripts, use
window.addEventListener('error')instead of relying onwindow.onerrorstack traces - CSP (
Content-Security-Policy) is separate from CORS — you need to allowlist Google domains in your CSPscript-srcdirective independently
PostHog & Analytics SDKs
Analytics tools like PostHog, Segment, and Mixpanel send events to their own endpoints:
- These SDKs handle CORS internally — their endpoints return appropriate
Access-Control-Allow-Origin: *headers - If you self-host PostHog, you must configure CORS on your instance to allow your app's origin
- Many analytics SDKs use
navigator.sendBeacon()for event dispatch, which avoids CORS entirely for simple POST payloads - For error tracking to capture full stack traces from your scripts loaded cross-origin, add
crossorigin="anonymous"to your own script tags (not third-party ones)
Cloudflare
- Cloudflare's CDN caches responses. If you dynamically set
Access-Control-Allow-Originbased on the request origin, you must includeVary: Origin— otherwise Cloudflare may serve a cached response with the wrong origin to a different requester - Cloudflare Workers can add CORS headers programmatically (see the Worker example above)
- Cloudflare Pages projects can use a
_headersfile for static CORS headers - Cloudflare Turnstile (CAPTCHA) and Access challenges can trigger unexpected CORS errors in SPAs — these are managed by Cloudflare's client-side JS and don't need your CORS config
Fonts
Browsers enforce CORS for @font-face loads. If you serve fonts from a CDN or different subdomain, the font server must return Access-Control-Allow-Origin: *. Google Fonts handles this automatically. For self-hosted fonts on a CDN, add the header to your CDN configuration.
The crossorigin HTML Attribute
The crossorigin attribute on HTML elements controls whether CORS is used when loading the resource:
| Value | Sends credentials | Use case |
|---|---|---|
| (omitted) | N/A — no CORS | Default: opaque load, no error details |
| anonymous | No | Full error stack traces, canvas usage, SRI |
| use-credentials | Yes (cookies) | Authenticated resource loads |
<!-- Your scripts on a CDN — add crossorigin to get stack traces in error tracking -->
<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>
<!-- Third-party scripts — usually do NOT add crossorigin -->
<script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"></script>
<!-- Fonts always require CORS -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter" crossorigin="anonymous" />
<!-- Images you'll draw to canvas -->
<img src="https://cdn.example.com/photo.jpg" crossorigin="anonymous" />How to Debug CORS Errors
CORS errors are enforced by the browser. The server doesn't know CORS failed — it responded normally. This means debugging must happen on both sides.
Step 1: Read the browser console error
Browsers give detailed CORS error messages. Common ones:
# Missing header entirely
Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.
# Wildcard with credentials
Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header
must not be the wildcard '*' when the request's credentials mode is 'include'.
# Preflight failure
Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access control
check: It does not have HTTP ok status.Step 2: Inspect in DevTools Network tab
Open DevTools → Network tab. Look for both the OPTIONS preflight request and the actual request:
- Check if the
OPTIONSrequest exists and returned 2xx - Check if the response includes
Access-Control-Allow-Originmatching your origin - Check if
Access-Control-Allow-Headersincludes all headers your request sends - If using credentials, verify
Access-Control-Allow-Credentials: trueis present and the origin is not*
Step 3: Test the server directly with curl
Remove the browser from the equation to confirm the server is sending correct headers:
curl -X OPTIONS https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
-v 2>&1 | grep -i "access-control"curl https://api.example.com/data \
-H "Origin: https://app.example.com" \
-v 2>&1 | grep -i "access-control"If curl shows the correct headers but the browser still blocks, check for CDN caching issues (missing Vary: Origin).
Step 4: Common fixes
| Error pattern | Fix |
|---|---|
| No ACAO header present | Server isn't setting CORS headers — add them |
| ACAO wildcard with credentials | Echo the specific origin instead of * |
| Preflight doesn't have OK status | Handle OPTIONS method and return 204 |
| Header not allowed by ACAH | Add the missing header to Access-Control-Allow-Headers |
| Method not allowed by ACAM | Add the method to Access-Control-Allow-Methods |
| Works in curl but not browser | CDN caching — add Vary: Origin |
| Works locally but not in prod | Check if a proxy, CDN, or WAF strips CORS headers |
Common Pitfalls and Gotchas
1. Missing Vary: Origin
If you dynamically set Access-Control-Allow-Origin based on the request origin, you must include Vary: Origin in every response. Without it, CDNs and browser caches may serve a response with origin A's CORS header to a request from origin B, causing CORS failures.
2. OPTIONS not handled
Many frameworks and routers don't handle OPTIONS by default. If your preflight returns 404 or 405, the browser blocks the actual request. Always explicitly handle OPTIONS in your CORS middleware or route handlers.
3. Never allowlist the null origin
Sandboxed iframes and data: URLs send Origin: null. If your server allowlists null, any sandboxed page can access your API — this is a security vulnerability.
4. Max-Age browser caps
Access-Control-Max-Age tells browsers how long to cache preflight results. But browsers cap this: Chrome caps at 7200 seconds (2 hours), Firefox at 86400 seconds (24 hours). Setting higher values has no effect. The default (no header) is 5 seconds.
5. Opaque responses in no-cors mode
Using fetch(url, { mode: "no-cors" }) does not bypass CORS — it silences the error but returns an opaque response (status 0, empty body, no headers). Your JavaScript still can't read the data. The only use case is Service Worker caching of third-party resources.
6. Expose-Headers for custom response headers
By default, JavaScript can only read a small set of "CORS-safelisted" response headers (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma). Custom headers like X-Request-Id or X-Total-Count are invisible to JS unless you add Access-Control-Expose-Headers.
7. CORS is not security
CORS is a browser-only mechanism. It does not protect your API from server-to-server requests, curl, Postman, or mobile apps. Always authenticate and authorize requests independently of CORS. CORS prevents unauthorized browser-based JavaScript from reading responses — nothing more.
Best Practices
- Use an explicit allowlist — never reflect the
Originheader back without checking it against a list - Always include Vary: Origin when dynamically setting ACAO
- Handle OPTIONS explicitly and return
204 No Contentwith no body - Set Access-Control-Max-Age to reduce preflight requests (7200 is practical max for Chrome)
- Don't use * with credentials — echo the specific origin
- Expose only needed headers via Access-Control-Expose-Headers
- Restrict allowed methods — only list methods your API actually supports
- Implement CORS at the gateway/middleware level — not in every individual route handler
- Test CORS in CI — add curl-based tests that verify CORS headers in your deployment pipeline
CORS Decision Flowchart
Is your request cross-origin?
├── No → No CORS needed
└── Yes → Does it use only simple methods/headers/content-types?
├── Yes → Simple request (no preflight)
│ └── Server must return Access-Control-Allow-Origin
└── No → Preflight required
└── Server must handle OPTIONS and return:
├── Access-Control-Allow-Origin
├── Access-Control-Allow-Methods
└── Access-Control-Allow-Headers
Does the request include credentials?
├── No → Access-Control-Allow-Origin: * is fine
└── Yes → Server must:
├── Echo exact origin (not *)
├── Return Access-Control-Allow-Credentials: true
└── Include Vary: OriginFrequently Asked Questions
Why do I get CORS errors in development but not production?
In development, your frontend often runs on localhost:3000 and your API on localhost:8080 — different ports mean different origins. In production, a reverse proxy or same domain eliminates the cross-origin issue. Fix: configure your dev server to proxy API requests, or add localhost origins to your CORS allowlist.
Can I disable CORS?
CORS is enforced by the browser, not the server. You cannot disable it in production. During development only, you can use browser flags (chrome --disable-web-security), a proxy, or a browser extension — but never in production. The correct fix is always to configure the server to send the right CORS headers.
Why does my API work in Postman but fail in the browser?
Postman is not a browser and does not enforce the Same-Origin Policy. Browsers block cross-origin responses that lack CORS headers. If Postman works but the browser doesn't, your server is not returning CORS headers — add them.
Does Access-Control-Allow-Origin: * allow credentials?
Not for credentialed requests. When a request uses credentials mode (cookies or browser-managed HTTP authentication via fetch() with credentials: "include"), the browser requires the server to return the exact requesting origin — not the wildcard *. If the server responds with Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true, the browser blocks the response. Note that a JavaScript-set Authorization header does not by itself trigger this restriction — it is the credentials mode that matters.
How do I fix CORS for a third-party API I don't control?
If you cannot modify the API server, use a server-side proxy. Your frontend calls your own server, which forwards the request to the third-party API. Since server-to-server requests aren't subject to CORS, this bypasses the restriction entirely.
What is the difference between CORS and CSP?
CORS controls which origins can read responses from your server. CSP (Content-Security-Policy) controls which resources your page can load. They are independent mechanisms. A script can be allowed by CSP but blocked by CORS, or vice versa. You may need to configure both.
References
- MDN: Cross-Origin Resource Sharing (CORS) — comprehensive reference covering all CORS headers, request types, and error scenarios
- WHATWG Fetch Specification: CORS Protocol — the official specification that browsers implement
- MDN: Access-Control-Allow-Origin — detailed reference for the primary CORS response header
- MDN: Preflight Request — glossary entry explaining when and why preflight requests occur
- MDN: crossorigin Attribute — reference for the crossorigin HTML attribute on script, img, link, and other elements
- Chrome DevTools: Network Panel — guide to inspecting network requests and response headers for CORS debugging