Supabase

linkWithRedirect fails with the Auth emulator: 3 causes

Three different errors hide behind one symptom, and only one of them is about the emulator. Two were reproduced with linkWithRedirect against the emulator, the API key one with the emulator disconnected.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

TL;DR#

linkWithRedirect() against the Firebase Auth emulator can fail in three different ways, and only one of them is really about the emulator:

  • auth/argument-error: Auth was created with initializeAuth() without a popupRedirectResolver. Passing browserPopupRedirectResolver as the third argument of linkWithRedirect() is not enough, because the SDK does not forward it to the step that saves the redirect user. Give the resolver to initializeAuth() (or use getAuth(), which does it for you).
  • auth/provider-already-linked: the provider is still on the user. Unlink it first, as the original report did.
  • API key not valid: the Auth instance that starts the redirect is not connected to the emulator, so the SDK checks the page origin against the real Firebase project with your fake key.

With the resolver in initializeAuth() and connectAuthEmulator() called on that same instance, the redirect goes to the emulator's own sign-in page on 127.0.0.1:9099.

The errors, and where they come from#

The original report is firebase/firebase-js-sdk#4275, filed in January 2021 against the namespaced (v8-style) SDK. The reporter signed in with Google against the emulator, called unlink('google.com'), then linkWithRedirect(googleProvider), and got this response:

json
{"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","errors":[{"message":"API key not valid. Please pass a valid API key.","domain":"global","reason":"badRequest"}],"status":"INVALID_ARGUMENT"}}

The console line in the report shows where the request went: https://www.googleapis.com/identitytoolkit/v3/relyingparty/getProjectConfig?key=fake-api-key. That is the production project-configuration endpoint, not the emulator. The issue was closed in April 2026 with a comment saying that with the latest SDK "The network request is now pointing to the url string provided in the connectAuthEmulator()", and marked it a possible duplicate of #4879 (the same problem with linkWithPopup).

A pull request that names #4275, #7924 (opened January 2024, still open), shows the error the modular SDK gives today:

text
FirebaseError: Firebase: Error (auth/argument-error).

The failures were reproduced on 21 October 2026 with firebase 12.19.0 and the Auth emulator from firebase-tools 15.33.0, on Node 22 in a Docker container. auth/provider-already-linked and auth/argument-error came from linkWithRedirect() against the emulator. The API key error came from signInWithRedirect() with the emulator disconnected, which goes through the same origin check (_validateOrigin()). The browser was simulated with jsdom 26, and a wrapper around fetch logged every request the SDK made. jsdom does not follow page navigations, so the run stops at the moment the SDK sends the browser to the sign-in page; that is the point where the redirect has succeeded.

Why it happens#

1. The resolver passed to linkWithRedirect is not used to save the redirect user#

Before leaving the page, linkWithRedirect() stores the current user in "redirect" storage, so that it can be matched when the browser comes back. That storage belongs to the popup/redirect resolver. In the redirect source, _linkWithRedirect() builds resolverInternal from the argument you passed, but then calls a helper that does not receive it:

ts
async function prepareUserForRedirect(user: UserInternal): Promise<string> {
  const eventId = _generateEventId(`${user.uid}:::`);
  user._redirectEventId = eventId;
  await user.auth._setRedirectUser(user);
  await user.auth._persistUserIfCurrent(user);
  return eventId;
}

_setRedirectUser(user) is called with no resolver, so in auth_impl.ts the SDK falls back to the resolver given at initialisation, and asserts when there is none:

ts
const resolver: PopupRedirectResolverInternal | null =
  (popupRedirectResolver && _getInstance(popupRedirectResolver)) ||
  this._popupRedirectResolver;
_assert(resolver, this, AuthErrorCode.ARGUMENT_ERROR);

The docs comment above linkWithRedirect() in the same file calls the resolver optional when it was already supplied to initializeAuth() or provided by getAuth(). Read the other way round, it is required there: an app that only passes it per call fails. PR #7924 forwards the resolver to prepareUserForRedirect(); until something like it ships, the fix is on the app side.

