The Aroma Store SPA lives at https://aromastore.example. The API lives at https://api.aromastore.example. The first time somebody writes a fetch from the SPA to the API, the browser console shows the most famous message in web development:

Access to fetch at 'https://api.aromastore.example/v1/coffees' from origin
'https://aromastore.example' has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
header is present on the requested resource.

And then the worst thing happens: somebody searches the internet, finds app.use(cors()) with no arguments, pastes it, it works, and they have just opened their API to every web page in the world. This lesson exists so that does not happen. We are going to understand why the browser blocks, what exactly it protects, why curl and Aroma Mobile see none of these problems, and how to configure a policy that lets the SPA and the back office through without opening the door to anybody else. By the end, src/config/cors.js will exist and the cors middleware will occupy position 4 in the src/app.js chain — the position matters, and we will see why.

Warning. CORS is a browser mechanism and it is not a server access control. A well-made CORS configuration does not replace authentication or authorisation. Any real deployment must be reviewed by a security professional. All the domains and data in this lesson are fictional.

Contents

  1. The same-origin policy
  2. What exactly an origin is
  3. What the same-origin policy really protects
  4. Why curl and Aroma Mobile are unaffected
  5. Simple requests and preflighted requests
  6. The complete OPTIONS exchange
  7. All the CORS headers
  8. Access-Control-Expose-Headers: the one that is always missing
  9. Aroma Store's real configuration
  10. Why * and Allow-Credentials are incompatible
  11. The position in the src/app.js chain
  12. Typical errors and how to read them in the console
  13. Cross-origin cookies: SameSite, Secure, HttpOnly
  14. CSRF: what it is and why Bearer is immune
  15. Other browser policies
  16. CORS authorises nothing
  17. Testing CORS with curl and with the DevTools

  1. The same-origin policy

The same-origin policy (SOP) is the fundamental security rule of browsers: a page's JavaScript can only read responses from its own origin.

It is what prevents this:

// This script is on https://malicious-site.example, which you opened without noticing.
const r = await fetch('https://my-bank.example/api/accounts', { credentials: 'include' });
const data = await r.json();           // ← the SOP prevents this
sendToAttacker(data);

Without the SOP, any page you visited could, in the background, read your email, your bank and your intranet by exploiting the sessions you already have open. The web would be unusable.

And now the uncomfortable part: the SOP also blocks your own SPA calling your own API, because the browser has no way of knowing that aromastore.example and api.aromastore.example are the same organisation.

CORS (Cross-Origin Resource Sharing) is the standardised mechanism by which the server tells the browser: "this specific origin may read my responses". In other words:

CORS is not a defence. It is a controlled relaxation of a defence that already exists.

Internalising that sentence avoids 90% of the conceptual mistakes about CORS.

  1. What exactly an origin is

An origin is the triple scheme + host + port. All three have to match, exactly, with no exceptions.

Taking https://aromastore.example as the reference:

URL Same origin? Why
https://aromastore.example/cart Yes The path is not part of the origin
https://aromastore.example:443/ Yes 443 is the default HTTPS port
http://aromastore.example No A different scheme
https://api.aromastore.example No A different host (a subdomain ≠ the same host)
https://www.aromastore.example No A different host
https://aromastore.example:8443 No A different port
https://aromastore.example.evil.example No Another domain that merely starts the same

Two practical consequences:

A subdomain is another origin. This is the number-one source of surprise: many people assume api.aromastore.example is "the same" as aromastore.example. To the browser it is not, and that is why Aroma Store needs CORS.

The last row matters for security. When validating origins, origin.startsWith('https://aromastore.example') accepts https://aromastore.example.evil.example, a domain anybody can register. The comparison has to be exact equality against an allowlist.

  1. What the same-origin policy really protects

There is a subtlety here that almost everyone gets wrong at first, and it changes completely how you reason about CORS.

The request is sent. When your page does a fetch to another origin without preflight, the browser sends the request to the server and the server processes it. What the SOP blocks is the page's JavaScript reading the response.

sequenceDiagram
  participant JS as JS on malicious-site.example
  participant N as Browser
  participant API as api.aromastore.example

  JS->>N: fetch('https://api.aromastore.example/v1/coffees')
  N->>API: GET /v1/coffees (the request IS sent)
  API->>API: Processes it: queries the database
  API->>N: 200 with the data (no Access-Control-Allow-Origin)
  N->>N: The header is missing: I will NOT hand over the response
  N->>JS: TypeError: Failed to fetch (no details)

Consequences that need to be very clear:

  • If the endpoint has side effects, they happen anyway. A GET that deleted something would delete it, even though the attacker never saw the response. It is one more argument for the safety of GET from 02-03.
  • CORS protects your server from nothing. It protects the user's data by stopping another page reading it with that user's credentials.
  • The attacker does not see the error. The JavaScript receives a generic TypeError: Failed to fetch, with no status code and no body. That is deliberate: if it could see the 401 or the 404, it would already have information.

