Handling the null Origin from Sandboxed Iframes
A widget renders inside <iframe sandbox>, calls your API, and every request dies before the response is readable — even though the same call works when you paste it into the page directly. This guide explains why the browser sends the opaque string null instead of a hostname, and how to restore the integration without adding null to an allowlist. It sits under Origin Matching Rules & Validation, which covers how the origin tuple is built and compared in the general case.
Failure symptom:
Access to fetch at 'https://api.acme-labs.io/v1/orders' from origin 'null'
has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header has
a value 'https://widgets.acme-labs.io' that is not equal to the supplied origin.
The server did everything you asked: it looked up https://widgets.acme-labs.io in its allowlist, found it, and echoed it. The request simply did not come from that origin as far as the browser is concerned.
Root Cause
An origin is normally a tuple of scheme, host and port, and it serializes into the Origin header as something like https://widgets.acme-labs.io. Some documents, though, are deliberately given an opaque origin: an internal, unguessable value that is not equal to any other origin, including a second copy of itself. RFC 6454 defines the serialization of such an origin as the four characters null, and the Fetch Standard sends that string in the Origin header. Sandboxing is the most common way to end up with one — an <iframe sandbox> without the allow-same-origin token is specified to produce an opaque origin, no matter which host actually served the document inside it.
Because the value is a constant rather than an identity, exact string comparison against your allowlist fails, and you cannot repair the comparison by loosening the pattern. Several unrelated contexts collapse onto the same string:
Why You Cannot Simply Allowlist null
The instinct is to add "null" to the permitted set and move on. That works, and it is the reason null grants show up as a finding in almost every audit: the string carries no identity, so a grant issued for your widget is simultaneously a grant for every other sandboxed document in the world. An attacker does not need to compromise anything — they embed <iframe sandbox src="https://their-page.example/probe.html"> on a page they control, the frame sends Origin: null, and your server answers with the same permission it meant for you.
Pairing that grant with Access-Control-Allow-Credentials: true turns it into a data-exfiltration primitive, which is why the CORS Security Audit Checklist treats a reflected null as a high-severity item. The same reasoning behind the wildcard prohibition applies here, as covered in Credential Sharing & Security Boundaries in CORS.
Prerequisite State
- You can edit the page that embeds the frame, or at least the
sandboxattribute it renders. - You control the API’s CORS configuration (Express, Nginx, or whatever emits the headers).
- A browser with DevTools open, plus
curlfor reproducing the request outside the browser. - You know whether the endpoint needs cookies or an
Authorizationheader, because that decides which remedies are even available.
Step-by-Step Fix
Step 1 — Confirm which context produced the opaque origin
Do not change server code until you know the source. Open the Network panel, select the failing request, and read the Request Headers section: the literal Origin: null is the confirmation. Then switch to the Console and evaluate window.origin and document.location.href inside the frame’s execution context (choose the frame in the context selector at the top of the Console). If window.origin prints "null" while the location is an ordinary https:// URL, sandboxing is responsible. If the location itself starts with data: or file:, the frame is not the problem and no server change will help.
One more check separates a sandbox from a redirect: run the request with curl and follow the hops. Since a redirect that crosses an origin boundary rewrites Origin to null for the second hop, a request that fails only in the browser but shows a 302 in the trace has a different cause than one that starts opaque.
curl -sS -o /dev/null -D - -L https://api.acme-labs.io/v1/orders \
-H 'Origin: https://widgets.acme-labs.io' \
| grep -iE '^(HTTP/|location:|access-control-allow-origin:)'
Step 2 — Choose the remedy that fits your control and your data
Three remedies exist, and picking the wrong one is how teams end up with a permanent null grant. The decision turns on two questions only: can you change the document inside the frame, and does the endpoint carry credentials?
Step 3 — Remedy 1: give the frame a real origin
If the frame exists to isolate untrusted markup rather than untrusted script from your own team, add allow-same-origin and host the document on a subdomain dedicated to it. The frame then has an ordinary origin, and your existing allowlist logic works unchanged.
<iframe
src="https://widgets.acme-labs.io/order-panel.html"
sandbox="allow-same-origin allow-scripts allow-forms"
title="Order panel"
referrerpolicy="strict-origin-when-cross-origin"></iframe>
Serve that document from a host that is not the embedding page’s own origin. A frame carrying both allow-same-origin and allow-scripts from the same origin as its parent can reach into the parent’s DOM and delete its own sandbox attribute, which defeats the point of sandboxing entirely. A separate subdomain keeps the two documents on distinct origins while giving each a nameable identity — the same reasoning that makes localhost and 127.0.0.1 two different origins, described in Why localhost and 127.0.0.1 Are Different Origins.
Then allowlist the new origin explicitly:
const ALLOWED = new Set([
'https://widgets.acme-labs.io',
'https://app.acme-labs.io',
]);
app.use((req, res, next) => {
const origin = req.get('Origin');
if (origin && origin !== 'null' && ALLOWED.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
}
res.set('Vary', 'Origin');
next();
});
Step 4 — Remedy 2: relay through the embedding page
When the frame genuinely has to stay opaque — third-party markup, a user-authored template, an untrusted preview — do not try to authorise it. Let the embedding page, which has a real origin and real cookies, make the call on its behalf and hand back only the result. The frame talks to its parent over postMessage; the parent talks to the API.
Inside the frame, ask rather than fetch:
// Runs inside the sandboxed frame (origin is null)
window.parent.postMessage({ action: 'loadOrders' }, 'https://app.acme-labs.io');
window.addEventListener('message', (event) => {
if (event.origin !== 'https://app.acme-labs.io') return;
if (event.data?.action !== 'loadOrders:result') return;
render(event.data.payload);
});
In the embedding page, validate the message shape before acting on it, and answer with an explicit target origin. Because the frame is opaque, '*' is the only target origin that reaches it — so send back the rendered result, never a token or a raw session payload:
const FRAME = document.getElementById('order-frame');
window.addEventListener('message', async (event) => {
if (event.source !== FRAME.contentWindow) return; // ignore other frames
if (event.data?.action !== 'loadOrders') return; // ignore other messages
const res = await fetch('https://api.acme-labs.io/v1/orders', {
credentials: 'include',
headers: { Accept: 'application/json' },
});
const payload = await res.json();
// The frame is opaque, so '*' is the only reachable target: send display data only.
FRAME.contentWindow.postMessage(
{ action: 'loadOrders:result', payload: summarise(payload) },
'*',
);
});
Step 5 — Remedy 3: a deliberate, credential-free public grant
Occasionally the endpoint really is public — a currency table, a status feed, a font manifest — and the frame really cannot be given an origin. Only then is answering null defensible, and only with hard constraints: the route must be read-only, unauthenticated, and permanently free of Access-Control-Allow-Credentials. Scope it to the exact path rather than the whole API.
location = /v1/public/rates {
if ($http_origin = "null") {
add_header Access-Control-Allow-Origin "null" always;
add_header Vary "Origin" always;
}
# Never add Access-Control-Allow-Credentials on this route.
proxy_pass http://rates_upstream;
}
Everywhere else, reject the string explicitly so a future refactor of the allowlist cannot let it through by accident. The guard belongs in the comparison itself, not in a comment.
Verification
Reproduce the opaque origin from the terminal — curl will send whatever you tell it to, so Origin: null is easy to test without building a sandboxed page:
# Must print nothing: the API must not answer a null origin on a credentialed route.
curl -sS -o /dev/null -D - https://api.acme-labs.io/v1/orders \
-H 'Origin: null' \
| grep -i '^access-control-allow-'
# Must echo the frame's real origin after Remedy 1.
curl -sS -o /dev/null -D - https://api.acme-labs.io/v1/orders \
-H 'Origin: https://widgets.acme-labs.io' \
| grep -iE '^(access-control-allow-origin|vary):'
Then confirm the same result in the browser, which is where the sandbox actually applies:
The curl techniques here extend the workflow in Simulating a Preflight with curl -X OPTIONS when the failing call is preflighted rather than simple.
Security Boundary Note
The null origin is the one value that can never be authenticated, so it must never be paired with credentials. A response carrying Access-Control-Allow-Origin: null together with Access-Control-Allow-Credentials: true lets any page on the internet read a logged-in user’s data by embedding a sandboxed frame — no phishing, no token theft, just an iframe. Treat that combination as a live incident rather than a configuration preference, and keep the rejection in the same expression that performs the allowlist lookup, as recommended in Wildcard CORS Risks and Safe Origin Allowlisting. If the browser is enforcing an opaque origin, it has already decided that the document should not be trusted with an identity; overriding that decision on the server side is a policy change, not a bug fix.
Common Mistakes
| Mistake | Technical impact | Correct approach |
|---|---|---|
Adding "null" to the origin allowlist |
Every sandboxed document on the web receives the same grant | Give the frame a real origin, or relay the call through the embedding page |
Matching with a pattern such as /acme-labs\.io$/ |
null still fails, and the loosened pattern accepts lookalike hosts |
Keep exact-match comparison and fix the origin at its source |
Sending postMessage results with a specific targetOrigin to an opaque frame |
The message is silently dropped, because the frame’s origin matches nothing | Use '*' for the reply, and send only display-safe data |
Adding allow-same-origin to a frame served from the parent’s own origin |
The frame can reach into the parent and remove its own sandbox | Host the framed document on a dedicated subdomain |
FAQ
Is an Origin of null always caused by a sandboxed iframe?
No. A sandboxed iframe is the most common source, but any opaque browsing context serializes the same way: a document loaded from a data: URL, a page opened over the file: scheme, an iframe using srcdoc inside a sandbox, and — in several engines — a cross-origin redirect on a request that was already cross-origin. Confirm the source before you change server code, because the fix for a sandbox attribute is completely different from the fix for a redirect chain.
Can I allowlist null just for my own embedded widget?
The server cannot tell your widget apart from anyone else’s sandboxed frame, because every opaque context sends the identical string. Adding null to an allowlist grants access to every sandboxed document on the web, including one an attacker embeds in their own page. If the endpoint returns nothing user-specific and you never set Access-Control-Allow-Credentials, the exposure is limited to data you were willing to publish anyway; for anything authenticated, treat a null grant as a vulnerability.
Does adding allow-same-origin to the sandbox undo the sandbox?
It removes only the opaque-origin part of the sandbox. The frame regains its real origin, so it can use its own cookies, storage and a normal CORS grant, while every other restriction you did not list — form submission, top-level navigation, popups, plugins, and script execution unless you add allow-scripts — stays in force. The important caveat is that a frame with both allow-same-origin and allow-scripts, served from the same origin as the embedding page, can reach into the parent document and remove its own sandbox attribute, so host that document on a separate subdomain you control.