Nothing on this path reads the emulator config: any app that calls initializeAuth() without the resolver hits it, emulator or not. Setups that build Auth with initializeAuth() (for example to choose persistence) meet it. The browser getAuth() passes popupRedirectResolver: browserPopupRedirectResolver itself, so it never meets this error.

2. The provider is still linked#

Before any storage is touched, linkWithRedirect() reloads the user and checks that the provider is not already linked. If the user still has google.com in providerData, the call fails at once with auth/provider-already-linked. That is why the original report unlinks first.

3. Origin validation runs against production when the emulator is not connected#

Before redirecting, the browser resolver validates that the page's domain is an authorised domain of the project. It fetches the project configuration to do that. In the current validate_origin.ts _validateOrigin() returns early for the emulator:

ts
export async function _validateOrigin(auth: AuthInternal): Promise<void> {
  // Skip origin validation if we are in an emulated environment
  if (auth.config.emulator) {
    return;
  }
 
  const { authorizedDomains } = await _getProjectConfig(auth);
  // (loop over authorizedDomains omitted)
}

and the redirect target is built from the emulator config in handler.ts, which returns the emulator's emulator/auth/handler path instead of https://<authDomain>/__/auth/handler. So with the modular SDK, an "API key not valid" error during a redirect means the Auth instance doing the redirect has no emulator config: connectAuthEmulator() was not called on it, or was called on a different instance. With a placeholder key such as fake-api-key, the real project lookup has to fail.

What the reproduction showed#

Without a resolver in initializeAuth(), linking Google while it is still linked:

text
$ node repro.mjs no yes link
firebase 12.19.0 | popupRedirectResolver in initializeAuth: no | emulator: yes | link
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:lookup
signed in, providers: google.com
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:lookup
FirebaseError: Firebase: Error (auth/provider-already-linked).

Same setup, the issue's exact sequence: unlink, then relink with browserPopupRedirectResolver passed to linkWithRedirect():

text
$ node repro.mjs no yes unlink-relink
firebase 12.19.0 | popupRedirectResolver in initializeAuth: no | emulator: yes | unlink-relink
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:lookup
signed in, providers: google.com
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:lookup
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:update
unlinked google.com
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:lookup
FirebaseError: Firebase: Error (auth/argument-error).

Every request went to the emulator. The failure is purely local, after the last accounts:lookup.

Fix#

  1. Give the resolver to initializeAuth(), not only to linkWithRedirect(). Connect the emulator right after creating Auth, on that same instance, before any other Auth call:
js
import { initializeApp } from 'firebase/app';
import {
  initializeAuth,
  indexedDBLocalPersistence,
  browserLocalPersistence,
  browserPopupRedirectResolver,
  connectAuthEmulator,
} from 'firebase/auth';
 
const app = initializeApp({
  apiKey: 'fake-api-key',
  authDomain: 'demo-lwr.firebaseapp.com',
  projectId: 'demo-lwr',
});
 
export const auth = initializeAuth(app, {
  persistence: [indexedDBLocalPersistence, browserLocalPersistence],
  popupRedirectResolver: browserPopupRedirectResolver,
});
 
if (['localhost', '127.0.0.1'].includes(location.hostname)) {
  connectAuthEmulator(auth, 'http://127.0.0.1:9099');
}

If you do not need custom persistence, getAuth(app) gives the same result: it sets the resolver itself.

  1. Unlink the provider before relinking it, and skip the call when it is still there:
js
import { unlink, linkWithRedirect, GoogleAuthProvider } from 'firebase/auth';
 
export async function relinkGoogle(user) {
  if (user.providerData.some((p) => p.providerId === 'google.com')) {
    await unlink(user, 'google.com');
  }
  await linkWithRedirect(user, new GoogleAuthProvider());
}
  1. Read the result when the page loads again. With the resolver set at initialisation, neither call needs a third argument: _withDefaultResolver() falls back to the resolver Auth was created with.