Preflighted requests (section 5) are stopped before they are sent, and there there really is server-side protection: that is why Content-Type: application/json and Authorization trigger preflight.

  1. Why curl and Aroma Mobile are unaffected

# This works perfectly. Always. Against any API in the world.
curl -s https://api.aromastore.example/v1/coffees

curl does not apply CORS. Neither does Postman, nor a Node script, nor CataBox's backend, nor the Aroma Mobile app (which makes native requests, not requests from a browser context). CORS is implemented by the browser, and only by the browser, because it is the only thing that runs third-party code with the user's credentials.

Consumer Does CORS apply? Why
The shop's SPA Yes It is JavaScript in a browser
Internal back office Yes Ditto
Aroma Mobile (native) No There is no origin context
Aroma Mobile (WebView) Yes It is an embedded browser
CataBox's backend No Server to server
SwiftShip No Ditto
curl, Postman No They are not browsers

From which follows the most important conclusion of the lesson, which is worth repeating until it feels obvious:

A restrictive CORS policy stops nobody calling your API. It only stops a web page from another origin reading the response inside a user's browser. The real protection for your data is, always, the server's authentication and authorisation.

  1. Simple requests and preflighted requests

The browser distinguishes two categories, and the difference is visible in performance and in the errors.

Simple requests

They are sent directly, with no prior enquiry. They must meet all of these conditions:

  • The method is GET, HEAD or POST.
  • Headers limited to the "CORS-safelisted" ones: Accept, Accept-Language, Content-Language, Content-Type (with restrictions) and a few more.
  • Content-Type may only be application/x-www-form-urlencoded, multipart/form-data or text/plain.
  • No upload event handlers on the XMLHttpRequest.

The historical reason for this list: these are exactly the requests an HTML form could make before CORS existed. Since they were already possible, requiring prior permission would have added no security and would certainly have broken the web.

Preflighted requests

Anything outside that list triggers a preliminary OPTIONS request. In Aroma Store, that means almost everything:

Request Preflight? Why
GET /v1/coffees with no headers No Simple
GET /v1/coffees with Authorization Yes A header not allowed in simple requests
POST /v1/orders with Content-Type: application/json Yes That Content-Type is not on the list
POST with Content-Type: text/plain No Simple
PUT, PATCH, DELETE Yes A method not allowed in simple requests
Anything with Aroma-Trace-Id Yes A custom header

Since our API uses Authorization: Bearer and application/json everywhere, practically all the SPA's traffic is preflighted. That is why Access-Control-Max-Age (section 7) has a real performance impact: without a preflight cache, every request becomes two.

  1. The complete OPTIONS exchange

Let us look, in the raw, at what happens when the SPA creates an order.

sequenceDiagram
  participant JS as SPA (aromastore.example)
  participant N as Browser
  participant API as api.aromastore.example

  JS->>N: fetch POST /v1/orders with Authorization and JSON
  Note over N: Not simple: a preflight is needed
  N->>API: OPTIONS /v1/orders + Origin + Access-Control-Request-*
  API->>N: 204 + Allow-Origin, Allow-Methods, Allow-Headers, Max-Age
  Note over N: Allowed. Caches the response for 10 min
  N->>API: POST /v1/orders (the real request)
  API->>N: 201 + Allow-Origin + Expose-Headers + Location
  N->>JS: Response handed over, Location header included

Step 1: the preflight. The browser generates it on its own; your code never writes it:

OPTIONS /v1/orders HTTP/1.1
Host: api.aromastore.example
Origin: https://aromastore.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type,idempotency-key

Notice three things: it carries no body, it does not carry the real Authorization (it only announces that it is going to send one) and the announced headers are lowercase and sorted.

Step 2: the response to the preflight.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://aromastore.example
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Allow-Headers: Authorization,Content-Type,Idempotency-Key,Aroma-Trace-Id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

Step 3: the real request. Only if step 2 allowed it:

POST /v1/orders HTTP/1.1
Host: api.aromastore.example
Origin: https://aromastore.example
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
Idempotency-Key: 3f2b9a10-7c4e-4b6a-9d21-0a5e1f8c7b33

{"customerId":"cus_842","items":[{"coffeeId":"cof_001","quantity":2}]}

Step 4: the real response. And here is the point that always gets forgotten: the real response also needs Access-Control-Allow-Origin. The preflight having gone well is not enough.

HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://aromastore.example
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Location,Link,Aroma-Trace-Id,Aroma-RateLimit-Limit,Aroma-RateLimit-Remaining,Aroma-RateLimit-Reset,Retry-After
Location: /v1/orders/ord_5002
Vary: Origin
Content-Type: application/json

{"id":"ord_5002","status":"pending_payment", ...}

Without Expose-Headers, the SPA would receive the 201 and the body, but response.headers.get('Location') would return null.

  1. All the CORS headers

Sent by the browser

Header When Content
Origin Every cross-origin request The page's origin. It cannot be forged from JavaScript
Access-Control-Request-Method Preflight only The method that is going to be used
Access-Control-Request-Headers Preflight only The headers that are going to be sent

