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
- The same-origin policy
- What exactly an origin is
- What the same-origin policy really protects
- Why
curland Aroma Mobile are unaffected - Simple requests and preflighted requests
- The complete
OPTIONSexchange - All the CORS headers
Access-Control-Expose-Headers: the one that is always missing- Aroma Store's real configuration
- Why
*andAllow-Credentialsare incompatible - The position in the
src/app.jschain - Typical errors and how to read them in the console
- Cross-origin cookies:
SameSite,Secure,HttpOnly - CSRF: what it is and why Bearer is immune
- Other browser policies
- CORS authorises nothing
- Testing CORS with
curland with the DevTools
- 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.
- 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.
- 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
GETthat deleted something would delete it, even though the attacker never saw the response. It is one more argument for the safety ofGETfrom 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 the401or the404, 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.
- Why
curl and Aroma Mobile are unaffected
curl and Aroma Mobile are unaffected# This works perfectly. Always. Against any API in the world.
curl -s https://api.aromastore.example/v1/coffeescurl 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.
- 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,HEADorPOST. - Headers limited to the "CORS-safelisted" ones:
Accept,Accept-Language,Content-Language,Content-Type(with restrictions) and a few more. Content-Typemay only beapplication/x-www-form-urlencoded,multipart/form-dataortext/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.
- The complete
OPTIONS exchange
OPTIONS exchangeLet 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-keyNotice 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: OriginStep 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.
- 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.
Access-Control-Expose-Headers: the one that is always missing
Access-Control-Expose-Headers: the one that is always missingBy 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.
- Aroma Store's real configuration
// 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)'
),
- Why
* and Allow-Credentials are incompatible
* and Allow-Credentials are incompatibleAccess-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: trueThe 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.
- The position in the
src/app.js chain
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); // 11Why position 4 and no other. It is the most important decision in this lesson:
- Before the rate limiting (6): otherwise a
429goes 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
OPTIONSpreflight carries no body, but aPOSTwith malformed JSON would produce a400with 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
OPTIONSpreflight carries noAuthorization— the browser never sends it — so if the authentication middleware ran first, it would answer401to 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-Policycase 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.
- 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.
- Cross-origin cookies:
SameSite, Secure, HttpOnly
SameSite, Secure, HttpOnlyIf Aroma Store used session cookies instead of Authorization: Bearer, the necessary configuration would be:
| 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.
- 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:
- It uses
Authorization: Bearer, and the browser does not attach that header automatically. A form on another site cannot add it. - It requires
Content-Type: application/json, which an HTML form cannot produce (theenctype="text/plain"trick in the example is precisely an attempt to get round that, and ourexpress.json({type: [...]})rejects that type). - Every write triggers a preflight, and the preflight would fail because
malicious-site.exampleis 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.
- 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:
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.
- 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.
- Testing CORS with
curl and with the DevTools
curl and with the DevToolsWith 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
- Network tab, "Fetch/XHR" filter, and turn on "Preserve log" so entries are not lost on navigation.
- Look for the
OPTIONSrequest. If it fails, that is the problem; its status code points at the culprit. - Under "Headers", compare
Access-Control-Request-Headers(what the browser asks for) withAccess-Control-Allow-Headers(what the server grants). The difference is your mistake. - Check on the real response that both
Access-Control-Allow-OriginandAccess-Control-Expose-Headersare there. - If the response arrives but a header is
nullin the code, it isExpose-Headers. The DevTools do show every header even when JavaScript cannot read them: that discrepancy between "I can see it in the DevTools butheaders.get()returnsnull" 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:
fetch('https://api.aromastore.example/v1/coffees')fetch('https://api.aromastore.example/v1/coffees', { headers: { Authorization: 'Bearer x' } })fetch('https://api.aromastore.example/v1/orders', { method: 'POST', body: 'hello', headers: { 'Content-Type': 'text/plain' } })fetch('https://api.aromastore.example/v1/orders', { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } })fetch('https://aromastore.example/api/coffees')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), butresponse.headers.get('Location')returnsnull. - (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-Originbelonging to the back office. - (d)
POST /v1/ordersfails from the SPA with "Response to preflight request doesn't pass access control check", but the samePOSTwithcurlworks.
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.exampleNothing 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:
- A valid token issued by the authorisation server, with
audequal to our API (04-03). - The
orders.readscope in that token, checked byrequireScope. - 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
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
