Derive ordering from the request path
Express processes middleware in registration order. An earlier component can already end the request. If authentication middleware demands a session cookie for OPTIONS before CORS processing, the browser may fail at preflight. Under the CORS protocol, preflight contains no such credentials.
A sensible arrangement for the affected API area is therefore: handle CORS preflight, then authenticate and authorise the actual request, validate input and execute the business operation. The CORS rule must also remain on intended success and error responses. General safeguards, such as connection or size limits, are not categorically exempt. See Express: using middleware and cors: preflight.
With app.use('/demo', cors(options)), the middleware handles matching preflights in the mounted area. No additional blanket app.options('*', ...) is needed. The example therefore also avoids depending on differing wildcard path syntax between Express major versions.
Two local origins, one synthetic API
Prerequisites are a separate local working directory, Node.js and available Express and cors packages there. Express 5.2.1 and cors 2.8.6 with Node.js 24.3.0 have been tested. The example installs nothing itself and reads no environment secrets. Save it as demo.mjs and start it in this environment with node demo.mjs.
Ports 43101, 43102 and 43104 must be free. All listeners bind exclusively to 127.0.0.1. The code sets no session and processes no mutating actions:
import express from 'express';
import cors from 'cors';
const allowedOrigins = new Set(['http://127.0.0.1:43101']);
const api = express();
api.use('/demo', cors({
origin(origin, callback) {
const allowed = typeof origin === 'string' && allowedOrigins.has(origin);
callback(null, allowed ? origin : false);
},
credentials: true,
methods: ['GET'],
allowedHeaders: ['X-Demo'],
exposedHeaders: ['X-Demo-Result'],
maxAge: 5,
}));
api.get('/demo/data', (req, res) => {
res.set('X-Demo-Result', 'synthetic');
res.json({ message: 'Synthetic response' });
});
api.get('/demo/error', (req, res) => {
res.status(503).json({ message: 'Synthetic error' });
});
const page = `<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Local CORS demo</title>
<h1>Local CORS demo</h1>
<p>Allowed origin: port 43101. Negative test: port 43104.</p>
<button id="data" type="button">Read data</button>
<button id="error" type="button">Read error</button>
<pre id="output" aria-live="polite"></pre>
<script>
async function load(path) {
const output = document.querySelector('#output');
try {
const response = await fetch('http://127.0.0.1:43102/demo/' + path, {
credentials: 'include',
headers: { 'X-Demo': 'reader' }
});
const data = await response.json();
output.textContent = JSON.stringify({
status: response.status,
result: response.headers.get('X-Demo-Result'),
data
}, null, 2);
} catch (error) {
output.textContent = 'Response cannot be read: ' + error.message;
}
}
document.querySelector('#data').addEventListener('click', () => load('data'));
document.querySelector('#error').addEventListener('click', () => load('error'));
</script>
</html>`;
const ui = express();
ui.get('/', (req, res) => res.type('html').send(page));
const uiOnly = process.argv.includes('--ui-only');
const servers = [
ui.listen(43101, '127.0.0.1'),
ui.listen(43104, '127.0.0.1'),
];
if (!uiOnly) servers.push(api.listen(43102, '127.0.0.1'));
process.on('SIGINT', () => {
for (const server of servers) server.close();
});
console.log('Demo: http://127.0.0.1:43101 and http://127.0.0.1:43104');
Open the displayed addresses explicitly through 127.0.0.1, rather than alternating with localhost. That would be a different origin. The custom request header triggers preflight. credentials: 'include' and credentials: true demonstrate the associated permission condition; they create no login and the demo sets no cookie.
What each option does
The origin function allows exactly the specified local origin. It generates no corresponding permission for other origins or requests without Origin. However, origin: false does not automatically end the business request with an access error. An ordinary GET can still reach the handler. This is expected: CORS is not authorisation. The official cors documentation explicitly states this boundary.
methods and allowedHeaders describe browser permission for preflight. They do not implement method or input validation in the actual API. exposedHeaders, by contrast, lets browser code read the named response header. These directions deliberately differ in the example.
maxAge: 5 keeps cached preflight duration short for the demo. It is not a general production recommendation. A real configuration must consider changeability, browser limits and preflight load together. Successful preflight also does not automatically make the later response readable; that still needs appropriate headers.
The results the demo should produce
The following results were confirmed sequentially on 27 September 2026 on macOS 26.0.1 in Chromium 147.0.7727.15, Firefox 148.0.2 and Playwright WebKit 26.4. WebKit here is a test engine, not native Safari or iPhone acceptance. The table also serves as a small acceptance template for your local repeat:
On narrow screens, scroll the table sideways.
| Your call | Expectation |
|---|---|
| Port 43101, ‘Read data’ | Preflight allowed, followed by JSON with status 200 and readable X-Demo-Result |
| Port 43101, ‘Read error’ | Response with status 503 and intended JSON error is readable |
| Port 43104, same browser call | Preflight not allowed, so no subsequent GET and no readable response |
| GET without Origin from a local HTTP tool | Synthetic data is retrievable, illustrating the demo's lack of authentication |
Inspect preflight and subsequent request separately in the browser. Also document the headers actually received. If the demo runs behind your own nginx proxy, it must not add a conflicting second origin permission. CORS in nginx helps with this case.
The additional negative test without X-Demo demonstrates the key boundary: a simple GET from port 43104 reached the handler, but JavaScript could not read its response. With a disallowed request header (X-Other), even the allowed origin failed at preflight. credentials: 'include' worked with the specific origin and Access-Control-Allow-Credentials: true; no login or real cookies were created or tested.
For the local nginx demo, the same program can provide only the browser interfaces: first stop a running full demo process with Ctrl+C, then start node demo.mjs --ui-only. This mode opens only ports 43101 and 43104; the API on 43102 then comes from your separately started nginx demo. The nginx version provides no X-Demo-Result, so the displayed result field may be null there.
Stop the demo in its terminal with Ctrl+C. The listeners belonging to that process close. The local HTTP demo is neither a publishable application nor tested cookie or CSRF protection.
Apply the rule to a real API
Replace the demo origin, routes and headers with the actual contract. If the client needs a bearer header, allow Authorization explicitly. Use credentials: true only for the intended credentials model and specific trusted origins. A public data service may instead have a different, deliberately open CORS rule.
Add existing authentication, object authorisation and input validation on the actual request path. Cookie-based changes still need CSRF protection. If rules load from a database, define clear error behaviour. A loading failure must not be treated as permission for every origin.
During rollout, test allowed clients, exceptions and error cases. Preserve the last secure configuration as a rollback path. On error, do not expand the list unchecked or set origin: true together with credentials. CORS error diagnosis helps identify the affected layer.
The API Security Check observes public header responses. It tests neither your Express middleware order nor authenticated browser or CSRF flows. Sources checked: 27 September 2026.
Sources and date
Sources checked: 27 September 2026. Links also appear alongside the statements they support.