The fact that Origin cannot be forged from JavaScript is what makes the mechanism work: the browser writes it and does not let the script touch it. That said, a client that is not a browser can write whatever it likes (curl -H "Origin: ..."), and that is why Origin is not usable as an access control.

Sent back by the server

Header Values What it does
Access-Control-Allow-Origin A specific origin or * Who may read the response. A single value, never a list
Access-Control-Allow-Methods GET,POST,PUT,... Allowed methods (preflight only)
Access-Control-Allow-Headers A list of headers Headers the client may send (preflight only)
Access-Control-Expose-Headers A list of headers Response headers the JavaScript may read
Access-Control-Allow-Credentials true Allows sending cookies and reading the response with credentials
Access-Control-Max-Age Seconds How long to cache the preflight
Vary Origin Essential: see below

Two details that cause bugs:

Allow-Origin accepts a single value. There is no such thing as Access-Control-Allow-Origin: https://a.example, https://b.example. For several origins, the server reflects the origin it received if it is on its allowlist. That is what the cors package does with an origin function.

Max-Age has browser caps. Even if you ask for 86,400 seconds, browsers impose their own maximum (of the order of two hours in Chromium, less in others). A value of 600 is a good balance: it cuts down preflights a lot and does not leave a policy change frozen for too long.

Vary: Origin

If the server reflects the origin, the response depends on the Origin header. Without Vary: Origin, any intermediate cache — a CDN, a corporate proxy, the browser's cache — can store the response for https://aromastore.example and serve it to a request from https://panel.aromastore.example, which will then receive the wrong Allow-Origin and fail intermittently and inexplicably.

It is the same Vary mechanic we saw in 02-05 with Accept-Language, and we will return to it in 04-06 when we talk about caching. All three cases are the same principle: if the response depends on a request header, say so in Vary.

  1. Access-Control-Expose-Headers: the one that is always missing

By default, JavaScript from another origin can read only seven response headers, the "CORS-safelisted" ones:

Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma.

All the rest are invisible, even though they are there. And that wipes out a good part of what we have built in this course:

Header What the SPA needs it for Without exposing it
Location Knowing the URI of the order just created (03-03) null
Link RFC 8288 pagination (02-06) The SPA cannot paginate
ETag Conditional requests and If-Match (04-06) No caching, no optimistic concurrency
Retry-After Knowing how long to wait after a 429 (04-04) Blind retries
Aroma-RateLimit-* Slowing down before hitting the wall (04-04) A useless mechanism
Aroma-Trace-Id Showing the identifier in an error message (04-07) Blind support
Allow Knowing which methods are supported after a 405 Invisible
Accept-Patch Knowing which patch format is accepted (02-05) Invisible

This is the link that joined the three previous lessons together. Aroma Store exposes exactly this:

Access-Control-Expose-Headers: Location, Link, ETag, Accept-Patch, Allow, Retry-After,
  Aroma-Trace-Id, Aroma-RateLimit-Limit, Aroma-RateLimit-Remaining, Aroma-RateLimit-Reset

And a design criterion: you expose what is needed, not everything. There is a * wildcard for Expose-Headers, but it does not work when credentials are involved and, above all, exposing infrastructure headers leaks unnecessary information.

  1. Aroma Store's real configuration

npm install cors
// src/config/cors.js  (NEW file)
import { environment } from './environment.js';

/**
 * Allowed origins, per environment, read from the configuration.
 *
 * In .env:
 *   ALLOWED_ORIGINS=https://aromastore.example,https://panel.aromastore.example
 *
 * In development the local ones are added so the SPA running on Vite works.
 */
function allowedOrigins() {
  const configured = (environment.ALLOWED_ORIGINS ?? '')
    .split(',')
    .map((o) => o.trim())
    .filter(Boolean);

  if (environment.NODE_ENV === 'development') {
    return [...configured, 'http://localhost:5173', 'http://localhost:4173'];
  }
  return configured;
}

const ALLOWED = new Set(allowedOrigins());

export const corsOptions = {
  /**
   * The decision function. The `cors` package calls it with the value of Origin.
   * - callback(null, true)  → reflects that origin in Access-Control-Allow-Origin
   * - callback(null, false) → does NOT emit the header: the browser will block
   *
   * IMPORTANT: callback(error) is not used. Returning an error would turn the
   * preflight into a 500 and the console message would be even more confusing. We
   * answer without the header and let the browser apply its policy.
   */
  origin(origin, callback) {
    // No Origin: curl, Postman, Aroma Mobile, server to server. It is allowed:
    // those requests are not protected by CORS, they are protected by authentication.
    if (!origin) return callback(null, true);

    // Comparison by EXACT EQUALITY against the allowlist.
    return callback(null, ALLOWED.has(origin));
  },

  // The methods the API supports. OPTIONS is handled by the package itself.
  methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],

  // Headers the client may SEND.
  allowedHeaders: [
    'Authorization',
    'Content-Type',
    'Accept',
    'Accept-Language',
    'If-Match',            // optimistic concurrency (04-06)
    'If-None-Match',       // conditional caching (04-06)
    'Idempotency-Key',     // 02-03 / 03-03
    'Aroma-Trace-Id',      // the client may propagate its own trace (04-07)
  ],

  // Headers the client may READ. Without this, they are invisible.
  exposedHeaders: [
    'Location',
    'Link',
    'ETag',
    'Accept-Patch',
    'Allow',
    'Retry-After',
    'Aroma-Trace-Id',
    'Aroma-RateLimit-Limit',
    'Aroma-RateLimit-Remaining',
    'Aroma-RateLimit-Reset',
  ],

  // Aroma Store uses Authorization: Bearer, NOT cookies. See section 13.
  credentials: false,

  // The preflight is cached for 10 minutes: it halves the round trips.
  maxAge: 600,

  // Answer the preflight with a 204 and stop there.
  optionsSuccessStatus: 204,
  preflightContinue: false,
};
# .env.example  (MODIFIED)
ALLOWED_ORIGINS=https://aromastore.example,https://panel.aromastore.example

