CORS, cookies and the security basics
Two halves on different origins means the browser's security rules apply to every request between them. This lesson covers the rules, the settings that satisfy them, and the ones that quietly disable your protection.
Why CORS exists
A browser will not let JavaScript on one origin read a response from another unless that other origin allows it.
An origin is scheme, host and port together. http://localhost:3000 and
http://localhost:3001 are different origins — the port alone is enough.
The reason is the one thing to understand: the browser sends cookies automatically. Without this rule, any site you visited could make a request to your bank with your session cookie attached and read the response.
CORS is the browser refusing to hand the response to JavaScript that should not have it.
It protects users, not your server. The request may still reach your API —
curl ignores CORS entirely, because there is no browser involved. CORS is not
authorisation, and it is not a substitute for a guard.
The error
Access to fetch at 'http://localhost:3001/api/products' from origin
'http://localhost:3000' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
Two things people get wrong about it.
The fix is on the server, not the client. No fetch option makes CORS go away.
The request often succeeded. Check your API logs — it probably ran and returned 200. The browser received the response and refused to give it to your JavaScript. Which is why a CORS error can be accompanied by data appearing in your database.
Configuring it
// apps/api/src/main.ts
app.enableCors({
origin: config.getOrThrow<string>("CORS_ORIGIN"),
credentials: true,
methods: ["GET", "POST", "PATCH", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
maxAge: 86_400,
});
credentials: true is required for cookies to cross origins. Without it the
browser sends no cookie and receives no Set-Cookie, so login appears to
succeed and no session exists — the confusing bug from module 8.
origin must be an explicit URL. This does not work:
app.enableCors({ origin: "*", credentials: true }); // browsers reject this
A wildcard origin and credentials are mutually exclusive, by specification. If you allow any origin, the browser refuses to send credentials. A wildcard is only acceptable for a genuinely public, unauthenticated API.
Several origins:
const allowed = config.getOrThrow<string>("CORS_ORIGINS").split(",");
app.enableCors({
origin: (origin, callback) => {
if (!origin || allowed.includes(origin)) return callback(null, true);
callback(new Error("Not allowed by CORS"));
},
credentials: true,
});
!origin allows requests with no Origin header — server-to-server calls, curl,
mobile apps. Those are not subject to CORS anyway, so rejecting them achieves
nothing and breaks your own server components.
Never echo back whatever origin was sent:
origin: (origin, cb) => cb(null, true) // this is origin: "*" with credentials
That defeats the entire mechanism while looking like configuration.
Preflight
For anything other than a simple request, the browser first sends OPTIONS:
OPTIONS /api/orders
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
Your API must answer it before the real request is sent. NestJS's enableCors
handles this — the reason to know it exists is that every write becomes two
requests, which is visible in the Network tab and can look like a bug.
maxAge: 86400 lets the browser cache the preflight for a day, removing most of
that cost.
Cookies across origins
For a cookie set by localhost:3001 to be sent to it from a page on
localhost:3000, three things must line up:
The server sets credentials: true in CORS.
The client sends credentials: "include" on every request.
The cookie has compatible attributes.
Miss any one and cookies silently do not work.
SameSite
The attribute that decides whether a cookie is sent on a cross-site request:
| Value | Sent |
|---|---|
strict |
only on same-site requests — a link from elsewhere arrives logged out |
lax |
same-site, plus top-level navigations. The default |
none |
always — requires secure: true |
lax is right for most applications. It blocks the classic CSRF case — a
hidden form on another site POSTing to yours — while letting somebody clicking a
link to your shop arrive logged in.
none is needed when your front end and API are on genuinely different sites —
shop.example and api.otherhost.example. It requires HTTPS, and browsers are
increasingly restrictive about it.
In production, put both on the same site. kirana.example and
api.kirana.example are the same site even though they are different origins,
so lax works and everything is simpler. That is worth arranging early.
Development
{
httpOnly: true,
secure: isProduction,
sameSite: "lax",
path: "/",
}
secure: true cookies are only set over HTTPS, so hard-coding it breaks local
development entirely — with no error, just no cookie.
CSRF
Because cookies are sent automatically, another site can make your browser issue an authenticated request:
<!-- on evil.example -->
<form action="https://kirana.example/api/orders" method="POST">
<input type="hidden" name="items" value="...">
</form>
<script>document.forms[0].submit()</script>
Your cookie goes along. The order is placed.
Three defences, and you want the first two:
sameSite: "lax" blocks it, because this is a cross-site POST. This alone
handles the common case.
Require Content-Type: application/json. A cross-site form can only send
application/x-www-form-urlencoded or multipart/form-data without triggering
a preflight — and a preflight your API refuses stops the request.
A CSRF token for anything especially sensitive. More machinery; with lax
plus JSON-only it is rarely needed for an API like this.
sameSite: "lax" does not protect a GET that changes state, which is one
more reason GET must be safe — the rule from module 1.
The other security headers
import helmet from "helmet";
app.use(helmet());
Sets a group of sensible defaults in one line — X-Content-Type-Options,
X-Frame-Options and others. Worth adding on day one.
Next.js takes its own:
// apps/web/next.config.js
module.exports = {
async headers() {
return [{
source: "/:path*",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
],
}];
},
};
X-Frame-Options: DENY stops your shop being embedded in an invisible frame on
another site — clickjacking, where a user believes they are clicking something
else.
A Content Security Policy is the strongest protection against XSS and takes real effort to configure without breaking things. Worth doing for a production shop, and out of scope here.
Diagnosing it
"blocked by CORS policy" — check origin matches exactly, including scheme
and port. http://localhost:3000 is not http://localhost:3000/.
Cookies not being set — check all three: server credentials: true, client
credentials: "include", and secure not true over HTTP.
Works in Postman, fails in the browser — that is CORS by definition. Postman is not a browser and does not enforce it.
Works in development, fails in production — usually secure, sameSite, or
an origin still pointing at localhost.
The Network tab is where you find this: look at the OPTIONS request, the
response headers, and whether a Cookie header was sent.
Check your work
What CORS protects: the user, by stopping JavaScript on another origin from reading your responses. Not your server.
Why a CORS error can still mean the request ran: the browser blocks the response, not the request.
Why origin: "*" with credentials: true fails: browsers forbid the
combination by specification.
What credentials: "include" does: allows cookies to be sent and set on
cross-origin requests.
What preflight is: an OPTIONS request the browser sends first for
non-simple requests, making every write two requests.
Why secure must be conditional: secure cookies are not set over HTTP, so
local development silently breaks.
What sameSite: "lax" prevents: CSRF via cross-site form posts, while
allowing links from elsewhere to arrive logged in.
Why same-site hosts in production: api.kirana.example and kirana.example
are the same site, so lax works and none is unnecessary.
Practice
- Fetch your API from the front end with no CORS configured. Read the error, then check your API logs and confirm the request ran.
- Enable CORS with an explicit origin. Confirm it works.
- Change the origin to include a trailing slash and watch it break.
- Set
origin: "*"withcredentials: trueand read the browser's complaint. - Log in without
credentials: "include". Confirm no cookie is stored. - Find the
OPTIONSrequest in the Network tab for a POST. Confirm it precedes the real one. - Add
maxAgeand confirm the preflight stops repeating. - Set
secure: truein development and watch cookies vanish with no error. - Set
sameSite: "strict", then reach your site from a link on another page, and notice you are logged out. - Build the CSRF form on a local HTML file. Confirm
laxblocks it.
That is module nine, and the end of the teaching half of this course. You can build both sides, connect them with types that catch disagreements, handle failure without collapsing, and secure the seam.
The next seven modules build the Kirana Store itself.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship