While comparing event.origin against a fixed, port-normalized string literal (such as event.origin === 'https://app.example.com') works for simple allowlists, manual string comparisons and regular expressions break down when validating arbitrary URLs, DOM link elements, cross-subdomain redirects, or sandboxed messages:
- Prefix and substring spoofing: Checking
url.startsWith('https://app.example.com')is bypassed by'https://app.example.com.attacker.example'. - Unnormalized URLs: Full URLs with paths, query strings, or explicit default ports (
'https://app.example.com:443/path') do not match'https://app.example.com'under string equality without first parsing and normalizing. - Public Suffix List mistakes: Splitting hostnames on
.to check same-site relationships fails on multi-label public suffixes (such as.co.ukor shared hosting domains like.github.io) and ignores scheme mismatches (http:vs.https:). - Opaque
"null"origin collisions: Sandboxed iframes (<iframe sandbox="allow-scripts">) anddata:URLs serializeevent.originto the literal string"null". Comparing two serialized runtime origins withoriginA === originBfalsely equates two unrelated opaque contexts ("null" === "null").
The Origin API (Origin.from(), isSameOrigin(), isSameSite(), and opaque) provides structured, spec-compliant origin and schemeful same-site comparisons backed by the browser's URL parser and Public Suffix List.
Choosing the right comparison for your scenario
- Use
isSameOrigin()for strict privilege boundaries: Require an exact(scheme, host, port)match when handling cross-windowpostMessagecommands (such as an embedded checkout or auth popup) or deciding whether to attach credentials. Even a different subdomain (https://blog.example.comvs.https://app.example.com) is a different origin and must be rejected so a vulnerability on one subdomain cannot control another. - Use
isSameSite()for cross-subdomain navigation and link classification: Allow any subdomain or port sharing the same scheme and registrable domain when validating post-login redirect URLs (?returnTo=https://billing.example.co.uk/orders) or classifying links as internal vs. external.isSameSite()rejects bothhttp:scheme downgrades and sibling tenants on shared public suffixes (.co.uk,.github.io). - Use
origin.opaqueandOrigin.from(event)for opaque or sandboxed contexts: Rejectdata:URLs for redirects (origin.opaque === true), or pin the opaqueOriginextracted viaOrigin.from(event)when communicating with a specific<iframe sandbox="allow-scripts">widget whose serializedevent.originis"null".
1. Verify Exact Origins for Privileged Actions (Origin.from() and isSameOrigin())
Use Origin.from() to extract an Origin object and compare it against a trusted Origin using isSameOrigin():
- Both sides must be
Origininstances:isSameOrigin(other)andisSameSite(other)only accept anOriginobject—passing a raw string throws aTypeError. - Supported
Origin.from()inputs:- Serialized URL strings (for example,
'https://app.example.com:443/path'). Note that any invalid URL string—for instance'example', or the'null'string serialized byevent.originfor opaque origins—causesOrigin.from()to throw aTypeError. - Platform objects that define origin-extraction steps in the HTML specification:
Origin,URL,HTMLAnchorElement(<a>),HTMLAreaElement(<area>), same-originWindoworWorkerGlobalScope(Origin.from(self)), and browser-dispatchedMessageEventinstances (Origin.from(event)). - Objects such as
Location,WorkerLocation,Document,Request, andResponsedo not define origin-extraction steps and throw aTypeErrorif passed directly toOrigin.from()—passself(for the current context) orrequest.url/response.urlinstead.
- Serialized URL strings (for example,
- Handle both browser-dispatched and synthetic
MessageEventinstances: Real browserpostMessageevents carry an internal origin extracted byOrigin.from(event), whereas syntheticnew MessageEvent('message', { origin })events created in unit tests only populate theevent.originstring and throw onOrigin.from(event). Falling back toOrigin.from(event.origin)inside atry...catchhandles both cleanly while still rejecting invalid or"null"strings.
// Replace with your application's trusted origin(s)
const TRUSTED_ORIGIN = Origin.from('https://app.example.com');
function extractOrigin(candidate) {
try {
return Origin.from(candidate);
} catch (err) {
// Synthetic new MessageEvent('message', { origin }) objects in tests lack an
// internal browser origin; fall back to parsing candidate.origin if it is a URL.
if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
return Origin.from(candidate.origin);
}
throw err;
}
}
export function isTrustedSameOrigin(candidate) {
try {
return extractOrigin(candidate).isSameOrigin(TRUSTED_ORIGIN);
} catch {
// Rejects invalid URLs, "null" strings, or unsupported objects
return false;
}
}
window.addEventListener('message', (event) => {
if (!isTrustedSameOrigin(event)) return;
// Safe to process privileged commands from the verified origin
});
2. Validate Cross-Subdomain Redirects and Links (isSameSite())
When validating redirect targets (such as ?returnTo= parameters) or internal links across subdomains of the same registrable domain (for example, https://app.example.co.uk and https://billing.example.co.uk:8443), use isSameSite():
isSameSite()performs a schemeful same-site comparison:https://sub.example.comandhttp://sub.example.comare not same-site because their schemes differ.isSameSite()uses the browser's built-in Public Suffix List, so distinct tenants on shared public suffixes (such ashttps://tenant-a.github.ioandhttps://tenant-b.github.io, orhttps://a.co.ukandhttps://b.co.uk) evaluate tofalse.
// Replace with your application's primary origin
const APP_ORIGIN = Origin.from('https://app.example.co.uk');
export function isAllowedSameSiteUrl(candidateUrlOrElement) {
try {
return extractOrigin(candidateUrlOrElement).isSameSite(APP_ORIGIN);
} catch {
return false;
}
}
// true: same scheme (https) and same registrable domain (example.co.uk)
isAllowedSameSiteUrl('https://billing.example.co.uk:8443/checkout');
// false: scheme downgrade (http vs. https) is rejected by schemeful same-site
isAllowedSameSiteUrl('http://billing.example.co.uk/checkout');
3. Distinguish and Pin Opaque Origins (origin.opaque and Origin.from(event))
Sandboxed iframes (<iframe sandbox="allow-scripts">), data: URLs, and new Origin() produce opaque origins (origin.opaque === true). While their string serialization is always "null", Origin.from(event) extracts the sender's actual underlying opaque origin from a browser-dispatched MessageEvent:
- Two messages sent from the same sandboxed iframe yield
Originobjects that areisSameOrigin()with each other (true). - Messages sent from different sandboxed iframes—or separate
Origin.from('data:...')/new Origin()calls—yield distinct opaque origins that evaluateisSameOrigin()tofalse, preventing"null" === "null"spoofing across unrelated opaque contexts. new Origin()creates a fresh, unique opaque origin that matches nothing except itself, which is useful as a default deny-all sentinel before an expected peer origin is pinned once on initialization.
// Initialize with a unique opaque sentinel that matches no other origin
let pinnedSandboxOrigin = new Origin();
let isSandboxPinned = false;
export function handleSandboxedWidgetMessage(event, expectedWidgetWindow) {
try {
const senderOrigin = Origin.from(event);
// Pin the opaque origin only once on initialization from the expected sandboxed iframe
if (!isSandboxPinned && event.source === expectedWidgetWindow && event.data?.type === 'init') {
pinnedSandboxOrigin = senderOrigin;
isSandboxPinned = true;
}
// Subsequent messages from the SAME sandboxed iframe match pinnedSandboxOrigin;
// messages from any other sandboxed iframe (also event.origin === "null") return false.
return senderOrigin.isSameOrigin(pinnedSandboxOrigin);
} catch {
return false;
}
}
Best practices
- DO convert both sides of a comparison to
Origininstances viaOrigin.from()before callingisSameOrigin(other)orisSameSite(other). - DO pass a browser-dispatched
MessageEventdirectly toOrigin.from(event)when you need to preserve opaque origin identity, and useOrigin.from(self)(rather thanLocationorDocument, which throwTypeError) to obtain the current execution context'sOrigin. - DO wrap
Origin.from()calls on untrusted or runtime inputs in atry...catchblock so invalid URLs,"null"strings, or unsupported objects safely returnfalseinstead of throwing an uncaughtTypeError. - DO NOT compare two runtime
event.originorurl.originstrings directly without first rejecting"null"(origin !== 'null'), as two unrelated opaque origins both serialize to"null". - DO NOT validate origins or sites with
startsWith(),includes(),endsWith(), or naive.split('.')hostname slicing.
Fallback strategy
If your Baseline target includes browsers that do not yet support the Origin interface, feature-detect 'Origin' in globalThis and fall back to parsing URLs with new URL():
- Same-origin fallback (tuple origins): Extract the URL string (using
event.originforMessageEventobjects orcandidate.hreffor link elements), parse both the candidate and trusted URLs withnew URL(), reject"null"opaque origins (candidateUrl.origin !== 'null'), and comparecandidateUrl.origin === trustedUrl.origin. Because an opaque origin is never same-origin with a tuple origin like'https://app.example.com', immediately rejecting'null'matchesOrigin.isSameOrigin(). - Same-site fallback (caveat): The
URLinterface does not expose the browser's Public Suffix List. In browsers withoutOrigin, only perform suffix matching against an explicit, known private registrable domain that you control (verifying bothprotocoland an exact or dot-prefixedhostnamematch), or use a maintained Public Suffix List library. - Opaque / sandboxed iframe fallback: Browsers without
Origindo not expose distinct opaque origin identities onMessageEvent(event.originis always"null"). If you intentionally accept messages from a known sandboxed<iframe sandbox="allow-scripts">, verifyevent.source === expectedWidgetWindow(or communicate over a dedicatedMessageChannelMessagePorttransferred to the iframe on initialization) rather than comparingevent.origin.
// Replace with your trusted reference URL and known registrable domain
const TRUSTED_URL = 'https://app.example.com';
const TRUSTED_SITE_DOMAIN = 'example.com';
function extractUrlString(candidate) {
if (typeof candidate === 'string') return candidate;
if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
return candidate.origin;
}
if (candidate === globalThis) {
return globalThis.location?.href ?? '';
}
return candidate?.href ?? '';
}
function extractOrigin(candidate) {
try {
return Origin.from(candidate);
} catch (err) {
if (typeof MessageEvent !== 'undefined' && candidate instanceof MessageEvent) {
return Origin.from(candidate.origin);
}
throw err;
}
}
export function checkSameOriginWithFallback(candidate) {
try {
if ('Origin' in globalThis) {
return extractOrigin(candidate).isSameOrigin(Origin.from(TRUSTED_URL));
}
const candidateUrl = new URL(extractUrlString(candidate));
const trustedUrl = new URL(TRUSTED_URL);
return candidateUrl.origin !== 'null' && candidateUrl.origin === trustedUrl.origin;
} catch {
return false;
}
}
export function checkSameSiteWithFallback(candidate) {
try {
if ('Origin' in globalThis) {
return extractOrigin(candidate).isSameSite(Origin.from(TRUSTED_URL));
}
const candidateUrl = new URL(extractUrlString(candidate));
const trustedUrl = new URL(TRUSTED_URL);
return (
candidateUrl.origin !== 'null' &&
candidateUrl.protocol === trustedUrl.protocol &&
(candidateUrl.hostname === TRUSTED_SITE_DOMAIN ||
candidateUrl.hostname.endsWith(`.${TRUSTED_SITE_DOMAIN}`))
);
} catch {
return false;
}
}