And the start-up validation, consistent with 03-01: if there are no origins configured in production, the process must not start, because the silent alternative is worse.

// src/config/environment.js  (MODIFIED, fragment)
ALLOWED_ORIGINS: z
  .string()
  .min(1, 'ALLOWED_ORIGINS is mandatory')
  .refine(
    (v) => v.split(',').every((o) => o.trim().startsWith('https://') || o.includes('localhost')),
    'Every allowed origin must use https (except localhost in development)'
  ),

  1. Why * and Allow-Credentials are incompatible

Access-Control-Allow-Origin: * means "any web page in the world may read this response". There is one case where that is perfectly reasonable:

Scenario Is * acceptable Why
A public, unauthenticated GET /v1/coffees Yes It is the shop window; the information is already public
Any endpoint returning a user's data No It depends on who is asking
Anything with Authorization or cookies No And it is forbidden by the standard on top of that

The prohibition is explicit in the specification: if Access-Control-Allow-Credentials: true, then Access-Control-Allow-Origin cannot be *. It has to be a specific origin.

# ❌ The browser REJECTS this combination and blocks the response.
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

The reason is clear once you think about the attack it prevents: if it were allowed, any web page could make requests carrying the user's session cookies to any API and read the result. It would be the complete elimination of the same-origin policy. The wildcard can only apply to data that does not depend on who is asking, and the moment credentials are involved, it does.

One nuance that confuses people: credentials: 'include' in the fetch affects cookies and HTTP authentication headers managed by the browser, not an Authorization: Bearer you set by hand. Even so, with Bearer you still need an allowlist, because the token identifies the user and the response depends on them.

Aroma Store's decision: an allowlist across the whole API. An exception with * could be made for GET /v1/coffees, which is public and cacheable, but keeping a single policy is simpler, less brittle and consistent with the consistency principle from 04-01.

  1. The position in the src/app.js chain

// src/app.js  (extract after 04-05)
import express from 'express';
import cors from 'cors';
import { v1Routes } from './routes/index.js';
import { assignTraceId } from './middleware/trace.js';
import { securityHeaders } from './middleware/security.js';
import { corsOptions } from './config/cors.js';                   // ← NEW
import { globalLimit } from './middleware/rate-limit.js';
import { notFoundHandler } from './middleware/not-found.js';
import { errorHandler } from './middleware/errors.js';

export const app = express();

app.disable('x-powered-by');                                    // 1
app.set('trust proxy', 1);                                      //   (04-04)
app.use(assignTraceId);                                         // 2
app.use(securityHeaders);                                       // 3  helmet (04-02)
app.use(cors(corsOptions));                                     // 4  ← NEW
// (5) structured logging → 04-07
app.use(globalLimit);                                           // 6  (04-04)
app.use(express.json({ limit: '100kb', /* ... */ }));           // 7
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));   // 8
app.use('/v1', v1Routes);                                       // 9
app.use(notFoundHandler);                                       // 10
app.use(errorHandler);                                          // 11

Why position 4 and no other. It is the most important decision in this lesson:

  • Before the rate limiting (6): otherwise a 429 goes out with no CORS headers and the browser turns it into an opaque network error. The SPA developer sees "Failed to fetch" and has no idea they have exceeded a limit.
  • Before the JSON parser (7): the OPTIONS preflight carries no body, but a POST with malformed JSON would produce a 400 with no CORS headers, and once again the SPA would see a useless error.
  • Before the routes (9) and therefore before all authentication: this is the key to the classic error in section 12. The OPTIONS preflight carries no Authorization — the browser never sends it — so if the authentication middleware ran first, it would answer 401 to the preflight and the real request would never be sent.
  • After helmet (3): so that the preflight response also carries the security headers and so that, if helmet and CORS conflict (the Cross-Origin-Resource-Policy case we saw in 04-02), CORS has the last word over its own headers.
  • After the trace (2): so that a failed preflight can be correlated in the logs.

And a design consequence worth noting: the cors package answers the OPTIONS and ends the chain (preflightContinue: false). The preflight never reaches the routes, so each route's router.all(...) with methodNotAllowed(...) does not interfere with it.

  1. Typical errors and how to read them in the console

