An interactive playground for the same-origin policy. It starts real HTTP servers on several loopback origins, fires real cross-origin requests between them from your own browser, and shows you two things side by side that you can normally never see together:
- what actually crossed the wire, reported by the receiving server — including the
OPTIONSpreflight, which the Fetch API hides from JavaScript entirely; and - what
fetch()was allowed to give your code — which is often far less, and in a failure is nothing at all.
That contrast is the entire point. Almost every hour lost to CORS is lost because those two views were assumed to be the same view.
same-origin-simulator 1.0.0
front-end http://localhost:8080
same server http://127.0.0.1:8080 <- a different origin, on purpose
API http://localhost:8081
bound: app, api, app-v6, api-v6
open http://localhost:8080 and pick a scenario
press Ctrl-C to stop
- Why this exists
- Requirements
- Running it
- Usage and flags
- How CORS actually works
- The scenarios
- Worked example
- Architecture: why three origins
- Free play and the control API
- Exit codes
- Security note
- Limitations, and what this deliberately is not
- Tests
- Further reading
- Contributing
- Licence
CORS is taught badly, and mostly for one reason: the evidence is split across two places and nobody looks at both.
The browser tells you Access to fetch has been blocked by CORS policy. Your server logs
say 200 OK. Both are correct. The request was sent, the handler ran, side effects
happened, a response came back — and then the browser refused to hand it to your script.
The failure is deliberately opaque: fetch() rejects with a bare TypeError carrying no
status, no headers and no body, because leaking the status of a cross-origin response
would itself be an information leak.
And when a preflight is involved it gets worse, because the request that failed is one your code never wrote. You cannot log it, intercept it, or see it in a promise. It exists only in the Network panel and in the server's own logs.
This tool puts both halves on one screen. The API server records every request it
receives — preflights included — and the page shows that log next to the exact Response
object your code was handed. Reading a scenario takes about a minute; the mechanics tend
to stop being mysterious after two or three.
It is a teaching instrument rather than a diagnostic one. Everything runs on loopback, nothing is sent anywhere, and the server is deliberately misconfigurable in ways you would never ship.
- Python 3.9 or newer.
- A browser.
Nothing else. No third-party packages, no build step, no network access. The Python side is standard library only and the front-end is vanilla HTML, CSS and JavaScript served as plain files.
Clone the repository and run the launcher directly:
git clone <your-clone-url> same-origin-simulator
cd same-origin-simulator
python3 simulate
Your browser opens at http://localhost:8080. Pick a scenario from the left, press
Run this scenario, and read the five panels.
If you would rather type simulate than python3 simulate, mark it executable and put
it on your PATH:
chmod +x simulate
ln -s "$PWD/simulate" ~/.local/bin/simulate
To stop it, press Ctrl-C.
Open the browser devtools Network panel while you use this. The simulator shows you the server's view; the Network panel shows you the browser's. Seeing the same preflight in both is what makes it stick.
usage: simulate [-h] [--port N] [--api-port N] [--host HOST] [--no-browser]
[--log-level {quiet,info,debug}] [--version]
| Flag | Default | What it does |
|---|---|---|
--port N |
8080 |
Port for the front-end origin. The 127.0.0.1 variant of this same port becomes the second origin. |
--api-port N |
8081 |
Port for the configurable API origin. Must differ from --port; the two origins are only distinct because their ports are. |
--host HOST |
127.0.0.1 |
Loopback interface to bind. Only localhost, 127.0.0.1 and ::1 are accepted — see the security note. |
--no-browser |
off | Do not open a browser window on start. Useful over SSH or in a container. |
--log-level |
info |
quiet prints nothing but errors. info prints the startup banner. debug additionally logs every HTTP request both servers handle to stderr. |
--version |
Print the version and exit. | |
-h, --help |
Full help, including the origin map and exit codes. |
If a port is taken you get a specific message and a suggested alternative rather than a traceback:
$ python3 simulate --port 8080
simulate: error: port 8080 is already in use ([Errno 98] Address already in use).
Try a different front-end port, for example:
python3 simulate --port 8090 --api-port 8091
The model in four parts. Every scenario in the tool is an instance of one of them.
1. An origin is (scheme, host, port), compared as written.
Not as resolved. http://localhost:8080 and http://127.0.0.1:8080 are different
origins even though they are the same socket on the same machine, because origin
comparison happens on the host string and never consults DNS. http and https on the
same host differ. Port 8080 and port 8081 differ. There is no partial matching, no
subdomain rule, no "same machine" concept.
2. The policy governs reading, not sending.
This is the part that surprises people. The browser sends your cross-origin request. The
server receives it and runs its handler. The response comes back. Then the browser
checks whether the response granted your origin permission to read it, and if not, throws
the whole thing away before your code sees it. A blocked POST still created the record.
3. Some requests get a preflight first.
A request is simple — sent directly, no preflight — only when all of these hold:
- the method is
GET,HEADorPOST; - every author-set header is CORS-safelisted (
Accept,Accept-Language,Content-Language,Content-Type,Range, plus the client hints); - if
Content-Typeis set, its value is one ofapplication/x-www-form-urlencoded,multipart/form-dataortext/plain; - the body is not a
ReadableStream, and there is no listener on anXMLHttpRequestupload object.
Break any of those and the browser first sends an OPTIONS request carrying
Access-Control-Request-Method and, if relevant, Access-Control-Request-Headers. The
server must answer with a 2xx status and matching Access-Control-Allow-Methods /
Access-Control-Allow-Headers. A 3xx or 4xx fails the preflight no matter how good the
headers are, and redirects are never followed on a preflight.
The boundary is not arbitrary. An HTML form can already cross-origin POST those three
content types without any JavaScript, so preflighting them would protect nothing that is
not already exposed. application/json cannot be produced by a plain form, so it gets the
extra round trip.
Preflights are cached per origin, per URL, per method and per header set, for
Access-Control-Max-Age seconds. Browsers clamp that value rather than reject it:
Chromium at 7200 seconds, Firefox at 86400, Safari at 600.
Preflight requests never carry credentials, even when the request they authorise will.
If your authentication middleware runs before your CORS handling, it will see an anonymous
OPTIONS and reject it — which is the single most common preflight bug in production.
4. Credentials raise the bar, and the response is filtered.
When a request carries cookies or TLS client certificates:
Access-Control-Allow-Origin: *is rejected. The server must name one concrete origin. Wildcard plus ambient cookies would mean "any site the user visits may read this user's authenticated data".Access-Control-Allow-Headers: *andAccess-Control-Allow-Methods: *are read literally, as a header or method actually named*.Access-Control-Allow-Credentials: trueis required in addition.
And whether or not credentials are involved, your script can only read the CORS-safelisted
response headers — Cache-Control, Content-Language, Content-Length,
Content-Type, Expires, Last-Modified, Pragma — unless the server names others in
Access-Control-Expose-Headers. Everything else is stripped from the Headers object
before your code touches it, silently, returning null rather than raising.
One more rule that bites in production: whenever Access-Control-Allow-Origin is computed
from the request's Origin, the response must also carry Vary: Origin, or a shared
cache will eventually hand one origin's ACAO value to a different origin.
Fifteen one-click lessons. Each reconfigures the API origin, fires a real request, and explains what happened and why.
| Scenario | What it teaches |
|---|---|
| Simple GET, allowed | The minimum successful cross-origin read: one request, no preflight, ACAO echoing the origin, plus the Vary: Origin that has to accompany it. |
| Simple GET, blocked | The same request without ACAO. The server answers 200 with a full body and JavaScript still gets nothing but a TypeError. The clearest possible demonstration that the policy blocks reading, not sending. |
| Scenario | What it teaches |
|---|---|
| Custom request header | X-Request-Id is not safelisted, so a plain GET stops being simple. Two requests on the wire where JavaScript wrote one. |
Content-Type: application/json |
The header is safelisted; that value is not. Why "POST is a simple method" is a trap. |
DELETE |
Methods outside GET/HEAD/POST preflight unconditionally, with no headers involved. |
| Scenario | What it teaches |
|---|---|
| Credentialed request meets a wildcard | ACAO: * and ACAC: true are each valid and are rejected together, specifically because credentials are in play. Explains the CSRF-with-readback primitive the rule exists to prevent. |
| Credentialed request done correctly | The three parts that are all required: one concrete allowlisted origin, ACAC: true, and Vary: Origin. |
| Scenario | What it teaches |
|---|---|
| Preflight returns 403 | Perfect CORS headers, non-2xx status, hard failure. The signature bug of auth middleware ordered in front of CORS handling — works in curl, works in Postman, fails only in a browser. |
| Preflight is redirected (301) | Redirects are never followed on a preflight. Caused in the wild by HTTPS upgrades, trailing-slash normalisation and load-balancer canonicalisation — all invisible to server-side tests. |
| Scenario | What it teaches |
|---|---|
| Max-Age caching | Fires the identical request twice and you watch the second one skip the preflight: three wire entries for two fetch() calls. Directly observable rather than merely asserted, and the highlight of the set. Covers the per-browser clamps. |
| Scenario | What it teaches |
|---|---|
| Reading a header without Expose-Headers | X-Total-Count: 137 is plainly there in the raw headers panel and response.headers.get() returns null. No error, no warning. Why pagination counts and rate-limit headers vanish. |
| Scenario | What it teaches |
|---|---|
null origin from a sandboxed iframe |
A genuinely sandboxed frame in an opaque origin sends Origin: null, and the API is set to trust it. Demonstrates that an allowlist containing null grants access to everyone while looking narrow in review. |
Opaque response from mode: 'no-cors' |
fetch() resolves, with type: 'opaque', status: 0 and nothing readable. Why "just add no-cors" is the worst advice in this area. Also shows that a no-cors GET sends no Origin header at all. |
| localhost vs 127.0.0.1 | The same server, the same port, the same bytes — and a blocked request, because the host strings differ. |
Duplicate Access-Control-Allow-Origin |
The app sets it, the proxy sets it too, both correct, and the pair joins into origin, origin which matches nothing. The raw headers panel shows both field lines. |
Select Max-Age caching and press Run. The five panels fill in.
Panel 1 — the JavaScript that runs. Real, copyable, and the actual code path taken:
// Executed in a document served from http://localhost:8080
try {
for (let i = 0; i < 2; i++) {
const response = await fetch("http://localhost:8081/api/widgets", {
method: "GET",
mode: "cors",
credentials: "omit",
headers: {
"X-Request-Id": "cached"
}
});
console.log(response.status, await response.text());
}
} catch (error) {
// A CORS failure lands here as a bare TypeError:
// no status, no headers, no body.
console.error(error.name, error.message);
}Panel 2 — what crossed the wire. Reported by the API server, not by the page:
# Kind Method Path Origin sent Preflight asks for Status
1 PREFLIGHT OPTIONS /api/widgets http://localhost:8080 method GET; headers x-request-id 204
2 ACTUAL GET /api/widgets http://localhost:8080 not applicable 200
3 ACTUAL GET /api/widgets http://localhost:8080 not applicable 200
3 request(s) reached the API origin: 1 preflight, 2 actual. JavaScript called fetch() 2 time(s).
Only the first fetch paid for a preflight — the rest were served from the browser's
preflight cache, which is what Access-Control-Max-Age buys you.
Panel 3 — raw response headers, exactly as emitted:
Access-Control-Allow-Origin: http://localhost:8080
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: X-Request-Id
Access-Control-Max-Age: 600
Vary: Origin
Cache-Control: no-store
Panel 4 — what JavaScript got. response.type cors, status 200, ok true,
and the list of headers the browser was willing to expose.
Panel 5 — the verdict, plus the console text your browser should be showing, plus links to the relevant background reading.
Now run Simple GET, blocked and compare panel 2 with panel 4: a healthy 200 OK on
the wire, and a bare TypeError: Failed to fetch in your code.
+---------------------------------------+
browser | simulate (one Python process) |
+-------------------------+ | |
| http://localhost:8080 |<---->| app server 127.0.0.1:8080 + [::1] |
| index.html, app.js | | www/ + /__origins.json |
| | | + /__scenarios.json |
| +-------------------+ | | |
| | iframe | | | |
| | http://127.0.0.1 |--+----->| (same server, different origin) |
| +-------------------+ | | |
| +-------------------+ | | |
| | sandboxed iframe | | | |
| | opaque origin |--+----->| api server 127.0.0.1:8081 + [::1] |
| +-------------------+ | | /api/* CORS per config |
| | | /__control/* config + wire log |
+-------------------------+ +---------------------------------------+
Why a separate API port. Two origins only differ if their scheme, host or port differ. Serving the API from a second port is the simplest way to create a genuinely cross-origin relationship on one machine, with no hosts-file editing, no TLS and no DNS.
Why the 127.0.0.1 origin. The same server, reached by its other name, is a second
origin for free. That makes the localhost-versus-127.0.0.1 lesson demonstrable rather
than merely assertable — you can see one succeed and the other fail against the same
allowlist. Requests from it are fired inside a hidden iframe pointed at that origin, so
the page never has to reload.
Why the sandboxed iframe. sandbox="allow-scripts" without allow-same-origin puts
a document in an opaque origin, and requests from an opaque origin carry Origin: null.
That is a real browser behaviour, not a simulation: it is exactly how an attacker
produces a null origin, in three lines of HTML.
Why both IPv4 and IPv6 loopback. On many systems localhost resolves to ::1 before
127.0.0.1. Binding only 127.0.0.1 would make the front-end reachable under one name
and not the other, which would break the very lesson the tool is trying to teach. Each
server therefore binds both, giving four listening sockets and one thread apiece. If the
::1 bind fails the launcher says so and carries on with IPv4.
Why the control channel is always CORS-open. /__control/* echoes any origin
unconditionally and ignores the scenario config. If it obeyed the config, selecting a
"blocked" scenario would lock you out of the controls that let you select the next one.
It is also excluded from the wire log, so the log shows only the lesson.
Below the scenario list is a form that drives the API origin by hand: ACAO mode
(absent, wildcard, echo, static allowlist, reflect-anything, trust-null), ACAC,
Allow-Methods, Allow-Headers, Expose-Headers, Max-Age, preflight status code,
artificial delay, duplicate-ACAO, and Vary: Origin — plus the request itself: origin
to fire from, method, path, an extra header, fetch mode, credentials mode, and a repeat
count.
The same control channel is a plain HTTP API, so you can drive it from curl while the browser watches:
| Endpoint | Method | Purpose |
|---|---|---|
/__control/config |
GET |
Read the live config. |
/__control/config |
POST |
Replace it. Unknown keys and out-of-range values are rejected with 400 and a message. This is a full replace, not a merge. |
/__control/log?since=N |
GET |
Every /api/ request received after sequence N, with the exact response header lines emitted. |
/__control/reset |
POST |
Clear the log. |
$ curl -s -X POST http://localhost:8081/__control/config \
-H 'Content-Type: application/json' \
-d '{"acao_mode":"echo","allow_methods":"PUT, OPTIONS","max_age":600}' > /dev/null
$ curl -s -D - -o /dev/null -X OPTIONS http://localhost:8081/api/widgets \
-H 'Origin: http://localhost:8080' \
-H 'Access-Control-Request-Method: PUT'
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:8080
Access-Control-Allow-Methods: PUT, OPTIONS
Access-Control-Max-Age: 600
Vary: Origin
Cache-Control: no-store
Reproducing a preflight in curl like this is the standard way to isolate whether a CORS problem is in the server or in the browser. If curl shows the right headers and the browser still blocks, the difference is something the browser adds — credentials, a redirect, a duplicated header from a proxy.
Config keys: acao_mode, allowlist, acac, allow_methods, allow_headers,
expose_headers, max_age, preflight_status, redirect_location, delay_ms,
duplicate_acao, vary_origin.
The API surface itself is trivial on purpose: any path under /api/ answers 200 with a
small JSON body describing what it saw, plus a deliberately non-safelisted
X-Total-Count: 137 header for the Expose-Headers lesson.
| Code | Meaning |
|---|---|
0 |
Clean shutdown (Ctrl-C). |
1 |
Unexpected error, such as a missing www/ directory. |
2 |
Bad usage: an unknown flag, an out-of-range port, identical --port and --api-port, or a non-loopback --host. |
3 |
A required port was already in use. The message names the port and suggests a free alternative. |
The servers bind to loopback interfaces only, and the launcher refuses to do otherwise.
Passing anything other than localhost, 127.0.0.1 or ::1 to --host exits with code
2 rather than binding.
That restriction is not incidental. This tool's job is to serve deliberately broken CORS
configurations: reflect-any-origin, trust-null, wildcard-with-credentials. Those are
the exact misconfigurations a CORS security audit exists to find. Exposed on a routable
interface they would be a genuine vulnerability rather than a lesson, so the tool does not
offer you the option.
Related points:
- It is not a proxy. It never forwards anything to a remote host, makes no outbound connections, and has no allowlist of upstreams because it has no upstreams. It cannot be used to bypass a real server's CORS policy, and if you are here looking for that, the answer is that the fix belongs on the server.
- Nothing leaves your machine. No telemetry, no CDN, no fonts, no analytics. The front-end is served from disk and the only hosts involved are loopback.
- Do not point it at anything you do not own. There is no facility to do so, but for the avoidance of doubt: probe only hosts you are authorised to test.
- The scenario configurations are examples of what not to ship.
reflect,null_trustedand wildcard-with-credentials are included so you can see them fail safely, not as templates.
- HTTP only, on loopback. No TLS. That means the
SameSite=None; Securecookie requirement cannot be demonstrated, because aSecurecookie will not be sent over plain HTTP — the credentialed scenarios say so where it matters. - It cannot read your console. A page has no access to its own devtools console; that is a browser privacy boundary and it is correct. The verdict panel quotes the message Chrome and Firefox are expected to print and asks you to compare, rather than pretending to have captured it.
- Preflight caching is the browser's, not the tool's. The Max-Age scenario relies on your browser's preflight cache. A hard reload with "Disable cache" ticked in devtools, or a private window, will suppress it and the lesson will show two preflights instead of one. The panel tells you when that has happened.
- Browsers differ at the edges. Where an observed outcome disagrees with the scenario's stated expectation, the UI says so plainly rather than hiding it. The wire trace is always the source of truth for what the server sent; the tests assert it on every run.
- It does not diagnose your application. It teaches the model. There is no facility to point it at a real API, replay a captured request, or scan a host. Use curl for reproduction and the Network panel for diagnosis.
- Not a CORS library or reference implementation. The server's header logic is written for legibility and for the ability to be wrong on purpose. Do not lift it into production.
- One config at a time. The API origin holds a single global configuration in memory. Two browser tabs running scenarios simultaneously will fight over it.
- No persistence. Config and log are in memory and vanish on exit. The log is capped at 400 entries.
The test suite drives the real API server over a real socket on an ephemeral port and
asserts, for every scenario, that the response headers are exactly what the scenario's
teaching text promises — status codes, preflight handling, the wildcard/credentials
interaction, Vary, Max-Age and Expose-Headers placement, and the duplicate-header
case. The scenarios are defined once, in simulator/scenarios.py, and consumed by both
the UI and the tests, so a lesson cannot drift from what the server actually does.
$ python3 -m unittest discover -s tests -v
...
Ran 40 tests in 3.056s
OK
Standard library only. There is no CI configuration because there is nothing to install.
Background on the mechanics demonstrated here, from cross-origin.com, a technical reference on CORS, preflight mechanics and browser security boundaries:
- Same-origin policy fundamentals
- How browsers evaluate the same-origin policy
- Simple vs preflight requests
- Why preflight requests use the OPTIONS method
- Why localhost and 127.0.0.1 are different origins
- Understanding Access-Control-Allow-Credentials
- Debugging a missing Access-Control-Allow-Origin header
- Reading a preflight OPTIONS request in DevTools
- Simulating a preflight with curl OPTIONS
- Fixing a duplicate Access-Control-Allow-Origin header
- How to set Access-Control-Max-Age effectively
- Preflight vs simple request performance cost
- Handling the Vary: Origin header correctly
- Wildcard vs dynamic origin reflection: when to use each
- Safely allowing localhost origins in development
- CORS security audit checklist
See CONTRIBUTING.md. New scenarios are the most useful contribution, and each one needs a test that pins the headers it promises.
MIT. See LICENSE.