Cross-Origin-Opener-Policy Blocks window.closed: 2026 Fix
The Firebase Google sign-in popup loses window.closed access when a COOP header isolates the opener. Here's the exact header change that restores the popup flow.
Cross-Origin-Opener-Policy policy would block the window.closed call shows up when a Next.js app with hardened security headers ships Firebase Google popup auth to Vercel. The pattern reported in the original Stack Overflow question (87 upvotes, 119,000+ views, tagged firebase, next.js, oauth-2.0) is always the same: the Google account chooser opens, an account is selected, the popup closes, and signInWithPopup never resolves. No error surfaces in the catch block either; the promise just hangs. It works on localhost and fails in production. The production page is serving a Cross-Origin-Opener-Policy value of same-origin, and that header alone is enough to break the popup window reference. Loosening it — ideally to same-origin-allow-popups, or to unsafe-none if that isn't enough — on the sign-in route and redeploying makes the popup flow resolve.
This error is not exclusive to Firebase. It can happen with any cross-origin popup that polls window.closed. Firebase's popup sign-in is just the place where most developers hit it, so this guide uses that as the working example throughout. COOP (short for Cross-Origin-Opener-Policy) is used interchangeably with the full header name below.
What triggers the COOP error in Firebase popup auth?#
Firebase Authentication's signInWithPopup(auth, provider) works by calling window.open() to the Google authorization URL and waiting for the result. The Firebase SDK keeps a reference to the popup and polls popup.closed to see whether the user dismissed it. If the opener page has Cross-Origin-Opener-Policy: same-origin, the browser places the Google popup in a separate browsing context group. Any access to popup.closed then throws a security error instead of returning true or false, and the Firebase promise dead-ends. If the browser console shows no security error at all and signInWithPopup instead rejects several seconds after the user actually closed the popup, that is the unrelated auth/popup-closed-by-user timing issue, caused by the SDK's polling interval rather than COOP.
This is not a CORS failure. A CORS problem would show as a failed network request or a blocked response from the OAuth endpoint. This is a frame-access policy failure: the opener page has declared that cross-origin popups should not share its browsing context group. The error appears in production, not localhost, because local dev almost never applies the same security header allowlist that a deployed Next.js app or a Vercel project uses.
Here is the Firebase call that gets stuck (firebase v10.11, next v14.2):
import { getAuth, signInWithPopup, GoogleAuthProvider } from "firebase/auth";
const auth = getAuth();
const provider = new GoogleAuthProvider();
export async function loginWithGoogle() {
try {
const result = await signInWithPopup(auth, provider);
return result.user;
} catch (error) {
console.error(error);
throw error;
}
}The signInWithPopup call itself is fine. The header is the problem.
How COOP breaks window.closed and popup communication#
When window.open() returns a popup reference, the browser hands back a WindowProxy. Some properties are safe to read across origins, but closed is one of the properties a cross-origin page can read only when the opener and popup sit in the same browsing context group. COOP changes exactly that grouping.
The values relevant here, from most to least permissive:
unsafe-none— the default when the header is absent. Documents can share a browsing context group with any other document, so cross-origin popup references likewindow.closedwork as expected.same-origin-allow-popups— keeps the opener isolated from other same-origin documents that don't opt in, but explicitly carves out an exception for popups the opener itself opens withwindow.open(): those stay in the same browsing context group even when the popup is cross-origin (likeaccounts.google.com), sowindow.closedstill resolves. This is the value the Firebase and Next.js communities generally recommend for popup-based OAuth, because it keeps more COOP protection thanunsafe-nonewhile still letting the popup reference work.same-origin— fully isolates the opener from any window that doesn't also declaresame-origin, cross-origin or not. This is the value that breakssignInWithPopup, because a Google OAuth popup can never satisfysame-origin— the popup origin ishttps://accounts.google.comand the opener is your domain.
So the fix is not necessarily to drop COOP entirely. same-origin-allow-popups is usually enough to unblock the popup flow; reach for unsafe-none only if same-origin-allow-popups still doesn't resolve it for your setup.
Cross-Origin-Embedder-Policy (COEP) is another security header often set alongside COOP to enable cross-origin isolation features like SharedArrayBuffer, but it does not affect window.closed — only COOP controls the browsing context group. Similarly, the popup's window.opener reference would be blocked if COOP were set on the opener, but the Firebase SDK doesn't rely on that; it uses window.closed.
If you are also guarding window.closed inside a client component and you have seen a separate window is not defined error during SSR, that is a sibling issue, not the COOP problem. See Window is not defined in Next.js – 2026 Fix for React Apps for the client-only guard.
Confirm the COOP header is the root cause#
Before changing config, verify the response header on the page that launches the popup. Open the production page in Chrome DevTools, go to the Network tab, reload, select the document request, and look at Response Headers. If you see a cross-origin-opener-policy header set to same-origin, that is the source.
You can check the same thing from the console instead of DevTools' Network panel:
// Run this on the production page that calls signInWithPopup
const response = await fetch(window.location.href, { method: "HEAD" });
console.log(response.headers.get("cross-origin-opener-policy"));If that prints same-origin, that confirms the root cause. If it prints any value other than unsafe-none or same-origin-allow-popups, treat it as suspect too, since the Google popup is cross-origin. The fix is the same: loosen the header on the route that opens the popup.
Fix COOP headers in Next.js (next.config.js)#
The change belongs in the headers() function in next.config.js. Change the COOP value to same-origin-allow-popups for the route that calls the popup opener; fall back to unsafe-none only if that isn't enough.
Here is the diff:
// next.config.js
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
- { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
+ { key: "Cross-Origin-Opener-Policy", value: "same-origin-allow-popups" },
{ key: "X-Content-Type-Options", value: "nosniff" },
],
},
];
},
};
module.exports = nextConfig;Scoping the relaxed value to just the sign-in route, rather than the whole app, keeps the stricter COOP policy everywhere else:
// next.config.js — COOP loosened only for the route that opens the popup
const nextConfig = {
async headers() {
return [
{
source: "/signin",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin-allow-popups" },
{ key: "X-Content-Type-Options", value: "nosniff" },
],
},
];
},
};
module.exports = nextConfig;Using a targeted source like /signin instead of a wildcard (/(.*)) limits the relaxed COOP policy to the route that actually launches the Google popup, so the rest of the app keeps the stricter default.
Fix COOP headers on Vercel (vercel.json)#
If you set headers in vercel.json, Vercel applies them at the edge before Next.js responds. That means a COOP header in vercel.json can override or conflict with the Next.js config. Security-hardening starter templates often put the header in both places, so make sure both are updated to the same value:
{
"headers": [
{
"source": "/signin",
"headers": [
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin-allow-popups" }
]
}
]
}A targeted path like source: "/signin" is safer here too, because it limits the relaxed COOP policy to the route that actually launches the Google popup. Redeploy to Vercel after saving vercel.json.
Fix COOP headers in Firebase Hosting#
If the Firebase Google auth flow runs from a static export deployed to Firebase Hosting, the COOP header is probably in firebase.json. The same rule applies: relax it to same-origin-allow-popups for the popup route. If firebase deploy then stops with Assertion failed: resolving hosting target of a site with no site name or target name, check the hosting entry you edited: Fix "Assertion failed: resolving hosting target" (Firebase) covers both causes.
{
"hosting": {
"public": "out",
"headers": [
{
"source": "/signin",
"headers": [
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin-allow-popups" }
]
}
]
}
}A narrow source is useful here too. If you had a wildcard source like "**", replace it with the sign-in path so the rest of your app keeps the stricter COOP value.
Alternative — switch to signInWithRedirect#
If you cannot loosen the COOP header in your deployment target, switch from popup auth to redirect auth. The redirect flow never gives you a cross-origin WindowProxy to poll, so window.closed is not part of the path.
import { getAuth, signInWithRedirect, GoogleAuthProvider } from "firebase/auth";
const auth = getAuth();
const provider = new GoogleAuthProvider();
export async function loginWithGoogleRedirect() {
await signInWithRedirect(auth, provider);
}After the redirect back to your app, handle the pending result with getRedirectResult(auth) on the client and onAuthStateChanged for session persistence. Reach for this fallback when a deployment target's security policy forbids relaxing COOP on the sign-in route at all.
Alternative — detect popup closure without window.closed#
For popups you control, a postMessage close signal can replace a window.closed poll. The popup sends a message on beforeunload or via a close button, and the opener listens for it. This pattern does not apply to Google's hosted consent page because you cannot inject a script there, but it is useful for custom OAuth consent screens or in-app popups.
const popup = window.open("/auth/custom", "oauth", "width=500,height=600");
window.addEventListener("message", (event) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === "oauth-closed") {
console.log("Popup closed");
}
});For Google OAuth specifically, the redirect flow is the reliable fallback when COOP cannot be relaxed.
Verify the fix#
Redeploy the app, then check the production page again with the same browser fetch used earlier:
// Run this on the production page after deploying the header change
const response = await fetch(window.location.href, { method: "HEAD" });
console.log(response.headers.get("cross-origin-opener-policy"));The console should print same-origin-allow-popups (or unsafe-none/null if you went with that fallback). If it still prints same-origin, the new header config did not reach the deployed route. Hard reload and wait for the CDN to propagate.
Once the header is confirmed, run the Google sign-in again. The popup should open, the account chooser should appear, selecting an account should close the popup, and signInWithPopup should resolve with a Firebase user. No Cross-Origin-Opener-Policy policy would block the window.closed call warning should appear. If you are also seeing a separate production-only /_not-found prerender failure while deploying the same Next.js app, that is unrelated to this header issue; Next.js 16 /_not-found Prerender Build Error: The Real Fix walks through it.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
FAQ#
What does Cross-Origin-Opener-Policy policy would block the window.closed call mean?#
It means the page that opened the OAuth popup is serving a COOP header that isolates the popup into a separate browsing context group. The Firebase SDK then reads window.closed to detect closure, and the browser blocks that access as a cross-origin security error.
How do I fix COOP blocking Firebase Google popup auth in Next.js?#
Set the COOP header to same-origin-allow-popups on the page that calls signInWithPopup, in next.config.js and vercel.json. Fall back to unsafe-none only if that isn't enough. Redeploy and confirm the new value reached production before retrying.
Does COOP break OAuth popup flow with Google Sign-In?#
Yes. Cross-origin popup references like window.closed require the opener and popup to share a browsing context group. A COOP value of same-origin prevents that sharing, so the popup promise hangs.
Can I still use window.closed across origins with COOP?#
Yes, with unsafe-none or same-origin-allow-popups — the latter still isolates the opener from other same-origin documents while explicitly allowing window.closed on popups the opener opens itself, which is why it's the preferred fix for OAuth popups. Do not set same-origin on the route that launches a cross-origin OAuth popup.
Further reading: Fix: A Route is only ever to be used as the child of Routes.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix: useRouter query undefined on first render
Why Next.js hydrates a dynamic Pages Router page with an empty query object, how router.isReady fixes it, and why the App Router does not have this exact problem.
Fix: A Route is only ever to be used as the child of Routes
Why v5-style routing code crashes on React Router 6 and 7, the two-step migration that fixes it, and the second error that appears if you only do half of it.
Fix Invariant: attempted to hard navigate to the same URL
Reproduced on Next.js 13.1.0, 13.2.0 and 16.4.3: why a NextResponse.next() middleware broke the 404 page, why the same invariant still fires after a redeploy, and the catch that stops it.