Console message The real cause Solution
No 'Access-Control-Allow-Origin' header is present The origin is not on the allowlist, or the middleware did not run Add the origin to ALLOWED_ORIGINS; check that cors comes before whatever responds
The 'Access-Control-Allow-Origin' header has a value 'https://other.example' that is not equal to the supplied origin An intermediate cache returned another origin's response Add Vary: Origin
Response to preflight request doesn't pass access control check The OPTIONS did not return 2xx, or headers are missing Look at the OPTIONS in the network tab; it is usually a 401 (see below)
Method PATCH is not allowed by Access-Control-Allow-Methods The method is missing from methods Add it to corsOptions.methods
Request header field idempotency-key is not allowed by Access-Control-Allow-Headers An undeclared header Add it to allowedHeaders
Credentials flag is 'true', but 'Access-Control-Allow-Origin' is '*' A forbidden combination An allowlist instead of *
The response arrives but headers.get('Link') is null Access-Control-Expose-Headers is missing Add the header to exposedHeaders
TypeError: Failed to fetch with no further detail It could be CORS, the network, DNS or a certificate Look at the network tab, not the console

The classic: the preflight returns 401

Access to fetch at 'https://api.aromastore.example/v1/orders' from origin
'https://aromastore.example' has been blocked by CORS policy: Response to preflight
request doesn't pass access control check: It does not have HTTP ok status.

Translation: the OPTIONS returned 401. And it returned 401 because the authentication middleware ran before the CORS one, and the preflight carries no Authorization: the browser never includes it, by design, because the preflight is a question about policy, not a request for data.

// ❌ The preflight dies in authenticate and never reaches cors.
app.use(authenticate);
app.use(cors(corsOptions));

// ✅ CORS first. The preflight is answered with a 204 and does not even reach the routes.
app.use(cors(corsOptions));
app.use('/v1', v1Routes);       // inside each route: authenticate, requireRole...

The confusion here is twofold, which is why the bug eats whole afternoons: the message talks about CORS, but the cause is the middleware ordering; and the request that fails (OPTIONS) is not the one you wrote (POST). The diagnostic rule: when CORS fails, open the browser's network tab and look for the OPTIONS request. If it is not there, the problem is something else. If it is there and does not return 2xx, that is your failure, and its status code tells you exactly which middleware intercepted it.

  1. Cross-origin cookies: SameSite, Secure, HttpOnly

If Aroma Store used session cookies instead of Authorization: Bearer, the necessary configuration would be:

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=900
Attribute Effect Why
HttpOnly JavaScript cannot read it An XSS does not steal the session
Secure Only sent over HTTPS It never travels in the clear
SameSite=None Sent across sites Necessary if the API is on another domain
SameSite=Lax Only on top-level navigation The default in modern browsers
SameSite=Strict Never across sites Maximum CSRF protection
Path=/ Scope
Max-Age Expiry A short session

The problem is obvious: SameSite=None is exactly what you have to set for things to work between aromastore.example and api.aromastore.example, and it is exactly what reopens the door to CSRF. In exchange you have to add anti-CSRF tokens, and on top of that SameSite=None requires Secure and is subject to the third-party cookie restrictions browsers have been tightening for years.

That is why Aroma Store uses Authorization: Bearer:

Session cookie Authorization: Bearer
Sent automatically Yes, the browser attaches it No, the code sets it
Vulnerable to CSRF Yes No
Needs SameSite=None across domains Yes
Affected by third-party cookie blocking Yes No
Works the same in a native mobile app No Yes
Vulnerable to XSS Less so with HttpOnly Yes if stored in localStorage
Requires credentials: true in CORS Yes No

Neither option is perfect: Bearer eliminates CSRF but shifts the risk onto how the token is stored on the client (04-03). For an API consumed by a SPA, a mobile app and third parties, Bearer is the coherent choice, and that is why credentials: false in our CORS configuration.

  1. CSRF: what it is and why Bearer is immune

CSRF (Cross-Site Request Forgery) is an attack that exploits the fact that the browser attaches cookies automatically. The victim, with an open session at Aroma Store, visits a malicious page:

<!-- On https://malicious-site.example -->
<form action="https://api.aromastore.example/v1/orders" method="POST"
      enctype="text/plain" id="f">
  <input name='{"customerId":"cus_842","items":[{"coffeeId":"cof_999","quantity":50}],"x":"' value='"}'>
</form>
<script>document.getElementById('f').submit();</script>

The browser sends the request with the victim's session cookies. The attacker cannot read the response (the SOP prevents it), but they do not need to: the order has already been created. CSRF is a blind write attack.

Why Aroma Store is immune:

  1. It uses Authorization: Bearer, and the browser does not attach that header automatically. A form on another site cannot add it.
  2. It requires Content-Type: application/json, which an HTML form cannot produce (the enctype="text/plain" trick in the example is precisely an attempt to get round that, and our express.json({type: [...]}) rejects that type).
  3. Every write triggers a preflight, and the preflight would fail because malicious-site.example is not on the allowlist.

