A user clicks Save preference. The browser reports a CORS failure, fetch() rejects, and the interface concludes that the save failed. The tempting recovery is to retry. That can be the wrong incident response: depending on the exact request shape and server policy, the API may already have received, admitted, and committed the write even though JavaScript was forbidden from reading the response.
That distinction is documented browser behavior, not a new CORS feature. MDN’s CORS guide separates requests that can be sent without a preflight from the CORS headers required to expose a cross-origin response to script, while the Express cors middleware documentation describes middleware that sets response headers rather than a general-purpose server authorization gate.
This playbook answers one narrow operational question: did API state change despite the browser error? It is written for senior API developers, frontend engineers, and application-security reviewers who need evidence strong enough to approve, repair, hold, or reconcile a browser write.
The laboratory is deliberately small and owned: three explicit loopback origins, synthetic preference state, credentials: "omit", no real accounts, no disabled browser security, and an independent verifier that does not depend on the response under test. The fixture below is reproducible, but the publication research environment did not complete an eligible normal-browser end-to-end run with the pinned Express dependencies. Accordingly, browser outcomes below are documented expectations and fixture predictions, not claimed observations.
Separate the browser outcome from the API outcome
A CORS incident becomes tractable when “success” is split into events that can be evidenced separately. The browser boundary, server admission boundary, and business-effect boundary are not interchangeable. This is the practical layer beneath broader HTTP and API development foundations: the question here is not how HTTP works in general, but which event actually happened for one synthetic mutation.
Use one vocabulary throughout the incident:
Event | Evidence required in this lab |
Attempted | The harness recorded the frozen Fetch input and generated run ID. |
Dispatched | The browser sent the actual business request, not merely an OPTIONS preflight. |
Received | The API arrival log contains that run ID and the actual method/path. |
Admitted | A server-side admission record says the mutation was permitted. |
Committed | The append-only effect ledger contains the run ID and the resulting synthetic preference state. |
Exposed to script | fetch() resolved with a response object that the page could inspect under CORS. |
For this fixture, commit has a concrete meaning: after deterministic reset to preference = "compact", a unique run ID is appended to the committed-effect ledger and the in-memory state becomes "spacious" before the response is sent. That is intentionally stronger than “the handler returned 200.” If a production endpoint only queues work, define the production effect separately; a 200 response does not prove downstream completion.
The primary incident question is therefore: for run X, is there a committed-effect record? A rejected Fetch promise is browser evidence. It is not, by itself, transaction evidence. Conversely, a server arrival record proves receipt, not authorization or commit. Keeping those distinctions stable prevents a console message from silently becoming an incorrect business-state assertion.
Create isolated origins and a versioned test baseline
Use explicit loopback hosts and ports so the origin boundary is obvious and owned:
Role | Origin | Purpose |
Main UI | Executes the primary browser cases. | |
API | Receives preflights, writes, and verifier reads. | |
Alternate UI | Tests one different owned origin against the allowlist. |
Do not use file://, do not mix localhost and 127.0.0.1, and do not disable web security. Run with a clean normal browser profile without extensions, service workers, proxy rewriting, shared caches in front of the loopback services, or automated retries. For a denied-preflight case, use a fresh run URL and, where necessary, a fresh browser context rather than assuming an ordinary HTTP-cache switch has cleared the browser’s separate CORS-preflight cache. The Fetch Standard defines a distinct CORS-preflight cache.
This is the publication baseline, not a claim about what is “latest”. Chromium 144 is the exact browser binary measured on the research host, but it is not an accepted supported-browser execution result; the eventual acceptance run must use a normal browser version still supported by the team/vendor at execution time and record that exact version in a new batch.
Component | Exact baseline | Status for this article |
Node.js | 22.16.0 | Binary measured in the research environment; Node’s official release page identifies this specific release. |
npm | 10.9.2 | Binary measured in the research environment. |
Express | 5.2.1 | Exact fixture pin; the official Express site displayed 5.2.1 at the cutoff. It was not executed here. |
cors | 2.8.6 | Exact fixture pin; the Express middleware page displayed v2.8.6. It was not executed here. |
Chromium | 144.0.7559.96 | Browser binary measured; no eligible end-to-end result is claimed. |
OS | Debian GNU/Linux 13.3 (trixie) | Measured research-host baseline. |
Pin dependencies rather than accepting ranges:
{
"name": "cors-write-evidence-lab",
"private": true,
"type": "module",
"scripts": { "start": "node server.mjs" },
"dependencies": {
"cors": "2.8.6",
"express": "5.2.1"
}
}A reproducer should generate and commit the real package-lock.json by installing these exact pins, then record npm ls --depth=0 in the evidence packet. This article does not fabricate a lockfile that was not produced by an actual install. Before a later publication or rerun, re-check package, OS, and browser support and create a new dated baseline rather than silently relabeling these values as current.
Keep cookies out of the first experiment
Every main Fetch call explicitly uses credentials: "omit". That removes cookies and HTTP authentication credentials from the core experiment so SameSite rules, credentialed-CORS requirements, and application sessions cannot obscure whether the actual request was dispatched and whether its response was exposed. MDN documents that credential mode changes cross-origin credential behavior; cookie policy is a separate boundary.
Cross-origin and cross-site are not synonymous security concepts. This lab needs only an origin difference: scheme, host, and port form the origin tuple, and the different ports are sufficient for the browser’s same-origin/CORS boundary. Cookie-bearing endpoints belong in the later bounded extension, not in the baseline.
Instrument the server before applying CORS middleware
Instrumentation has to precede the policy under test. If logging is installed after middleware that can terminate a request, the absence of a log becomes ambiguous. The API therefore records each arrival before body parsing and before any route-specific cors middleware.
For every request, capture: run ID, method, pathname, Origin, Content-Type, arrival timestamp, Access-Control-Request-Method, and Access-Control-Request-Headers. Log OPTIONS exactly as OPTIONS; do not relabel it as a write. Then record server admission and committed effect as separate ledger events.
The run ID lives in the query string for every browser case, and also in the form or JSON body. Do not add X-Request-ID to the safelisted POST. A custom request header would alter the request shape and can itself require preflight, destroying the experiment that is supposed to measure a form-like POST without preflight. MDN’s safelist description limits manually set request headers and content types; application/x-www-form-urlencoded is among the safelisted content types, while application/json is not.
The minimum evidence record should resemble this logical schema:
arrival: { run, method, path, origin, contentType, arrivedAt, acrMethod, acrHeaders }
admission: { run, decision: "allow"|"deny", reason, decidedAt }
effect: { run, preference, committedAt }Do not infer the last record from either of the first two. A request can arrive and be denied; it can be admitted and later fail before the declared effect; or it can commit and then lose response exposure at the browser boundary.
The verifier is deliberately independent. It is a CLI read of /__verify?run=... on the API origin, without CORS dependency, and it returns only synthetic state and ledger entries. In production, an equivalent verifier might be an authoritative database query, audit record, or operation-status endpoint. The acceptance rule is the same: the evidence channel must not depend on the very cross-origin response being diagnosed.
Build an effect ledger with an independent read path
Create public/index.html and server.mjs, then install the exact pins:
mkdir cors-write-evidence-lab && cd cors-write-evidence-lab
npm init -y
npm install --save-exact [email protected] [email protected]
mkdir public
# Save the two files below, then preserve package-lock.json.
node --version
npm --version
npm ls --depth=0
chromium --version
cat /etc/os-release
npm startThe server is intentionally in-memory: database technology is not part of the acceptance question. Reset is deterministic, and a duplicate committed run ID is rejected.
// server.mjs
import express from "express";
import cors from "cors";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HOST = "127.0.0.1";
const UI_ORIGIN = "http://127.0.0.1:4100";
const ALT_ORIGIN = "http://127.0.0.1:4102";
const API_PORT = 4101;
const here = path.dirname(fileURLToPath(import.meta.url));
const api = express();
let state = { preference: "compact" };
const arrivals = [];
const admissions = [];
const effects = [];
const runOf = (req) => String(req.query.run ?? req.body?.run ?? "");
const log = (record) => console.log(JSON.stringify(record));
api.use((req, res, next) => {
const record = {
kind: "arrival",
run: String(req.query.run ?? ""),
method: req.method,
path: req.path,
origin: req.get("Origin") ?? null,
contentType: req.get("Content-Type") ?? null,
acrMethod: req.get("Access-Control-Request-Method") ?? null,
acrHeaders: req.get("Access-Control-Request-Headers") ?? null,
arrivedAt: new Date().toISOString()
};
arrivals.push(record);
log(record);
next();
});
api.use(express.urlencoded({ extended: false }));
api.use(express.json());
function admission(req, decision, reason) {
const record = {
kind: "admission", run: runOf(req), decision, reason,
decidedAt: new Date().toISOString()
};
admissions.push(record);
log(record);
}
function commit(req) {
const run = runOf(req);
if (!run) throw Object.assign(new Error("missing run"), { status: 400 });
if (effects.some((e) => e.run === run)) {
throw Object.assign(new Error("duplicate run"), { status: 409 });
}
state = { preference: String(req.body.preference) };
const effect = {
kind: "effect", run, preference: state.preference,
committedAt: new Date().toISOString()
};
effects.push(effect);
log(effect);
return effect;
}
function admittedWrite(req, res) {
if (req.body.preference !== "spacious") {
admission(req, "deny", "invalid synthetic preference");
return res.status(400).json({ error: "invalid-preference" });
}
admission(req, "allow", "lab policy");
const effect = commit(req);
return res.status(200).json({ ok: true, run: effect.run, state });
}
const uiCors = cors({
origin: UIORIGIN,
credentials: false,
methods: ["POST", "OPTIONS"],
allowedHeaders: ["Content-Type"],
maxAge: 0
});
const altOnlyCors = cors({
origin(origin, callback) {
callback(null, origin === ALT_ORIGIN ? origin : false);
},
credentials: false,
methods: ["POST", "OPTIONS"],
allowedHeaders: ["Content-Type"],
maxAge: 0
});
// Safelisted form POST: actual write is allowed, but response has no ACAO.
api.post("/write/simple-blocked", admittedWrite);
// JSON: preflight gets 204 but no CORS permission, so the browser should stop.
api.options("/write/json-preflight-denied", (_req, res) => res.sendStatus(204));
api.post("/write/json-preflight-denied", admittedWrite);
// JSON: preflight is allowed; actual write has no CORS response headers.
api.options("/write/json-response-blocked", uiCors);
api.post("/write/json-response-blocked", admittedWrite);
// JSON: a readable explicit server rejection.
api.options("/write/server-denied", uiCors);
api.post("/write/server-denied", uiCors, (req, res) => {
admission(req, "deny", "deliberate server control");
res.status(403).json({ error: "server-denied", run: runOf(req) });
});
// Positive control: preflight and actual response both opt in.
api.options("/write/positive", uiCors);
api.post("/write/positive", uiCors, admittedWrite);
// Middleware comparison: disallowed origin receives no CORS headers,
// but cors itself does not deny the route.
api.post("/write/middleware-only", altOnlyCors, admittedWrite);
// Real admission comparison: server checks policy before mutation.
api.post("/write/admission-alt-only", altOnlyCors, (req, res) => {
if (req.get("Origin") !== ALT_ORIGIN) {
admission(req, "deny", "origin not admitted by application policy");
return res.status(403).json({ error: "origin-not-admitted" });
}
return admittedWrite(req, res);
});
// no-cors demonstration only; not a repair strategy.
api.post("/write/no-cors", admittedWrite);
api.get("/__verify", (req, res) => {
const run = String(req.query.run ?? "");
res.json({
run,
state,
arrivals: arrivals.filter((x) => x.run === run),
admissions: admissions.filter((x) => x.run === run),
effects: effects.filter((x) => x.run === run)
});
});
api.post("/__reset", (req, res) => {
if (req.get("Origin")) return res.sendStatus(403);
state = { preference: "compact" };
arrivals.length = 0;
admissions.length = 0;
effects.length = 0;
res.sendStatus(204);
});
api.use((err, req, res, next) => {
log({ kind: "error", run: runOf(req), message: err.message });
if (res.headersSent) return next(err);
res.status(err.status ?? 500).json({ error: "lab-error" });
});
api.listen(API_PORT, HOST, () =>
console.logAPI: http://${HOST}:${API_PORT})
);
for (const port of [4100, 4102]) {
const ui = express();
ui.use(express.static(path.join(here, "public"), {
etag: false,
lastModified: false
}));
ui.listen(port, HOST, () => console.logUI: http://${HOST}:${port}));
}The browser harness freezes the meaningful Fetch options and performs no retry:
<!-- public/index.html -->
<!doctype html>
<meta charset="utf-8">
<title>CORS write evidence lab</title>
<button data-case="simpleBlocked">Safelisted response blocked</button>
<button data-case="preflightDenied">JSON preflight denied</button>
<button data-case="jsonResponseBlocked">JSON response blocked</button>
<button data-case="serverDenied">Server denial</button>
<button data-case="positive">Positive control</button>
<button data-case="middlewareOnly">Middleware only</button>
<button data-case="originAdmission">Explicit admission</button>
<button data-case="noCors">no-cors opaque</button>
<pre id="out"></pre>
<script type="module">
const API = "http://127.0.0.1:4101";
const out = document.querySelector("#out");
const formInit = (run, mode = "cors") => ({
method: "POST",
mode,
credentials: "omit",
body: new URLSearchParams({ run, preference: "spacious" })
});
const jsonInit = (run) => ({
method: "POST",
mode: "cors",
credentials: "omit",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ run, preference: "spacious" })
});
const cases = {
simpleBlocked: ["/write/simple-blocked", formInit],
preflightDenied: ["/write/json-preflight-denied", jsonInit],
jsonResponseBlocked: ["/write/json-response-blocked", jsonInit],
serverDenied: ["/write/server-denied", jsonInit],
positive: ["/write/positive", jsonInit],
middlewareOnly: ["/write/middleware-only", formInit],
originAdmission: ["/write/admission-alt-only", formInit],
noCors: ["/write/no-cors", (run) => formInit(run, "no-cors")]
};
async function runCase(name) {
const run = ${name}-${crypto.randomUUID()};
const [path, makeInit] = cases[name];
const url = ${API}${path}?run=${encodeURIComponent(run)};
const attempt = { name, run, pageOrigin: location.origin, url, init: makeInit(run) };
out.textContent += ${JSON.stringify({ attempted: attempt }, null, 2)}\n;
try {
const response = await fetch(url, attempt.init);
const body = await response.text();
out.textContent += ${JSON.stringify({
run, fetch: "resolved", type: response.type,
status: response.status, ok: response.ok, body
}, null, 2)}\n;
} catch (error) {
out.textContent += ${JSON.stringify({
run, fetch: "rejected", errorName: error.name
}, null, 2)}\n;
}
}
document.addEventListener("click", (event) => {
const name = event.target.dataset.case;
if (name) void runCase(name);
});
</script>Avoid changing the request while observing it
For each case, reset with CLI, click exactly one harness button, copy its run ID, then query the verifier:
curl -sS -X POST http://127.0.0.1:4101/__reset
# Click one browser case and copy its generated run ID.
curl -sS 'http://127.0.0.1:4101/__verify?run=PASTE-RUN-ID'Do not “improve” observability by adding an Authorization, X-Request-ID, or other custom browser header between runs. Do not switch a form body to JSON while keeping the same case label. Those edits can change preflight behavior. Preserve the exact Fetch initialization with the evidence packet, and treat any modified request shape as a new experiment.
Before execution, author the expected-outcome row. If browser capture, arrival logging, or verifier evidence is missing, write unknown rather than reverse-engineering an answer from what you hoped would happen. After the batch, stop the Node process, close the dedicated browser context, and retain or delete the synthetic lab directory according to the evidence-retention plan; process termination clears this fixture’s in-memory state.
Send a safelisted POST whose response is not exposed
The first case establishes the most important failure mode: CORS request sent, response blocked. The page makes a cross-origin POST with a URLSearchParams body, no author-added non-safelisted header, mode: "cors", and credentials: "omit". Its media type is form-encoded, so it fits the CORS-safelisted form-like request shape documented by MDN. Such a request does not require the browser to obtain preflight permission first. The server still needs an appropriate Access-Control-Allow-Origin response header before the browser can expose the response to JavaScript.
The case deliberately omits that allowing response header while the application admits and commits the synthetic write.
Documented behavior. A safelisted cross-origin request can be transmitted without preflight, yet its response can remain unavailable to script when CORS response sharing is not allowed.
Fixture prediction. /write/simple-blocked should receive the actual POST; the admission ledger should say allow; the effect ledger should contain the run; state should be spacious; and the browser Fetch should reject because the actual response lacks the allowing CORS header.
Publication-run observation. Not claimed. This fixture was not completed end-to-end in an eligible normal browser with the pinned dependencies during article research. The verifier procedure above is the required measurement before converting this prediction into an observed pass.
The classification is about the complete request shape, not the word “POST”:
Request shape from the owned UI | Preflight expected? | Why |
POST + form-encoded body, no custom header | No | Method, author headers, and content type remain safelisted. |
POST + text/plain, no custom header | No | Safelisted content type. |
POST + browser-formed multipart/form-data | No, if otherwise safelisted | Form media type is safelisted. |
POST + application/json | Yes | JSON is not a safelisted Content-Type. |
Form POST + X-Request-ID | Yes | Custom header is not CORS-safelisted. |
PUT | Yes | PUT is not a safelisted method. |
POST + Authorization | Yes | Authorization is not a CORS-safelisted request header. |
That is why the request ID is in the URL/body: adding a diagnostic header would turn the baseline into a different CORS experiment.
Reject a preflight before the JSON POST is dispatched
Now change one relevant property: send the same desired preference as JSON. Content-Type: application/json makes this a preflighted CORS request. Before dispatching the business POST, the browser uses OPTIONS to ask whether the API permits the method and non-safelisted header. MDN describes this preflight sequence, and the Fetch Standard specifies the permission checks that gate the actual request.
/write/json-preflight-denied returns a network-successful 204 to OPTIONS but deliberately provides no allowing CORS headers. That distinction matters: HTTP success for the OPTIONS exchange is not equivalent to a successful CORS permission check.
Documented behavior. When the preflight CORS check fails, the browser does not proceed with the actual cross-origin request that required permission.
Fixture prediction. The server arrival log should contain one OPTIONS row for the run. It should contain no POST row, no admission row for the mutation, and no effect. Fetch should reject because the CORS fetch fails before the business request is dispatched.
Publication-run observation. Not claimed; the row remains expected until reproduced with reliable server logs and the verifier.
Use a fresh run URL for every attempt. Where browser preflight caching could contaminate repeated tests of the same target policy, begin a fresh browser context as well. The CORS-preflight cache is a browser-internal permission cache, not ordinary representation caching, so this playbook does not turn the experiment into an HTTP-cache exercise.
Do not count OPTIONS as the business request
Join evidence by both run identity and method. A record such as run=preflightDenied-... method=OPTIONS proves only that the permission probe reached the API. It does not prove that the preference POST arrived, was admitted, or committed.
The denied-preflight control passes only if the expected server policy was to deny that browser request and the ledger shows zero committed effects. If the POST is absent but logging was unreliable, the correct state is hold, not “proved blocked.” Absence is evidence only when the observation channel is known to cover the event that would have occurred.
The preflight handler itself must be side-effect free. It can report policy, but it must not increment counters, enqueue the business operation, or alter the synthetic preference. Otherwise an OPTIONS probe would become a mutation and invalidate the diagnostic separation.
Allow preflight but fail exposure of the actual response
A preflight success does not imply that the eventual response will be readable. /write/json-response-blocked uses cors on OPTIONS, granting the main UI permission to send the JSON POST, but deliberately omits CORS middleware from the actual POST response.
Documented behavior. CORS is a response-sharing protocol layered onto HTTP. Preflight can authorize the browser to proceed with a request, while the actual response still has to satisfy the CORS check before it is exposed to script.
Fixture prediction. The ledger should show OPTIONS, then POST, then admission=allow, then one committed effect. The page should nevertheless receive a rejected Fetch outcome because the actual response does not opt in to the UI origin.
This case is especially useful during incident diagnosis because its browser symptom can resemble the denied-preflight case while the business outcome is the opposite. In one case there is no POST; in the other, the write committed. Browser console text cannot resolve that ambiguity.
The positive control /write/positive keeps the JSON payload and desired state identical but applies the same allowing CORS policy to both preflight and actual response. Its expected chain is OPTIONS → POST → allow → commit → readable 200. Run it after reset with a distinct run ID. If that control does not behave as expected in the reproduction environment, hold the entire batch; do not interpret the negative rows until the harness and policy have been validated.
This is CORS preflight write validation in the operational sense: the acceptance test validates both permission stages and the independent business effect, rather than treating a browser-visible response as the sole source of truth.
Test middleware configuration versus server admission
The Express cors package is frequently given authority it does not have. Its documentation is explicit that the middleware configures CORS response headers, and that CORS is not a general API access-control mechanism for non-browser clients.
That statement needs one important browser qualification: a failed preflight can prevent the browser from dispatching the actual request at all. But once an actual request reaches Express, merely returning no Access-Control-Allow-Origin header is not the same thing as denying application admission.
/write/middleware-only demonstrates the distinction. The route configures cors to allow only the alternate owned UI at port 4102. When the main UI at port 4100 sends the safelisted form POST, the dynamic origin callback returns false, so the middleware adds no CORS permission for that origin. It still calls the next handler. The predicted result is a committed effect plus an unreadable browser response.
By contrast, /write/admission-alt-only performs an explicit application decision before mutation. From port 4100 it records deny and returns 403 without calling commit(). Whether the browser can read that 403 is a response-policy question; the no-effect result comes from server admission, not from the browser’s CORS enforcement.
This is where CORS versus authorization must remain cleanly separated. An Origin value can be an input to a browser-origin policy or a bounded CSRF defense, but it is not caller identity. A non-browser client can construct request headers and is not governed by browser CORS. Authentication and authorization must therefore remain server-side controls appropriate to the endpoint. Those broader secure API design practices belong around that application boundary; this lab isolates one admission decision rather than reproducing a complete security checklist.
Compare a browser with an authorized CLI control
An owned local curl request is useful because it shows the API’s HTTP behavior without browser CORS enforcement. It is a diagnostic control, not a bypass technique and not evidence that an unauthorized caller should be accepted.
curl -i -X POST \
'http://127.0.0.1:4101/write/middleware-only?run=cli-middleware-001' \
-H 'Origin: http://127.0.0.1:4100' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'run=cli-middleware-001&preference=spacious'
curl -sS \
'http://127.0.0.1:4101/__verify?run=cli-middleware-001'If the route returns HTTP 200 to the CLI and the verifier shows a committed effect while the browser version is unreadable, that is exactly the distinction being tested: the server admitted the operation, while the browser refused to expose its response. Run the explicit-admission route with a fresh ID to confirm the opposite server decision. Never infer authorization from the ability to type an Origin header into a CLI.
Explain why no-cors does not repair an API contract
A common workaround suggestion is mode: "no-cors". It does not turn a protected cross-origin API into a readable one. MDN’s Request.mode reference explains that no-cors restricts the request shape and makes the resulting response opaque to script. The Fetch Standard defines an opaque filtered response with status 0, an empty header list, and an inaccessible body from the caller’s perspective.
The harness includes /write/no-cors only to rebut the workaround. Its form-like POST is expected to be transmitted, and the server may commit it, but the page receives an opaque response rather than a usable API result. response.status === 0 is therefore not proof that the server rejected the request, and it is equally not proof that the mutation succeeded. The ledger decides the effect.
For an application API, a durable contract normally needs a readable authorized response or another deliberately designed result channel. Replacing cors mode with no-cors simply discards the script-visible evidence that the frontend usually needs for validation errors, identifiers, version data, and reconciliation.
Do not broaden the request to make no-cors work. Its method/header constraints are themselves part of Fetch behavior, and it does not create server authorization. The correct repair is to align server admission with an explicit response-sharing policy for the intended UI, then verify committed state independently when a historical request’s outcome is uncertain.
Reconcile network evidence with committed state
The acceptance matrix should be authored before execution and filled from three independent surfaces: the browser attempt/result, the API arrival/admission logs, and the effect verifier. That structure is narrower than a general observability architecture, even though disciplined API decision logs and observability provide the production foundations for making the same correlation possible.
The expected laboratory ledger is:
Case | OPTIONS received | Actual POST received | Admission | Committed effect | Response readable to script | Initial decision |
JSON preflight denied | Yes | No | N/A | No | No | Approve denial if intended; otherwise fix preflight policy. |
Safelisted POST, response blocked | No | Yes | Allow | Yes | No | Reconcile operation; fix response policy. |
JSON preflight allowed, actual response blocked | Yes | Yes | Allow | Yes | No | Reconcile operation; fix actual response policy. |
Explicit server denial | Yes | Yes | Deny | No | Yes, expected 403 | Approve server control if intended. |
Positive JSON control | Yes | Yes | Allow | Yes | Yes, expected 200 | Approve scoped browser/API contract. |
Evidence missing | Unknown | Unknown | Unknown | Unknown | Unknown | Hold; do not replay from browser symptom alone. |
These are expected rows, not reported measurements. After reproduction, replace each expectation with captured values and preserve the original expected table beside it. A discrepancy is a test result, not something to edit away.
For each run, save the generated URL, frozen Fetch options, DevTools Network entry, browser console context, server JSON lines, and verifier result under the same run ID. Browser tooling can tell you whether an OPTIONS or POST appeared from the client perspective; the server log tells you what arrived; the application ledger tells you what was admitted and committed. None should silently substitute for another.
The explicit-commit definition also prevents a misleading shortcut: a 200 is not automatically a committed business effect. In this lab, commit() changes state and appends the effect before sending 200. If a real API returns 202 after enqueueing work, define whether the relevant effect is “durably accepted to queue” or “final state changed,” then use evidence appropriate to that definition. Do not import the lab’s synchronous contract into an asynchronous system.
Treat an unresolved effect as unknown, not failed
If a user reports a CORS error and the operation ID cannot be found because logs were dropped, the verifier is unhealthy, or the run ID was never captured, the state is unknown. It is not “failed.” A rejected Fetch promise alone cannot authorize resubmission.
Perform a bounded reconciliation read first. For this fixture, that is one /__verify query for the run ID. In production, use the system’s authoritative status/effect record. Only after the evidence says the operation was absent should an operator consider an authorized resubmission under the application’s existing duplicate-safety policy.
If the ledger cannot distinguish “absent” from “unobserved,” hold. That condition is operationally uncomfortable, but it is more accurate than manufacturing certainty from the browser boundary. Preserving unknown as a first-class state is what prevents a network-looking error from becoming an accidental duplicate write.
Add authentication and CSRF boundaries without conflating them
The credential-free baseline is not a recommendation to build unauthenticated APIs. It is an experimental control. Real browser endpoints can add cookies, bearer credentials, authorization rules, CSRF defenses, and cookie SameSite behavior, each of which can change what is sent or accepted.
For a credentialed cross-origin Fetch, browser and server CORS configuration must be designed together. MDN documents explicit-origin and credential requirements and notes that third-party cookie policies can apply independently of CORS. That is another reason not to introduce cookies into the first experiment: a missing cookie could otherwise look like a CORS admission problem when the actual server result is an authentication failure.
CSRF is also a server-side application-security problem, not something to collapse into Access-Control-Allow-Origin. The OWASP Cross-Site Request Forgery Prevention Cheat Sheet describes server defenses including token strategies, origin/Referer validation considerations, and SameSite as defense in depth rather than a universal substitute.
Treat these controls as separate questions:
Authentication: which principal, if any, did the server authenticate?
Authorization: may that principal perform this mutation?
CSRF defense: should this browser-initiated state-changing request be accepted in its cross-site/origin context?
CORS: may JavaScript at this origin read the cross-origin response, and, for preflighted shapes, may the browser proceed with the request?
An Origin allowlist alone does not authenticate a caller. Nor should this lab be pointed at another site to “test CORS.” The bounded extension belongs only on endpoints and browser origins the team owns and is authorized to test.
Choose the correction from a decision matrix
Do not respond to every CORS incident by adding a permissive header. First classify which boundary failed, then choose the smallest correction consistent with the intended application contract. That discipline also fits reliable third-party integration boundaries: a caller contract and a server admission contract should be explicit rather than repaired by indiscriminate exposure.
Evidence | Decision | Required correction |
Intended UI gets readable success; correct effect exists | Approve | Preserve exact origin, method, header, credential, and admission policy. |
Disallowed request reaches mutation because server relied on CORS headers as authorization | Fix server admission | Add/repair server-side authentication, authorization, or explicit admission before mutation. Do not rely on absent ACAO. |
Intended request is admitted and commits, but actual response is unreadable | Reconcile, then fix response policy | Resolve the historical run from the effect ledger; correct ACAO/credential response policy narrowly for the intended UI. |
Intended JSON call never dispatches because preflight permission is wrong | Fix response/preflight policy | Grant only required origin, method, and headers; rerun positive and denied controls. |
Server deliberately denies and no effect exists | Approve if policy is correct | Keep denial; make the error readable only when that is part of the intended browser contract. |
Effect or arrival evidence is incomplete | Hold | Repair evidence first; do not infer failure and do not authorize replay. |
Avoid the emergency anti-pattern Access-Control-Allow-Origin: * simply to make the console quiet, especially when the intended contract is a narrow browser allowlist. The response policy should expose only the origins required by the application, and credentialed responses have additional browser restrictions.
A safe rollback treats middleware and application admission policy as one change set even though they are different controls. Restore the last known declared origin/method/header response policy and the corresponding server admission rules; rerun denied, blocked-response, and positive controls. Rolling back only the visible CORS header can leave an admission regression in place, while rolling back only admission can leave the frontend unable to inspect legitimate responses.
Prevent retries from hiding the original result
Automatic retries stay disabled in this baseline. They would create a second mutation attempt before the first attempt’s state had been reconciled, making the test answer harder to interpret.
For an unresolved production write, hand the run/request identity to the application’s existing idempotency or reconciliation mechanism if one exists. This article does not rebuild duplicate-delivery prevention. The minimum rule is narrower: do not convert a CORS Fetch rejection into permission to replay. First establish whether the original operation committed.
After a correction, use a fresh run ID. Do not overwrite or reuse the uncertain operation’s identifier simply to make the retest green; preserve the original evidence chain and make the new acceptance run independently auditable.
Assign ownership to both sides of the browser boundary
A reliable handoff names owners for evidence rather than making “CORS” a frontend-only ticket.
The frontend owner freezes method, URL, mode, credential mode, headers, body encoding, retry behavior, and initiating page origin. They preserve browser Network and console evidence, but they do not label a rejected Fetch as a failed transaction without server confirmation.
The API owner owns arrival logging, middleware ordering, admission records, response policy, and the committed-effect verifier. They ensure OPTIONS is distinguishable from the business method and that a declared commit has a durable production analogue. Where real APIs use queued or asynchronous work, the team should separately define what its operation record means; that is related to asynchronous endpoint behavior and performance, but performance tuning is outside this experiment.
The application-security reviewer owns the declared cross-origin trust model and its relationship to authentication, authorization, and CSRF defenses. They reject “CORS as authorization,” overly broad emergency origins, and test procedures that target systems the team does not own.
Revalidate the matrix after changes to CORS middleware ordering, allowed origins, accepted content types, authentication/credential mode, browser versions, or application admission. Retain an evidence packet containing batch identifier 2026-09-23, baseline versions, exact Fetch input, run ID, browser capture, server records, verifier result, expected row, observed row, and final decision. Redact credentials and sensitive payload fields in real incidents; the laboratory intentionally has none.
A useful support rule is simple: a CORS console message describes a browser access boundary, not an authoritative transaction result. Support should capture the operation identity and trigger reconciliation instead of telling a user to retry reflexively.
Connect the exercise to practical API engineering
The final acceptance handoff should be unambiguous. Approve when the intended browser origin can execute the declared request, the server’s admission decision is correct, the declared effect is verifiable, and the response is exposed as the contract requires. Fix server admission when a disallowed operation can mutate state. Reconcile when an operation may already have committed but its browser result was hidden. Hold whenever the evidence cannot distinguish absence from an unobserved effect.
Those habits depend on fundamentals that extend beyond CORS: request design, authentication and authorization, API testing, error handling and logging, and API security. Engineers who want structured practice in those foundations can review Refonte Learning’s APIs Developer Fundamentals. The programme page lists REST API development, GraphQL, authentication and authorization, database integration, documentation/testing, error handling/logging, versioning, performance, and API security; it presents a three-month format at 10–12 hours per week with basic programming knowledge recommended. This is a foundation path, not a claim that the programme teaches this exact CORS/CSRF laboratory or Express middleware exercise.
The engineering habit to carry forward is narrower and more durable: when a browser says it could not use a cross-origin response, reconcile the request against server admission and committed state before deciding what happened or sending it again.
