CORS from First Principles
- What CORS actually is
- Why this only happens in browsers
- What an origin is
- The simplest CORS error
- The fix: one header
- Preflight requests
- What a preflight looks like
- Handling both pieces in Hectoday
- Doing it manually
- Origin matching
- The wildcard: `*`
- Credentials: cookies and auth headers
- Exposed headers
- Max-Age: caching preflight results
- Common CORS errors and what they mean
- The full picture
- Development vs production
- Summary
A guide for developers who keep getting CORS errors and don't know why. Every example uses @hectoday/http, but the ideas apply everywhere.
What CORS actually is
Your frontend lives at https://myapp.com. Your API lives at https://api.myapp.com. The browser considers these different origins because the subdomain is different.
By default, browsers block JavaScript from making requests to a different origin. This is the Same-Origin Policy. It exists to prevent malicious websites from making requests to your bank's API using your cookies.
CORS (Cross-Origin Resource Sharing) is how the server tells the browser: "it's okay, I expect requests from that origin."
It's not a security feature you add. It's a restriction the browser enforces that you selectively relax.
Why this only happens in browsers
CORS is a browser rule. It doesn't exist in:
- Server-to-server requests
curlor Postman- Mobile apps making HTTP calls
- Your test suite
This is why your API works perfectly in tests and from curl, but fails from the browser. The API is fine. The browser is blocking the response.
What an origin is
An origin is the combination of scheme, hostname, and port:
https://myapp.com → origin: https://myapp.com
https://myapp.com:3000 → origin: https://myapp.com:3000 (different)
http://myapp.com → origin: http://myapp.com (different)
https://api.myapp.com → origin: https://api.myapp.com (different)
https://myapp.com/page → origin: https://myapp.com (same, path doesn't matter)If any of the three parts differ, the browser treats it as cross-origin.
The simplest CORS error
Your frontend at https://myapp.com makes a fetch call:
const res = await fetch("https://api.myapp.com/users");The browser sends the request. The server responds with the data. But the browser looks at the response headers and doesn't find Access-Control-Allow-Origin. So it throws away the response and gives you:
Access to fetch at 'https://api.myapp.com/users' from origin 'https://myapp.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.The request succeeded. The server processed it. The response came back. The browser just refuses to show it to your JavaScript.
The fix: one header
The server needs to include this header in its response:
Access-Control-Allow-Origin: https://myapp.comThis tells the browser: "responses from this server are allowed to be read by JavaScript running on https://myapp.com."
In Hectoday, you set it in onResponse:
const app = setup({
routes: [...],
onResponse: ({ request, response }) => {
const headers = new Headers(response.headers);
headers.set("access-control-allow-origin", "https://myapp.com");
return new Response(response.body, { status: response.status, headers });
},
});That's it for simple GET requests. But most real APIs hit a second problem.
Preflight requests
For "simple" requests (GET with standard headers), the browser just sends the request and checks the response headers. But for anything more complex, the browser sends a preflight request first.
A preflight is an OPTIONS request that asks: "am I allowed to make this actual request?"
The browser sends a preflight when:
- The method is PUT, PATCH, DELETE (anything other than GET, HEAD, POST)
- The request includes headers like
AuthorizationorContent-Type: application/json - The content type is anything other than
text/plain,multipart/form-data, orapplication/x-www-form-urlencoded
In practice, almost every API request triggers a preflight because you're either sending JSON or an auth token.
What a preflight looks like
Your frontend sends:
const res = await fetch("https://api.myapp.com/users", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer token123",
},
body: JSON.stringify({ name: "Alice" }),
});Before sending the POST, the browser automatically sends:
OPTIONS /users HTTP/1.1
Host: api.myapp.com
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorizationThe browser is asking: "can I POST to /users with these headers?"
The server must respond:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type, authorizationOnly then does the browser send the actual POST request.
If the OPTIONS response is missing or doesn't allow the method/headers, the browser never sends the real request. You get a CORS error.
Handling both pieces in Hectoday
CORS is two things: preflight handling (OPTIONS) and response headers (every response). Hectoday's cors() helper makes both explicit:
import { setup, route, cors } from "@hectoday/http";
const { preflight, headers } = cors({
origin: "https://myapp.com",
methods: ["GET", "POST", "PUT", "DELETE"],
allowHeaders: ["Content-Type", "Authorization"],
maxAge: 86400,
});
const app = setup({
routes: [
preflight(route), // handles OPTIONS requests
...userRoutes,
],
onResponse: ({ request, response }) => headers(request, response),
});preflight(route) creates a catch-all OPTIONS /** route that responds with 204 and the configured CORS headers.
headers(request, response) adds the CORS headers to every response, including errors and 404s. Used in onResponse so nothing slips through.
Doing it manually
You don't need the helper. CORS is just headers. Here's the manual version:
The preflight handler:
route.options("/**", {
resolve: () =>
new Response(null, {
status: 204,
headers: {
"access-control-allow-origin": "https://myapp.com",
"access-control-allow-methods": "GET, POST, PUT, DELETE",
"access-control-allow-headers": "Content-Type, Authorization",
"access-control-max-age": "86400",
},
}),
});The response headers:
onResponse: ({ request, response }) => {
const headers = new Headers(response.headers);
headers.set("access-control-allow-origin", "https://myapp.com");
return new Response(response.body, { status: response.status, headers });
};That's everything the cors() helper does. Two places, two jobs.
Origin matching
Hardcoding a single origin works for simple setups. But if you have multiple allowed origins (staging, production, local dev), you need to check the request's Origin header and respond accordingly:
const allowedOrigins = new Set([
"https://myapp.com",
"https://staging.myapp.com",
"http://localhost:5173",
]);
onResponse: ({ request, response }) => {
const origin = request.headers.get("origin");
if (!origin || !allowedOrigins.has(origin)) return response;
const headers = new Headers(response.headers);
headers.set("access-control-allow-origin", origin);
headers.set("vary", "Origin");
return new Response(response.body, { status: response.status, headers });
};Two important details:
Access-Control-Allow-Origin must be a single origin, not a list. You check the request's Origin and echo back the matching one.
Vary: Origin tells caches that the response depends on which origin sent the request. Without it, a CDN might cache the response with one origin and serve it to another.
The wildcard: `*`
headers.set("access-control-allow-origin", "*");This allows any origin. Use it for truly public APIs with no authentication. It has one major restriction: you cannot use * with credentials: "include". The browser rejects it.
Credentials: cookies and auth headers
By default, cross-origin requests don't send cookies. If your API uses cookies for auth, the frontend must opt in:
fetch("https://api.myapp.com/users", {
credentials: "include",
});And the server must respond with:
Access-Control-Allow-Origin: https://myapp.com (NOT *)
Access-Control-Allow-Credentials: trueIn Hectoday:
const { preflight, headers } = cors({
origin: "https://myapp.com",
credentials: true,
});Three rules when using credentials:
Access-Control-Allow-Originmust be a specific origin, not*Access-Control-Allow-Credentials: truemust be present- The frontend must set
credentials: "include"on every fetch
If any of the three are missing, the browser blocks the response.
Exposed headers
By default, the browser only lets JavaScript read a small set of response headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma.
If your API returns custom headers (like X-Request-Id or X-Total-Count), the frontend can't read them unless the server exposes them:
const { preflight, headers } = cors({
origin: "https://myapp.com",
exposeHeaders: ["X-Request-Id", "X-Total-Count"],
});This adds Access-Control-Expose-Headers: X-Request-Id, X-Total-Count to responses.
Max-Age: caching preflight results
Every complex request triggers a preflight OPTIONS request before the real request. That's two HTTP calls for every API call. For performance, the server can tell the browser to cache the preflight result:
const { preflight, headers } = cors({
origin: "https://myapp.com",
maxAge: 86400, // 24 hours in seconds
});This adds Access-Control-Max-Age: 86400 to the preflight response. The browser caches the "yes, this is allowed" answer and skips the OPTIONS request for the next 24 hours (per URL).
Common CORS errors and what they mean
"No 'Access-Control-Allow-Origin' header"
The server isn't sending the Access-Control-Allow-Origin header. Add it to your responses.
"The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'"
You're using credentials: "include" but the server responds with Access-Control-Allow-Origin: *. Change * to the specific origin.
"Method PUT is not allowed by Access-Control-Allow-Methods"
The preflight response doesn't list PUT in the allowed methods. Add it to your CORS config.
"Request header field authorization is not allowed by Access-Control-Allow-Headers"
The preflight response doesn't list Authorization in the allowed headers. Add it.
"Response to preflight request doesn't pass access control check"
Your server isn't handling the OPTIONS request at all, or it's returning an error. Make sure you have a preflight handler.
The full picture
Browser Server
│ │
│ Can I POST with these headers? │
│ ──── OPTIONS /users ────────────> │
│ │
│ Yes, here are the rules │
│ <──── 204 + CORS headers ─────── │
│ │
│ OK, here's the real request │
│ ──── POST /users ──────────────> │
│ │
│ Here's the data + CORS headers │
│ <──── 201 + body ─────────────── │
│ │The OPTIONS request is invisible to your JavaScript. The browser handles it automatically. You never see it in your fetch calls. But if the server doesn't answer it correctly, every subsequent request fails.
Development vs production
In development, your frontend is usually at http://localhost:5173 and your API at http://localhost:3000. Different ports means different origins means CORS.
Options:
- Allow
http://localhost:5173in your CORS config (simple, works) - Use a proxy in your dev server that forwards
/api/*to port 3000 (no CORS needed, same origin) - Allow
*in development only (quick but sloppy)
In production, use a specific origin. Never ship Access-Control-Allow-Origin: * for an API that uses authentication.
Summary
CORS is the browser enforcing the Same-Origin Policy. Your server opts out of it by sending specific headers.
| Header | Purpose |
|---|---|
Access-Control-Allow-Origin |
Which origin can read the response |
Access-Control-Allow-Methods |
Which HTTP methods are allowed (preflight) |
Access-Control-Allow-Headers |
Which request headers are allowed (preflight) |
Access-Control-Allow-Credentials |
Whether cookies/auth are allowed |
Access-Control-Expose-Headers |
Which response headers JavaScript can read |
Access-Control-Max-Age |
How long to cache preflight results |
The server sends these headers. The browser reads them. If they don't match, the browser blocks the response. Your API still works fine from curl, Postman, tests, and other servers. CORS is a browser-only concern.