All three are consequences of decisions that were already taken. If one day it migrated to cookies, explicit defences would be needed:

Defence How it works Note
SameSite=Lax or Strict The browser does not send the cookie across sites The first line, and often sufficient
A synchroniser anti-CSRF token The server issues a token the client returns in a header The standard approach; the attacker cannot read it
Double-submit cookie A cookie plus the same value in a header; the server compares Simpler, somewhat less robust
Checking Origin Reject if the Origin is not the expected one A good reinforcement, not a sole defence

And the section's final warning: CSRF and CORS are different things and are often confused. CORS controls who may read responses; CSRF exploits the fact that requests are sent with credentials. Configuring CORS strictly does not eliminate CSRF if you use cookies, because simple requests get sent anyway.

  1. Other browser policies

CORS is not the only policy governing the relationship between the SPA and the API.

Referrer-Policy, which we already enabled with helmet in 04-02 with the value no-referrer. It controls how much of the originating URL is sent when navigating or loading resources. It matters because our URIs contain identifiers (/v1/orders/ord_5001) and we do not want them appearing in third parties' logs.

Permissions-Policy (formerly Feature-Policy) declares which browser capabilities a document may use: camera, microphone, geolocation. It is a header for HTML documents, so it is set by the SPA, not by the API:

Permissions-Policy: camera=(), microphone=(), geolocation=(self), payment=()

Content-Security-Policy in the SPA, and specifically the connect-src directive, which is the symmetrical complement to CORS: while CORS says which origins may read us, connect-src says which origins the SPA may call.

Content-Security-Policy:
  default-src 'self';
  connect-src 'self' https://api.aromastore.example;
  img-src 'self' https://images.aromastore.example data:;
  script-src 'self';
  style-src 'self';
  frame-ancestors 'none';
  base-uri 'self'

Its defensive value is concrete: if an attacker manages to inject JavaScript into the SPA (an XSS), connect-src stops them exfiltrating the data to their own server, because the browser will block the fetch to an undeclared domain. That is why CSP is a valuable defence in depth even when you already sanitise your inputs.

The API, which serves no HTML, keeps its default-src 'none' from 04-02: nothing to load, nothing to execute.

  1. CORS authorises nothing

It deserves a section of its own because it is the misunderstanding with the worst consequences.

CORS is an instruction to the browser. It is not an access control.

What CORS does do What CORS does not do
Tell the browser which origin may read the response Stop anybody calling your API
Protect the user from another website using their session Authenticate anybody
Allow specific headers to be read Authorise operations
Reduce the surface from within the browser Protect you from curl, scripts or bots

An API with Access-Control-Allow-Origin: https://aromastore.example and no authentication is a completely open API: anyone with curl gets everything. And an API with the most restrictive CORS policy in the world is still vulnerable to BOLA if it does not check resource ownership (04-02).

The golden rule:

Configure CORS thinking about protecting your users inside their browser. Configure authentication and authorisation thinking about protecting your data from everything else. Never substitute the second with the first.

  1. Testing CORS with curl and with the DevTools

With curl

curl does not apply CORS, but it is perfect for inspecting the headers the server emits, which is what we want to verify.

# 1. An allowed origin: Access-Control-Allow-Origin must appear with that value.
curl -sI https://api.aromastore.example/v1/coffees \
  -H 'Origin: https://aromastore.example' | grep -i 'access-control\|vary'

# Expected:
# access-control-allow-origin: https://aromastore.example
# access-control-expose-headers: Location, Link, ETag, ...
# vary: Origin

# 2. A NON-allowed origin: the header must NOT appear. Careful: the body arrives anyway,
#    because curl is not a browser. What we are checking is the absence of the header.
curl -sI https://api.aromastore.example/v1/coffees \
  -H 'Origin: https://malicious-site.example' | grep -i 'access-control-allow-origin'
# Expected: no output

# 3. Simulate a complete preflight.
curl -si -X OPTIONS https://api.aromastore.example/v1/orders \
  -H 'Origin: https://aromastore.example' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type,idempotency-key'

# Expected: HTTP/1.1 204 with Allow-Methods, Allow-Headers and Max-Age.

# 4. Check that the preflight does NOT require authentication (the failure in section 12).
curl -so /dev/null -w '%{http_code}\n' -X OPTIONS \
  https://api.aromastore.example/v1/orders \
  -H 'Origin: https://aromastore.example' \
  -H 'Access-Control-Request-Method: POST'
# Expected: 204. If it comes back 401, the authentication middleware is in the wrong place.

With the DevTools

  1. Network tab, "Fetch/XHR" filter, and turn on "Preserve log" so entries are not lost on navigation.
  2. Look for the OPTIONS request. If it fails, that is the problem; its status code points at the culprit.
  3. Under "Headers", compare Access-Control-Request-Headers (what the browser asks for) with Access-Control-Allow-Headers (what the server grants). The difference is your mistake.
  4. Check on the real response that both Access-Control-Allow-Origin and Access-Control-Expose-Headers are there.
  5. If the response arrives but a header is null in the code, it is Expose-Headers. The DevTools do show every header even when JavaScript cannot read them: that discrepancy between "I can see it in the DevTools but headers.get() returns null" is the unmistakable signature of the problem.

