Skip to content
Chapter 12Lesson 3

CORS and the preflight request

CORS, the server response headers that decide which origins a browser will let read a cross-origin response.

The last lesson left an open question. The same-origin policy doesn’t stop a cross-origin request from being sent: the browser sends it and lets the response come back, but refuses to let the page read that response. On the way out it attaches an Origin header that asks the server, on the page’s behalf, who is calling? This lesson is the answer.

That answer is CORS , the protocol a server uses to say which origins may read a response. By the end you’ll be able to look at any fetch and predict whether it triggers a second hidden request, name the four headers a production server sends, avoid the most common CORS bug, and read the red console error well enough to know which header to fix.

One thing up front: a same-origin app calling its own backend, such as your web app fetching from the domain it was served from, uses none of CORS. No headers, no preflight, nothing. CORS engages only when a request crosses origins, to an API subdomain, a third-party API, a browser extension, or another site calling your app.

CORS is enforced by the browser, configured on the server

Section titled “CORS is enforced by the browser, configured on the server”

The browser is the enforcer: it blocks the read when the rules aren’t met. The server is the authority: it decides, header by header, which origins are allowed and what they may do. The Access-Control-* response headers are the contract between them. Your client fetch never sets one, because those are response headers, and the client sends requests, not responses.

This is why a CORS error tempts people to the wrong file. It surfaces in the browser console, on the client, but it is only the browser reporting that the server’s answer fell short. The fix is always on the server.

CORS comes in two shapes, and the rest of the lesson turns on the difference. In a simple request , the browser sends the request straight away, then checks the response headers to decide whether the page may read the body. In a preflighted request , it asks permission first: it sends a separate OPTIONS request, waits for the server to authorize the real request’s method and headers, and only then sends the real one. The next two sections take each in turn.

A request is “simple” and goes out directly only when every row below holds at once. Break any one and the browser preflights.

DimensionSimple (no preflight)Anything else preflights
MethodGET, HEAD, POSTPUT, PATCH, DELETE, …
Headersonly CORS-safelisted: Accept, Accept-Language, Content-Language, Content-Type, Rangeany other header (e.g. Authorization)
Content-Typeapplication/x-www-form-urlencoded, multipart/form-data, text/plainapplication/json, anything else

The table collapses into one rule that covers nearly every request your app makes:

application/json is not safelisted: the only simple Content-Type values are the three a plain HTML <form> can produce, so sending JSON triggers a preflight. An Authorization header does the same, since it isn’t safelisted either. Between JSON bodies and auth tokens, that covers the entire authenticated API surface of a typical app.

What stays simple is the embed-style web: a <form> POST sending multipart/form-data, or an image beacon firing a GET. For anything your app does deliberately with fetch, assume preflight.

For each statement, decide whether the request preflights.

For each fetch, decide whether the browser preflights it. (Assume each is cross-origin.) Mark each statement True or False.

A GET request with no custom headers and no body does not preflight.

GET is a simple method and there are no non-safelisted headers, so it qualifies as a simple request. The browser sends it directly and checks the response headers afterward.

A POST request with Content-Type: application/json preflights.

application/json is not a CORS-safelisted content type, so the request leaves the simple set and the browser sends an OPTIONS first. This is nearly every API call your UI makes.

A GET request carrying an Authorization: Bearer … header preflights.

The method is simple, but Authorization is not a safelisted header. Any non-safelisted header forces a preflight, so token-bearing calls always preflight too.

Your JSON POST preflights: before the real request goes out, the browser runs a permission check your JavaScript never sees.

%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 19px !important; } .actor, .actor tspan { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 16px !important; }'} }%%
sequenceDiagram
    participant P as Page (JS)
    participant B as Browser
    participant S as Server<br/>api.acme.com

    P->>B: fetch(POST /invoices, Content-Type: application/json)
    Note over B: JSON Content-Type → not simple → must preflight first

    rect rgba(56, 189, 248, 0.12)
        Note over B,S: Preflight — a separate OPTIONS your code never wrote
        B->>S: OPTIONS /invoices · Origin: https://app.acme.com<br/>Access-Control-Request-Method: POST<br/>Access-Control-Request-Headers: content-type
        S-->>B: 204 No Content · Access-Control-Allow-Origin: https://app.acme.com<br/>Access-Control-Allow-Methods: POST · Access-Control-Allow-Headers: content-type
    end

    Note over B: Allow-* headers cover the request → authorized

    rect rgba(34, 197, 94, 0.12)
        Note over B,S: The real request — only now does it fire
        B->>S: POST /invoices · Origin: https://app.acme.com + JSON body
        S-->>B: 200 OK + body · Access-Control-Allow-Origin: https://app.acme.com
    end

    B->>P: resolve the fetch promise with the response body

    Note over B,S: If the preflight is NOT authorized (wrong origin, missing method or header), the browser<br/>cancels the real request — the POST never fires — and the fetch promise rejects with a generic<br/>TypeError. The actionable reason appears only in the console.