js
import { getRedirectResult } from 'firebase/auth';
import { auth } from './firebase.js';
 
const result = await getRedirectResult(auth);
if (result) {
  console.log('linked:', result.user.providerData.map((p) => p.providerId));
}

Verify the fix#

With popupRedirectResolver in initializeAuth() and nothing else changed, the same unlink-and-relink sequence reaches the redirect, and the target is the emulator, not Google:

text
$ node repro.mjs yes yes unlink-relink
firebase 12.19.0 | popupRedirectResolver in initializeAuth: yes | emulator: yes | unlink-relink
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:lookup
signed in, providers: google.com
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:lookup
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:update
unlinked google.com
fetch http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:lookup
navigate -> http://127.0.0.1:9099/emulator/auth/handler | providerId=google.com | authType=linkViaRedirect

In a real browser this is the emulator's local sign-in page. The Auth emulator documentation describes it: the emulator "serves a local version of the corresponding sign-in page" for web apps that call signInWithPopup or signInWithRedirect, and creates mock accounts as the flow proceeds. In the browser's network tab, check the host column: every Auth call should go to the host 127.0.0.1:9099. Emulator URLs still contain identitytoolkit.googleapis.com in their path, so filtering on that string is not a test.

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.

When the API key error is the one you see#

As a control, the emulator connection was removed and signInWithRedirect() was called with the resolver in place and the same placeholder key. The SDK went to production for the origin check, and the project lookup rejected the key:

text
$ node repro.mjs yes no signInWithRedirect
firebase 12.19.0 | popupRedirectResolver in initializeAuth: yes | emulator: no | signInWithRedirect
fetch https://identitytoolkit.googleapis.com/v1/projects
FirebaseError: Firebase: Error (auth/api-key-not-valid.-please-pass-a-valid-api-key.).

The symptom matches the 2021 report, but the cause differs. In 2021 the reporter was connected to the emulator and the SDK still called production; that is the behaviour the closing comment says is gone. Today the request reaches production (now v1/projects rather than v3/relyingparty/getProjectConfig) only when the Auth instance has no emulator config. If you see it with the emulator running, check that the module which calls linkWithRedirect() imports the auth instance you connected, that it belongs to the same FirebaseApp, and that no condition skipped connectAuthEmulator() on the host you opened (see the hostname check in step 1 of the fix). If the error is auth/invalid-api-key instead, it is thrown even earlier, when Auth is created with an empty key: see why auth/invalid-api-key still throws without a real apiKey.

Why calling getRedirectResult first does not help#

A tempting workaround is to call getRedirectResult(auth, browserPopupRedirectResolver) at start-up, hoping it sets up redirect storage with the resolver. It does not. With no pending redirect, the call returns null, and the SDK only passes your resolver to _setRedirectUser() when there is a result to clear. The run confirms it (sign-in, lookup and unlink lines omitted):

text
$ node repro.mjs no yes getRedirectResult-first
getRedirectResult: null
FirebaseError: Firebase: Error (auth/argument-error).

The reliable fix is the resolver at initialisation, either in initializeAuth() or through getAuth().

After the emulator: production redirects#

Once the flow works against the emulator, the production redirect goes through https://<authDomain>/__/auth/handler instead. If your app is not hosted on Firebase, that handler has to be reachable from your domain: the custom authDomain fix for Vercel shows how to proxy it with a Next.js rewrite. If you switch to linkWithPopup() to avoid full page reloads, expect a different timing problem when the user closes the window: auth/popup-closed-by-user arrives about eight seconds late, by design.

The pattern behind all three errors is that linkWithRedirect() depends on state set up before it runs: a resolver chosen at initialisation, an emulator connection on the same instance, and a provider not yet on the user. When one of them is missing, the error appears at the redirect, not where the setup went wrong. Fix the setup, and the redirect goes to the emulator's sign-in page as designed.

Further reading: Google sign-in fails on an iOS PWA saved to Home Screen.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

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