An automated test

// tests/integration/cors.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { app } from '../../src/app.js';

describe('CORS', () => {
  it('reflects the allowed origin and adds Vary', async () => {
    const r = await request(app)
      .get('/v1/coffees')
      .set('Origin', 'https://aromastore.example');

    assert.equal(r.headers['access-control-allow-origin'], 'https://aromastore.example');
    assert.match(r.headers['vary'] ?? '', /Origin/);
  });

  it('does not emit Allow-Origin for an unknown origin', async () => {
    const r = await request(app)
      .get('/v1/coffees')
      .set('Origin', 'https://malicious-site.example');

    assert.equal(r.headers['access-control-allow-origin'], undefined);
  });

  it('answers the preflight WITHOUT requiring authentication', async () => {
    const r = await request(app)
      .options('/v1/orders')
      .set('Origin', 'https://aromastore.example')
      .set('Access-Control-Request-Method', 'POST')
      .set('Access-Control-Request-Headers', 'authorization,content-type,idempotency-key');

    assert.equal(r.status, 204);                              // NEVER 401
    assert.match(r.headers['access-control-allow-headers'], /Idempotency-Key/i);
    assert.match(r.headers['access-control-allow-methods'], /POST/);
  });

  it('exposes the headers the SPA needs to read', async () => {
    const r = await request(app)
      .get('/v1/coffees')
      .set('Origin', 'https://aromastore.example');

    const exposed = (r.headers['access-control-expose-headers'] ?? '').toLowerCase();
    for (const header of ['link', 'etag', 'retry-after', 'aroma-ratelimit-remaining']) {
      assert.ok(exposed.includes(header), `${header} is not exposed`);
    }
  });
});

The third test is the most valuable of the four: it pins down the middleware ordering. If somebody moves cors after the authentication, this test fails with a 401 and the problem is caught in continuous integration instead of in the console of a SPA developer.

Common Mistakes and Tips

Using app.use(cors()) with no options. It is equivalent to Access-Control-Allow-Origin: * across the whole API. It works, and that is why it is so dangerous: the problem never manifests itself.

Validating the origin with startsWith or a loose regular expression. https://aromastore.example.evil.example would pass the filter. Exact equality against a Set.

Forgetting Vary: Origin. It produces intermittent failures that depend on what the CDN happens to have cached, and they are among the hardest bugs to reproduce.

Putting cors after the authentication. The preflight gets a 401 and nothing works, with a message pointing in another direction.

Forgetting exposedHeaders. The SPA cannot read Location, Link, ETag or Retry-After, and a good part of the work from modules 2, 3 and 4 is invisible to it.

Trying to fix CORS from the client. You cannot: the decision belongs to the server. The proxies and extensions that "disable CORS" only work on your machine and hide the real problem.

Believing that mode: 'no-cors' in the fetch fixes anything. It returns an opaque response: you can read neither the body nor the status. It is almost never what you want.

Adding an origin to the allowlist "temporarily" for debugging. Temporary *s stay for ever. Use a development environment with its own configuration.

Tip: the preflight response also needs the security headers. That is why helmet goes before CORS.

Tip: when CORS fails, look at the network tab before the console. The console message is a summary; the OPTIONS request is the evidence.

Tip: document the list of allowed origins. When the SPA changes domain, somebody will have to update it, and if it is not written down nobody will know where.

Exercises

Exercise 1: preflight or not

For each request from https://aromastore.example, say whether it triggers a preflight and why:

  1. fetch('https://api.aromastore.example/v1/coffees')
  2. fetch('https://api.aromastore.example/v1/coffees', { headers: { Authorization: 'Bearer x' } })
  3. fetch('https://api.aromastore.example/v1/orders', { method: 'POST', body: 'hello', headers: { 'Content-Type': 'text/plain' } })
  4. fetch('https://api.aromastore.example/v1/orders', { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } })
  5. fetch('https://aromastore.example/api/coffees')
  6. fetch('https://api.aromastore.example/v1/coffees/cof_001', { method: 'DELETE' })

Exercise 2: diagnosing four failures

Diagnose each situation and propose the concrete fix:

  • (a) The SPA creates an order correctly (201), but response.headers.get('Location') returns null.
  • (b) The internal back office works from a developer's machine and fails in production with No 'Access-Control-Allow-Origin' header.
  • (c) After putting a CDN in front, the SPA fails one time in five with an Allow-Origin belonging to the back office.
  • (d) POST /v1/orders fails from the SPA with "Response to preflight request doesn't pass access control check", but the same POST with curl works.

Exercise 3: configuration for a third party

