CORSSecurity · NestJS

A browser will not let a page on one site read this API's answers unless the API says so: cross-origin resource sharing is that permission, granted origin by origin, method by method, in headers, with a preflight request the browser sends first.

What you will learn

Read the theory for CORS

All Security lessons

All NestJS courses

loading types…

What you'll learn

  • Explain the same-origin policy and what CORS changes: a browser rule, lifted by the server's headers, that never stops a non-browser client
  • Enable CORS on a Nest app for a list of origins, with the methods, headers, credentials and preflight cache the front end needs
  • Read a preflight request and its answer, and tell it from the actual request
  • Say why a refused origin still gets a 200 body and no Access-Control headers, and why '*' and credentials cannot be combined

CORS

The shelter's website lives at https://shelter.example; the API at https://api.shelter.example. A script on the website calls fetch('https://api.shelter.example/cats'), and the browser refuses to hand it the answer: blocked by CORS policy. Nothing is wrong with the API. The browser is enforcing the same-origin policy, its oldest rule: a page may read responses only from its own origin (scheme, host and port), because otherwise any page you visit could read your bank's API with your cookies attached. Cross-origin resource sharing is how a server lifts that rule for the origins it trusts, and it does so with headers, which makes it a server-side concern and a Nest concern.

What the browser does

Before showing a page a cross-origin response, the browser looks for Access-Control-Allow-Origin in it. If the header names the page's origin (or *), the page may read the body; if not, the page gets an error and no data. The request still happened and the server still answered; CORS never protected the server from anything, and it does nothing for a curl or another server, which send no Origin header and are never refused. It protects the user's browser from pages that would read things in their name.

For a request that could change something, the browser asks first. A preflight is an OPTIONS request with Origin, Access-Control-Request-Method and Access-Control-Request-Headers, sent before any POST, DELETE, or a request with a custom header such as Authorization. The server answers with what it allows, Access-Control-Allow-Methods and Access-Control-Allow-Headers, and only then does the real request go out. Access-Control-Max-Age lets the browser cache that answer, so the preflight is paid once, not per request. A plain GET with no custom headers is sent without preflight; the browser just checks the answer.

Credentials, cookies and the Authorization header, are not sent cross-origin unless the page asks (credentials: 'include') and the server agrees (Access-Control-Allow-Credentials: true). And a server that agrees may not answer Allow-Origin: *; it must name the origin, which is why the header echoes the request's origin rather than a pattern, and why the response says Vary: Origin, so that a cache does not serve one origin's answer to another.

Enabling it in Nest

Nest wraps Express's cors package. app.enableCors() with no arguments allows every origin, which is right for a public read-only API and wrong for one with accounts. The options are the package's:

app.enableCors({
  origin: ['https://docs.example', /\.example\.org$/],
  methods: ['GET', 'PUT'],
  allowedHeaders: ['Content-Type', 'X-Requested-With'],
  credentials: true,
  maxAge: 3600,
});

origin may be a string, a list, a regular expression, or a function (origin, callback) that decides per request, for a list that lives in a database. methods and allowedHeaders fill the preflight answer; a header the front end sends that is not listed fails the preflight, and Authorization is the one people forget. The same options can be passed at creation, NestFactory.create(AppModule, { cors: options }), or { cors: true } for the permissive default.

Where it sits: CORS is middleware at the front of the pipeline, before guards, so its headers ride on every response, a 401 from the guard included. A page that could not read the status of an error would not know it was signed out.

Your task

The shelter's sites call the API; nobody else's may.

  1. In main.ts, enable CORS for https://shelter.example and every subdomain of shelter.example, for GET, POST and DELETE, allowing the Content-Type and Authorization headers, with credentials, and a preflight cache of ten minutes.

Then, in the request panel, send GET /cats with an Origin header for https://evil.example and look at the answer: the body is there, and nothing in the headers lets a browser show it.

When it fails

  • GET /cats from the site has no Allow-Origin header: enableCors is not called, or the origin list does not match (http for https, a trailing slash, a pattern that requires a dot the apex domain does not have).
  • The preflight lists GET,HEAD,PUT,PATCH,POST,DELETE: methods was left at the default; the task narrows it.
  • The front end's request with a token fails at preflight: Authorization is missing from allowedHeaders.
  • Cookies never arrive: credentials: true is missing on the server, or the page did not ask for them.
  • Allow-Origin: * next to Allow-Credentials: true: the browser refuses that combination; name the origin.

Remember

  • Same-origin is the browser's rule; CORS is the server's permission, given in headers and enforced by the browser only.
  • A refused origin still gets the response; a curl gets no headers and needs none.
  • Preflight: OPTIONS with the intended method and headers; the answer lists what is allowed and how long to remember it.
  • Credentials need Allow-Credentials: true and a named origin, never *; Vary: Origin keeps caches honest.
Stuck? Show a hint

main.ts: app.enableCors({ origin: ['https://shelter.example', /\.shelter\.example$/], methods: ['GET', 'POST', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, maxAge: 600 }) before listen.