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:

Which browsing contexts send Origin: null A two-column mapping. Five browsing contexts on the left — a sandboxed iframe, a sandboxed srcdoc frame, a data URL document, a file scheme page, and a cross-origin redirect — all map to the Origin value null. The sixth row shows that adding allow-same-origin restores the frame's real origin. Browsing context that issued the request Origin header your server receives iframe sandbox without allow-same-origin Origin: null srcdoc frame inside a sandboxed frame Origin: null document loaded from a data: URL Origin: null page opened over the file: scheme Origin: null redirect that crosses an origin boundary Origin: null same frame plus allow-same-origin Origin: https://widgets.acme-labs.io Five different contexts, one indistinguishable value — only the last row gives the server something to match

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.

Why a null grant is a grant to everyone Three unrelated embedding pages each host a sandboxed frame, and all three send the identical Origin null. A server allowlist containing the literal string null matches all of them, so one intended grant becomes three actual grants. Your own embed widgets.acme-labs.io An unrelated site random-blog.example A hostile page attacker.example Each frame is sandboxed, so the browser gives it an opaque origin and all three requests arrive carrying Origin: null Allowlist contains the literal string null so the exact-match comparison succeeds for all three One intended grant, three actual grantees the response is readable by any sandboxed document on the web Opaque origins are unequal to each other by design, but they all serialize to the same four characters

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

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?

Choosing a remedy for an opaque origin A decision tree starting from whether you control the document inside the frame. Controlling it leads either to restoring a real origin or to a postMessage relay; not controlling it leads either to a public credential-free endpoint or to refusing the grant entirely. Can you change the document that runs inside the frame? yes no Must the frame stay opaque for script isolation? Is the response public and entirely free of credentials? no yes yes no Remedy 1 give the frame a real origin you control Remedy 2 relay through the embedding page Remedy 3 public data only, and never credentialed No safe grant move the call behind an origin you own Only the two left-hand outcomes give the server an origin it can actually name in its allowlist

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.

Relaying a request through the embedding page Three lifelines: the sandboxed frame, the parent page, and the API. The frame posts a message to the parent, the parent performs the credentialed fetch from its own origin, receives an allow-origin grant naming that origin, and posts the result back to the frame. Sandboxed frame origin: null Embedding page app.acme-labs.io API api.acme-labs.io postMessage({ action: 'loadOrders' }) fetch /v1/orders with credentials 200 with a grant naming app.acme-labs.io postMessage(result, targetOrigin) The API only ever sees a request from a named origin; the opaque frame never touches it directly

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.