Aroma Store is going to let CataBox (https://catabox.example) call the API from its own SPA in the browser, using OAuth with Authorization: Bearer and only the orders.read scope. Write the necessary CORS configuration and answer: is adding the origin to the allowlist enough for CataBox to read the orders? What else is needed, and what does CORS not contribute here?

Solutions

Solution 1

No. Preflight? Why
1 No A GET with no special headers: it is a simple request
2 Yes Authorization is not on the safelisted headers list
3 No A POST with text/plain meets the simple-request conditions
4 Yes Content-Type: application/json is not allowed in simple requests
5 Not applicable It is the same origin: CORS does not come into it at all
6 Yes DELETE is not among the simple-request methods

An observation about case 3: even though it does not trigger a preflight, our API would reject it anyway with a 400, because express.json is configured with type: ['application/json', 'application/merge-patch+json'] and does not parse text/plain. That rejection is precisely what makes the CSRF form trick from section 14 fail.

Solution 2

(a) Location is missing from exposedHeaders. The response arrives complete and the header is there — you can see it in the DevTools — but the browser does not let JavaScript read it because it is not one of the seven safelisted ones. Fix: add Location to exposedHeaders in src/config/cors.js.

(b) The production ALLOWED_ORIGINS variable does not include the back office's domain. In development it worked because allowedOrigins() adds localhost when NODE_ENV is development. Fix: add https://panel.aromastore.example to the production environment's configuration and redeploy. Verification: curl -sI -H 'Origin: https://panel.aromastore.example' ....

(c) Vary: Origin is missing. The CDN caches the response — including its Access-Control-Allow-Origin header — without knowing that it depends on Origin, and serves it indiscriminately to both origins. The failure is intermittent because it depends on which response happens to be cached. Fix: emit Vary: Origin (the cors package does so when an origin function is used, but you have to verify that the CDN respects it and does not strip it).

(d) The preflight is not being answered correctly. curl works because it does no preflight. The two likely causes, in order: the authentication middleware runs before cors and answers 401 to the OPTIONS; or Idempotency-Key is missing from allowedHeaders, so the browser rejects the preflight even though it returns 204. Diagnosis: look at the OPTIONS request in the network tab. If it returns 401, it is the first; if it returns 204 but the Access-Control-Allow-Headers does not include idempotency-key, it is the second.

Solution 3

// src/config/cors.js  (fragment)
// In the production .env:
// ALLOWED_ORIGINS=https://aromastore.example,https://panel.aromastore.example,https://catabox.example

Nothing else is needed in the configuration: CataBox uses Authorization: Bearer, which is already in allowedHeaders, and credentials stays false because there are no cookies.

Is that enough for CataBox to read the orders? No. Adding the origin only allows the browser to hand the response to CataBox's JavaScript. For the API to return data, three more things are needed, all on the server side:

  1. A valid token issued by the authorisation server, with aud equal to our API (04-03).
  2. The orders.read scope in that token, checked by requireScope.
  3. The ownership check: the token gives access only to the orders belonging to its sub, not to anyone's (04-02).

What CORS does not contribute here: absolutely no authorisation. If tomorrow CataBox's backend calls the API from its server, there will be no Origin and no CORS involved, and the access will carry on working or failing in exactly the same way, according to the token. And a practical note: since CataBox's SPA is a public client, it must use Authorization Code + PKCE and cannot store a client_secret (04-03).

Conclusion

CORS stops being a mystery as soon as you understand that it is not a defence, but a controlled relaxation of a defence that already exists: the same-origin policy, which stops a page's JavaScript reading responses from another origin. You know what an origin is exactly — scheme, host and port, with a subdomain counting as different — that the request is sent even when the response is blocked, and why curl, Aroma Mobile and CataBox's backend see none of this. You can tell simple requests from preflighted ones, and you have seen the complete raw OPTIONS exchange, with the key observation that the real response also needs its CORS headers. You know the protocol's seven headers, the importance of Vary: Origin when caches are involved, and above all Access-Control-Expose-Headers, which was the missing link letting the SPA read Location, Link, ETag, Retry-After and the Aroma-RateLimit-* headers we have spent three lessons building. In the project you have src/config/cors.js with a per-environment allowlist validated at start-up, and the middleware at position 4 of src/app.js: after helmet, before the rate limiting, the parser and all authentication — which is precisely what avoids the classic preflight 401. And you are clear on why Aroma Store uses Authorization: Bearer rather than cookies, which makes it immune to CSRF with no need for synchroniser tokens.

With the API secured, bounded and reachable from the right origins, what remains is making it fast. In 04-06, HTTP Caching and Performance, we will pick up the "cacheable" constraint from 01-04 and turn it into implementation: the levels of caching from the browser down to the database; Cache-Control in depth, with max-age, s-maxage, private, that no-cache which does not mean "do not cache", stale-while-revalidate and immutable; conditional validation with ETag and Last-Modified producing a 304 with no body; and the reunion we have been promising since 03-05, when If-Match and the 412 close the optimistic concurrency circle and explain why a header is a better place than the body for the version field. We will add src/middleware/cache.js, look at invalidation — the hard problem — server-side caching with Redis and its cache-aside pattern, compression, the N+1, the 202 for long operations, and why you have to measure p50, p95 and p99 before optimising anything.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved