Cross-Origin Restrictions on Deep Links
After reading this you will understand exactly why history.pushState throws a SecurityError the moment you hand it a URL on a different origin, and you will be able to route cross-origin deep links through a full navigation instead — safely carrying context across the boundary.
← Back to Deep Linking Implementation
Prerequisites
Core Concept
The History API deliberately confines you to your own origin. history.pushState and history.replaceState accept a url argument, but the specification requires that URL to be same-origin with the current document — same scheme, same host, same port. Pass anything else and the call throws a SecurityError (a DOMException) and the URL never changes. This is not a bug or a missing feature; it is the same-origin policy applied to the address bar. If a script on one origin could silently rewrite the visible URL to another origin without a real navigation, it could spoof a bank or a login page while serving its own content — so the browser forbids it. The History API can only ever rearrange entries within your origin.
The practical consequence for deep linking is a hard rule: you cannot pushState your way to another origin. Crossing an origin boundary always requires a genuine navigation — setting location.href, an anchor click, a form submit, or window.open. A real navigation unloads the current document and loads the target under its own security context, which is exactly the guarantee the same-origin policy exists to provide. What you can control is what travels across that boundary: the path and query you build into the outbound URL, whether the destination learns where the user came from via the referrer, and whether a new tab retains a scripting handle back to your window through opener. Getting those three right is the whole job of safe cross-origin deep linking.
Implementation
The helper below routes deep links correctly by origin: same-origin targets go through your SPA’s client-side pushState path, while cross-origin targets are sent as a full navigation with the context encoded in the URL and the opener/referrer surface locked down.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
function isSameOrigin(target: string): boolean {
// Resolve relative to the current document, then compare origins.
const url = new URL(target, location.href);
return url.origin === location.origin;
}
export function openDeepLink(
target: string,
context: Record<string, string> = {},
): void {
const url = new URL(target, location.href);
// Carry context in the query string — it is the only channel that
// survives a cross-origin navigation intact.
for (const [key, value] of Object.entries(context)) {
url.searchParams.set(key, value);
}
if (isSameOrigin(url.href)) {
// Same origin: stay in the SPA, update the entry client-side.
history.pushState(null, "", url.pathname + url.search + url.hash);
window.dispatchEvent(new PopStateEvent("popstate", { state: null }));
return;
}
// Cross origin: pushState would throw SecurityError. Do a real
// navigation in a new tab, and sever the opener handle + referrer so
// the destination cannot script our window or read our full URL.
const child = window.open(url.href, "_blank", "noopener,noreferrer");
// With noopener, window.open returns null — that is expected and safe.
void child;
}
Note the deliberate split: only same-origin URLs reach pushState. Everything else becomes a window.open (or you could assign location.href for a same-tab jump). The noopener,noreferrer pair prevents the new document from reaching back through window.opener and stops the referrer header from leaking your full path to a third-party origin.
Verification
Assert the two branches separately: a same-origin deep link must update the URL without a navigation, and a cross-origin pushState must throw. The second half is best proven by catching the SecurityError directly.
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("cross-origin pushState throws SecurityError", async ({ page }) => {
await page.goto("/app/");
const errorName = await page.evaluate(() => {
try {
history.pushState(null, "", "https://other-origin.example/path");
return "no-throw";
} catch (err) {
return err instanceof DOMException ? err.name : "unknown";
}
});
expect(errorName).toBe("SecurityError");
// Same-origin deep link updates the URL with no navigation.
await page.evaluate(() => (window as any).openDeepLink("/app/item/7"));
await expect(page).toHaveURL(/\/app\/item\/7$/);
});
For a quick manual check, open DevTools on any page and run history.pushState(null, "", "https://example.com/"); you will see an uncaught SecurityError naming the same-origin requirement. Then run it with a same-origin path like /app/x and watch the address bar update without a reload.
Gotchas
- The origin comparison includes the port and scheme, so
http://siteandhttps://site, orsite:3000andsite:8080, count as cross-origin and will throw onpushStateeven though the host matches. - A protocol-relative or subdomain URL (
//cdn.site.com/...,app.site.comfromwww.site.com) is a different origin; use a full navigation and, if you own both, pass context through the query string rather than trying to sharehistory.state. - Omitting
noopeneron a cross-originwindow.openleaves the new tab able to redirect your original window viawindow.opener; always pair it withnoreferrerwhen the destination should not learn the source URL, which also protects shareable deep links that embed sensitive params. history.statenever crosses an origin boundary — a full navigation starts the destination with its own empty state, so anything the target needs must be encoded in the URL it receives.
FAQ
Why does pushState throw a SecurityError for a cross-origin URL? Because the History API is bound by the same-origin policy: it may only manipulate entries within the current document’s origin. Rewriting the visible URL to another origin without a real navigation would let a script spoof another site, so the browser throws a SecurityError and leaves the URL unchanged.
Can I deep link to another origin from a single-page app? Yes, but not with pushState. Crossing an origin requires a genuine navigation — set location.href, submit a form, or use window.open. That unloads your document and loads the target under its own security context, which is exactly what the same-origin policy protects.
Does history.state carry over when I navigate to a different origin? No. A full cross-origin navigation starts the destination with its own empty history.state. Anything the target needs must travel in the URL you send it, typically as query-string parameters, because the state object cannot cross the origin boundary.
What counts as the same origin for the History API? The origin is the exact tuple of scheme, host, and port. A different port, a switch between http and https, or a different subdomain all count as cross-origin, so pushState to any of them throws even when the hostname looks similar.
How do I stop a cross-origin tab from controlling my window? Open it with noopener so the new document cannot reach window.opener and redirect your page, and add noreferrer so it does not receive your full URL in the referrer header. With noopener the window.open call returns null, which is expected and safe.
Related
- Deep Linking Implementation — the parent guide to building and handling deep links into an application.
- Generating Shareable Deep Links with Query Params — encoding state in the URL, the only channel that survives a cross-origin jump.
- History State Size Limits Across Browsers — why you keep state small and same-origin rather than trying to ferry it across boundaries.