One fetch, two round trips. The browser runs a preflight OPTIONS and sends your real POST only once the Access-Control-Allow-* headers authorize it; if they don't, the POST never fires.

The OPTIONS/204 pair in the blue band is the preflight: a separate request your code never wrote and never sees, in which the browser previews what it’s about to do. Access-Control-Request-Method announces the method, Access-Control-Request-Headers lists the non-safelisted headers, and the server replies with the matching Access-Control-Allow-* headers. Only when that reply covers the request does the browser send the real POST in the green band.

The failure case is where people get stuck. If the preflight response doesn’t authorize the request, the browser cancels the real request before sending it: the server never sees your POST, and your fetch promise rejects with a generic, unhelpful TypeError. The real reason prints separately, in red, in the console, which the next section teaches you to read.

The preflight is a real, separate network request, not a metaphor. Open the Network panel and you’ll see two rows: an OPTIONS, then your POST.

Network Fetch/XHR app.acme.com → api.acme.com
Name
Method
Status
Time
Waterfall
invoices
OPTIONS
pending
One fetch(POST) call — the preflight round trip happens first.
Your code calls fetch. DevTools shows one row: the preflight OPTIONS, still pending.
Network Fetch/XHR app.acme.com → api.acme.com
Name
Method
Status
Time
Waterfall
invoices
OPTIONS
204
38 ms
One fetch(POST) call — the preflight round trip happens first.
The server answers the preflight with 204 and the Access-Control-Allow-* headers. The OPTIONS resolves.
Network Fetch/XHR app.acme.com → api.acme.com
Name
Method
Status
Time
Waterfall
invoices
OPTIONS
204
38 ms
invoices
POST
pending
Still one fetch(POST) call — two rows on the wire.
The headers authorized the request, so the browser fires the real POST. A second row appears.
Network Fetch/XHR app.acme.com → api.acme.com
Name
Method
Status
Time
Waterfall
invoices
OPTIONS
204
38 ms
invoices
POST
200
120 ms
Still one fetch(POST) call — two rows on the wire.
The POST returns 200 with the body. Two rows for one fetch call: the preflight made visible.

Once you’ve seen those two rows, debugging a cross-origin call starts with one question: which row failed, the OPTIONS or the real one? That alone narrows the bug down fast.

To let a cross-origin page read a response, a production server sends up to four Access-Control-* response headers. Each one prevents a specific failure when it’s present.

HeaderWhat it does / failure modeExample
Access-Control-Allow-OriginNames which origin may read the response. Missing → the browser blocks the read with the canonical “No ‘Access-Control-Allow-Origin’ header” error.https://app.acme.com (the exact origin, echoed back; * works only without credentials, as the trap below explains)
Access-Control-Allow-Methods(preflight only) Lists the methods the real request may use. Missing → a PUT, PATCH, or DELETE is rejected at the preflight.GET, POST, PUT, DELETE, PATCH
Access-Control-Allow-Headers(preflight only) Lists the headers the real request may send. Missing → any non-safelisted header trips the preflight, including a JSON Content-Type or an Authorization token.content-type, authorization
Access-Control-Allow-CredentialsMakes the response readable when the request carries credentials; pairs with the client’s credentials: 'include'. Missing → cookies and auth aren’t usable, and the credentialed response is blocked.true

Two more headers matter less often:

  • Access-Control-Max-Age: 86400 caches the preflight result so the browser stops sending an OPTIONS before every call; without it, every JSON request pays for two round trips. Chrome caps this at 7200 seconds (2 hours) and Firefox at 86400 (24 hours); any larger value is silently clamped.
  • Access-Control-Expose-Headers: X-Total-Count, X-Page lets your JavaScript read response headers outside the default set (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma). The classic case is pagination: you return the total in X-Total-Count, and without exposing that header the browser hides it from your code even though it arrived.

Wildcard origins are illegal with credentials

Section titled “Wildcard origins are illegal with credentials”

This is the most common production CORS bug.

Access-Control-Allow-Origin: * is legal only when the request is not credentialed. A credentialed request is one where the client set credentials: 'include', telling the browser to attach the user’s cookies. Answer one with * and the browser refuses the response: “…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’.”

