env.dev

CORS: The Complete Guide to Cross-Origin Resource Sharing

Everything you need to know about CORS: headers, preflight requests, credentialed requests, debugging techniques, third-party scripts (Google Ads, Cloudflare, PostHog), and framework setup for Next.js and TanStack Start.

Last updated:

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.

Simple CORS flow
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 response

CORS Headers Reference

Response HeaderPurposeExample
Access-Control-Allow-OriginWhich origin(s) can accesshttps://app.example.com or *
Access-Control-Allow-MethodsAllowed methods (preflight)GET, POST, PUT, DELETE
Access-Control-Allow-HeadersAllowed request headers (preflight)Content-Type, Authorization
Access-Control-Allow-CredentialsAllow cookies/authtrue
Access-Control-Expose-HeadersWhich response headers JS can readX-Request-Id, X-Total-Count
Access-Control-Max-AgePreflight cache duration (seconds)86400
Request Header (browser-set)Purpose
OriginThe requesting origin (always sent on cross-origin requests)
Access-Control-Request-MethodMethod for upcoming request (preflight only)
Access-Control-Request-HeadersCustom 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:

CriteriaSimple (no preflight)Triggers preflight
MethodGET, HEAD, POSTPUT, DELETE, PATCH
Content-Typeapplication/x-www-form-urlencoded, multipart/form-data, text/plainapplication/json and any other value
HeadersAccept, Accept-Language, Content-Language, Content-Type (above), RangeAuthorization, 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?

Preflight request (browser sends automatically)
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, Authorization
Preflight response (server must return)
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 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:

Client-side
fetch('https://api.example.com/me', {
  credentials: 'include', // Send cookies cross-origin
});
Required server response headers
Access-Control-Allow-Origin: https://app.example.com   # Must be exact origin, NOT *
Access-Control-Allow-Credentials: true                  # Required
Vary: Origin                                            # Required for correct caching

The 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:

Node.js / Express
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();
});
Cloudflare Worker / any Web API runtime
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.

next.config.js
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).

app/api/data/route.ts
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.

middleware.ts
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:

app.config.ts
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:

app/middleware.ts
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:

app/routes/api/data.ts
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 crossorigin to Google script tags — Google's servers don't always return Access-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 on window.onerror stack traces
  • CSP (Content-Security-Policy) is separate from CORS — you need to allowlist Google domains in your CSP script-src directive 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-Origin based on the request origin, you must include Vary: 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 _headers file 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:

ValueSends credentialsUse case
(omitted)N/A — no CORSDefault: opaque load, no error details
anonymousNoFull error stack traces, canvas usage, SRI
use-credentialsYes (cookies)Authenticated resource loads
Enable full error reporting for your own cross-origin scripts
<!-- 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:

text
# 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 OPTIONS request exists and returned 2xx
  • Check if the response includes Access-Control-Allow-Origin matching your origin
  • Check if Access-Control-Allow-Headers includes all headers your request sends
  • If using credentials, verify Access-Control-Allow-Credentials: true is 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:

Test preflight
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"
Test actual request
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 patternFix
No ACAO header presentServer isn't setting CORS headers — add them
ACAO wildcard with credentialsEcho the specific origin instead of *
Preflight doesn't have OK statusHandle OPTIONS method and return 204
Header not allowed by ACAHAdd the missing header to Access-Control-Allow-Headers
Method not allowed by ACAMAdd the method to Access-Control-Allow-Methods
Works in curl but not browserCDN caching — add Vary: Origin
Works locally but not in prodCheck 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 Origin header back without checking it against a list
  • Always include Vary: Origin when dynamically setting ACAO
  • Handle OPTIONS explicitly and return 204 No Content with 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

text
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: Origin

Frequently 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