Cross-Origin-Opener-Policy Blocks window.closed: 2026 Fix
Next.js

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.

2026-09-01
7 min read
Cross-Origin-Opener-Policy Blocks window.closed: 2026 Fix

I ran into Cross-Origin-Opener-Policy policy would block the window.closed call the first time I deployed Firebase Google popup auth to Vercel with next.config.js headers I had copied from a security-hardening template. The Google account chooser opened, I selected an account, the popup closed—and signInWithPopup never resolved. No error surfaced in the catch block either; the promise just hung. In localhost it worked. In production it did not. The production page was serving Cross-Origin-Opener-Policy: same-origin, and that header alone was enough to break the popup window reference. I changed that header to unsafe-none on the sign-in route, redeployed, and the popup flow resolved immediately.

This error is not exclusive to Firebase. The Stack Overflow thread that first surfaced it doesn't mention Firebase at all. It can happen with any cross-origin popup that polls window.closed. Firebase's popup sign-in is just the place where I and plenty of other developers hit it, so I'll use that as the working example throughout.

I'll refer to the header as COOP from here on.

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.

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 exact Firebase call that got stuck in my project (firebase v10.11, next v14.2):

js
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.

There are three COOP values to know:

  • unsafe-none — the legacy opt-out and the default when the header is absent. Cross-origin popup references work as expected.
  • same-origin-allow-popups — keeps same-origin popup references but still restricts the cross-origin case.
  • same-origin — fully isolates the opener from cross-origin windows.

For a Google OAuth popup, the popup origin is https://accounts.google.com and the opener is your domain. Those are never same-origin, so any COOP value tighter than unsafe-none can trigger the window.closed block. The header I had set in a security-hardening starter template was same-origin, and that was the culprit.

Cross-Origin-Embedder-Policy (COEP) is another security header often set alongside COOP to require cross-origin isolation, but it doesn't affect window.closed. Only COOP isolates the browsing context. 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. I cover the client-only guard in Window is not defined in Next.js – 2026 Fix for React Apps.

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, that is the source.

I also ran this on the production page in the browser console to double-check:

js
// 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"));

The console printed same-origin, which confirmed the root cause. If the response shows another non-unsafe-none COOP value, treat that as suspect too because the Google popup is cross-origin. The fix is the same: loosen or remove 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. The specific fix is to remove the COOP header or change its value to unsafe-none for the route that calls the popup opener.

Here is the before and after diff I applied:

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: "unsafe-none" },
           { key: "X-Content-Type-Options", value: "nosniff" },
         ],
       },
     ];
   },
 };
 
 module.exports = nextConfig;

After the change, the full config looks like this:

js
// next.config.js — COOP loosened for the popup flow
const nextConfig = {
  async headers() {
    return [
      {
        source: "/signin",
        headers: [
          { key: "Cross-Origin-Opener-Policy", value: "unsafe-none" },
          { key: "X-Content-Type-Options", value: "nosniff" },
        ],
      },
    ];
  },
};
 
module.exports = nextConfig;

I switched from source: "/(.*)" to source: "/signin" to keep the relaxed COOP policy scoped to the route that actually launches the Google popup. That way the rest of the app keeps the stricter COOP value.

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. In my case I had the header in both places because the starter template included it. I changed the value to unsafe-none in vercel.json to match:

json
{
  "headers": [
    {
      "source": "/signin",
      "headers": [
        { "key": "Cross-Origin-Opener-Policy", "value": "unsafe-none" }
      ]
    }
  ]
}

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: change it to unsafe-none for the popup route.

json
{
  "hosting": {
    "public": "out",
    "headers": [
      {
        "source": "/signin",
        "headers": [
          { "key": "Cross-Origin-Opener-Policy", "value": "unsafe-none" }
        ]
      }
    ]
  }
}

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.

js
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. The redirect flow is the fallback I reach for when a client forbids unsafe-none on the sign-in route.

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.

js
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 I used earlier:

js
// 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 unsafe-none or null. 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.

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?#

Remove the COOP header from the page that calls signInWithPopup, or set it to unsafe-none in next.config.js and vercel.json. Redeploy and confirm the response header is unsafe-none or absent 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?#

Only when the COOP policy permits cross-origin popup access, which unsafe-none does. Do not set same-origin on the route that launches a cross-origin OAuth popup.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.