* means anyone, and letting anyone read a response carrying the user’s cookies would hand every site the keys to that session. The fix is to drop the wildcard and name the caller exactly.

app/api/invoices/route.ts
export const GET = async (req: Request) => {
const invoices = await listInvoices();
return Response.json(invoices, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': 'true',
},
});
};

Blocked the moment the client sends credentials: 'include'. Wildcard plus credentials is the one combination the browser refuses.

Almost everything in CORS lives on the server. Your code never sets an Access-Control-* header; these three fetch options are the only client-side controls that touch CORS, and each defaults to the right pick for a first-party app.

OptionDefaultWhat to know
mode'cors'The only useful value for cross-origin. 'same-origin' rejects cross-origin requests outright; 'no-cors' sends the request but hands you an opaque response you can’t read.
credentials'same-origin'Cookies attach only on same-origin requests. 'include' attaches them cross-origin too, and requires Access-Control-Allow-Credentials: true on the server. 'omit' never attaches.
Origin header(browser-set)The browser writes it automatically; your code can never set or forge it. A server can validate it but can’t trust it, since curl or a script can send any Origin it likes.

When a cross-origin call fails the CORS check, your fetch promise rejects with TypeError: Failed to fetch: no origin, no header, no clue. The browser prints the real reason as a separate red message in the console, not in the error your code catches.

Reading that red string is the skill. There are only a handful of canonical messages, and each maps to a fix from this lesson.

Each red console string points at one server-side fix. Match the error to the change that resolves it. Click an item on the left, then its match on the right. Press Check when done.

No Access-Control-Allow-Origin header is present on the requested resource.
The server didn’t send the header at all — add Access-Control-Allow-Origin in the route handler.
…must not be the wildcard '*' when the request’s credentials mode is 'include'.
The wildcard-with-credentials trap — validate and echo the exact origin, and add Vary: Origin.
Response to preflight request doesn’t pass access control check: It does not have HTTP ok status.
The OPTIONS handler returned a non-2xx status — return 204 with the CORS headers.
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.
Add authorization (or whichever header is named) to Access-Control-Allow-Headers.

Going from symptom to fix is most of the debugging in production. Every fix is a server change, made where you meet the error: the console.

You won’t write a Next.js backend until Unit 4, so read this to recognize the shape, not to build it.

The pattern keeps the allow-list next to the route it guards, in two files: a lib/cors.ts that holds the policy, and the Route Handler that uses it.

lib/cors.ts
const allowedOrigins = new Set(['https://app.acme.com']);
export const corsHeaders = (origin: string | null): HeadersInit => {
const allowOrigin = origin && allowedOrigins.has(origin) ? origin : '';
return {
'Access-Control-Allow-Origin': allowOrigin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'content-type, authorization',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '7200',
Vary: 'Origin',
};
};

The policy in one place. Check the origin against the allow-list, echo it back, and pack the four headers plus Vary: Origin and a modest Max-Age. Every route that needs CORS reads from this function.

Two things to lock in from that handler.

First, the OPTIONS export is the preflight handler. When the browser’s preflight arrives, it answers 204 with an empty body and the four headers. Forget to export OPTIONS, or return a non-2xx, and you get canonical error #3, “Response to preflight request… does not have HTTP ok status.”

Second, the allow-list lives per-route, not app-wide. Next.js can set headers globally in next.config.ts, but which origins may read invoices is a property of the invoices route, not the whole app, so the policy belongs next to that route.

CORS only enters the picture when a browser makes a cross-origin request and wants to read the response. Many common situations don’t meet that bar, and mistaking one for a CORS problem sends you debugging the wrong layer.

Same-origin app → its own backend

No CORS, no headers, nothing. Your web app fetching from the domain that served it is same-origin, the monolith default and this course’s normal case.

API subdomain

app.acme.com calling api.acme.com is cross-origin (different host) but same-site (same registrable domain). CORS is required, yet SameSite=Lax cookies still travel, because the site matches.

Server-to-server fetch

A Next.js Route Handler or Server Action calling a third-party API has no browser, no Origin header, and no CORS check at all. This is the escape hatch: when a third party ships no CORS, fetch it from your server, not the client.

Reverse proxy / rewrites

A Next.js rewrites() rule exposes a third party under your own origin, so the call turns same-origin and CORS disappears, at the cost of a network hop. Reach for this when a full server proxy is overkill.

Browser extension / null origin

Content scripts send Origin: null. With no exact origin to echo, you must decide deliberately whether to allow-list null or refuse it.

The full set of headers and the exact error strings are reference material, and MDN is the canonical source for both.