RizTech Academy logo
RizTech Academy
Connecting the Two HalvesLesson 4 of 430 min

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

  1. Fetch your API from the front end with no CORS configured. Read the error, then check your API logs and confirm the request ran.
  2. Enable CORS with an explicit origin. Confirm it works.
  3. Change the origin to include a trailing slash and watch it break.
  4. Set origin: "*" with credentials: true and read the browser's complaint.
  5. Log in without credentials: "include". Confirm no cookie is stored.
  6. Find the OPTIONS request in the Network tab for a POST. Confirm it precedes the real one.
  7. Add maxAge and confirm the preflight stops repeating.
  8. Set secure: true in development and watch cookies vanish with no error.
  9. Set sameSite: "strict", then reach your site from a link on another page, and notice you are logged out.
  10. Build the CSRF form on a local HTML file. Confirm lax blocks 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