Migrate Firebase Auth to Supabase Without Forcing a Password Reset — Cesar Ayala
← All posts

Migrate Firebase Auth to Supabase Without Forcing a Password Reset

Export users with firestoreusers2json.js, then copy Firebase's four scrypt params (base64_signer_key, base64_salt_separator, rounds, mem_cost) from Authentication → password hash parameters, and run import_users.js into Supabase's auth.users. Because GoTrue can verify Firebase scrypt hashes with those params, every user logs in with their same password — no reset.

The short answer: yes, users keep their passwords — here’s how

Export your users with firestoreusers2json.js, then copy Firebase’s four scrypt parameters (base64_signer_key, base64_salt_separator, rounds, and mem_cost) from the Firebase Console under Authentication → Users → password hash parameters, and run import_users.js into Supabase’s auth.users table with those params configured. Because Supabase Auth (GoTrue) can verify Firebase’s scrypt hashes when you give it those four values, existing users log in with the exact same password they had before — no reset email, no “please choose a new password” screen. This is the single hardest sub-problem in the whole Firebase-to-Supabase move, and the official docs bury it, so this post is the deep dive.

Why a naive user export breaks every login

The instinct is to SELECT your users, dump their emails, and INSERT them into auth.users. That gets you rows in the table and nothing else: the moment a user tries to sign in, GoTrue compares the submitted password against a hash that either isn’t there or isn’t in a format it understands, and authentication fails for every single account.

The password hash is the whole problem. Firebase does not store passwords — it stores a modified scrypt hash of each password, computed with a set of project-specific parameters. If you move the users but not the parameters that make those hashes verifiable, Supabase has no way to check a password. Your only fallback then is a mass password-reset email to your entire user base, which is exactly the outcome you’re trying to avoid. It tanks conversion, floods support, and makes the migration visible to people who should never have noticed it happened.

So the real task isn’t “move users.” It’s “move users and teach Supabase how to verify Firebase’s hashes.” Everything below is about that second half.

Naive export (INSERT emails)

  • Rows land in auth.users
  • No verifiable password hash
  • Every login fails immediately
  • Forces a full password-reset blast

import_users.js + scrypt params

  • Firebase scrypt hashes preserved
  • GoTrue verifies with the 4 params
  • Same password still works
  • Zero forced resets

Where to get the four scrypt params — and what each one means

Open the Firebase Console, go to Authentication, open the Users tab, click the overflow menu (the three dots) at the top of the list, and choose Password hash parameters. Firebase shows you four values that are unique to your project:

  • base64_signer_key — the project-wide signer (HMAC) key Firebase mixes into every hash. This is the secret that makes your hashes yours; treat it like a credential and keep it out of git.
  • base64_salt_separator — a short base64 value appended to each user’s per-account salt before hashing.
  • rounds — the scrypt round count. For Firebase projects this is commonly 8.
  • mem_cost — the scrypt memory cost, commonly 14.

Your exact rounds and mem_cost are whatever the Console shows for your project — do not assume 8 and 14, read them off the screen. These four values are the decoder ring. With them, GoTrue reconstructs the same scrypt computation Firebase used and can confirm a submitted password matches the stored hash.

base64_signer_keyproject HMAC secret
base64_salt_separatorsalt separator (base64)
roundsscrypt rounds (e.g. 8)
mem_costscrypt memory cost (e.g. 14)

Step 1: export your users with firestoreusers2json.js

First, clone the community tooling and give it credentials. The repo is supabase-community/firebase-to-supabase, and it’s configured with two service-account files you drop in the working directory: firebase-service.json (your Firebase credentials) and supabase-service.json (your Supabase credentials).

git clone https://github.com/supabase-community/firebase-to-supabase.git
cd firebase-to-supabase/auth
# place firebase-service.json and supabase-service.json here first

Then export the users. The firestoreusers2json.js script pulls every account — including the preserved scrypt password hashes — into a local JSON file:

node firestoreusers2json.js [<filename.json>] [<batch_size>]

Both arguments are optional. Pass a filename to control the output path and a batch size to tune how many users are fetched per request (useful when you have hundreds of thousands of accounts and want to avoid rate limits). A concrete run looks like:

node firestoreusers2json.js users.json 100

Open the resulting users.json and confirm each record carries a passwordHash and salt. If those fields are present, the hard part is already done — you exported the material GoTrue needs.

Step 2: import into auth.users with the hash params configured

Now configure the four scrypt parameters so the importer knows how to write hashes GoTrue can verify, then run import_users.js. The importer reads your supabase-service.json for the connection and writes into Supabase’s auth.users table:

node import_users.js <path_to_json_file> [<batch_size>]

A real invocation:

node import_users.js users.json 100

The scrypt params are set in the script’s configuration (the values you copied from the Console) so that the imported rows are verifiable. Conceptually you’re handing GoTrue the same recipe Firebase used:

{
  "base64_signer_key": "PASTE_FROM_CONSOLE",
  "base64_salt_separator": "PASTE_FROM_CONSOLE",
  "rounds": 8,
  "mem_cost": 14
}

Run it against a staging Supabase project first. You want to see the row count land in auth.users and confirm no records were silently dropped before you point this at production.

  1. Copy the 4 scrypt paramsFirebase Console → Authentication → Users → password hash parameters
  2. Export usersnode firestoreusers2json.js users.json
  3. Import into auth.usersnode import_users.js users.json — params configured
  4. Verify a real loginsign in with an OLD password, expect success

Step 3: verify a real user logs in with their OLD password

Never declare victory on a row count. Take a known test account whose password you actually remember, and sign in against the Supabase project using the standard client. The success condition is a session returned with no error — using the original Firebase password:

import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!,
);

const { data, error } = await supabase.auth.signInWithPassword({
  email: "known-user@example.com",
  password: "their-original-firebase-password",
});

console.log(error ? `FAILED: ${error.message}` : `OK: ${data.user?.id}`);

If this returns a session, GoTrue verified a Firebase scrypt hash — your params are correct and the migration works. If it fails with “Invalid login credentials” while the email exists in auth.users, your scrypt params are wrong or mismatched; go back to the Console and re-copy all four exactly. Test at least a handful of accounts, not just one, before flipping production traffic.

Session returned, no errorscrypt params correct — migration works
Invalid login credentialsa param is mismatched — re-copy all 4 from the Console
Email missing in auth.usersimport dropped the row — re-run import_users.js
Test 5+ accountsnever flip production on a single passing login

The alternative: first-login verification middleware

Sometimes you can’t or don’t want to bulk-import hashes — maybe you’re migrating gradually, or you never had clean access to the hash export. The community repo also ships this path, and it’s the one to reach for when you’d rather not trust the raw scrypt export to your database. The fallback is first-login verification middleware: on each user’s next sign-in, your app validates the submitted password against the old Firebase credential (via the Firebase Auth SDK), and on success, rehashes that password into Supabase and creates the GoTrue user. From then on, that user authenticates against Supabase natively.

async function migrateOnLogin(email: string, password: string) {
  // 1) Verify against the legacy Firebase credential
  const firebaseOk = await verifyWithFirebase(email, password);
  if (!firebaseOk) return { ok: false };

  // 2) First success → create/rehash into Supabase
  const { error } = await supabaseAdmin.auth.admin.createUser({
    email,
    password, // GoTrue hashes it natively on write
    email_confirm: true,
  });

  return { ok: !error };
}

Use this when a clean hash export isn’t available, when you want a slow trickle migration with Firebase still live as a fallback, or when only a fraction of your users are active and you’d rather not import millions of dormant rows. The tradeoff: you keep Firebase running (and paying for it) until enough users have logged in at least once. For most projects, the bulk import_users.js path is simpler and finishes in one shot — reach for middleware only when the bulk import genuinely can’t apply.

Preserving UIDs and metadata so your foreign keys don’t break

Here’s the trap that bites people after login works: your user_id foreign keys. Every row in your migrated Firestore data — orders, posts, subscriptions — references a Firebase UID. If Supabase assigns brand-new UUIDs on import, all of those references dangle and your app can’t join a user to their data.

import_users.js imports into auth.users preserving the original identifiers, so the Firebase UID carries over as the user’s id. That’s what keeps the foreign keys you migrated in the Firestore-to-Postgres sibling post pointing at the right people. Verify the join explicitly after import:

select count(*) as orphaned
from public.orders o
left join auth.users u on u.id = o.user_id
where u.id is null;

An orphaned count of 0 means every order still maps to a real user. Anything above zero means UIDs drifted and you need to fix the mapping before going live. Carry over user_metadata too — display names, avatars, custom claims — so profile screens don’t render blank on day one.

Edge cases: federated sign-in, MFA, and disabled accounts

Three categories don’t behave like a plain email-and-password user, and each needs a deliberate decision:

  • Google and other federated sign-in. These users never had a password hash — they authenticate through an OAuth provider. There’s nothing to preserve; instead, configure the matching provider (Google, etc.) in Supabase Auth so that when they sign in, GoTrue matches them by email. Confirm your imported federated users carry the email the provider will return.
  • MFA / multi-factor accounts. Enrolled second factors do not transfer through the hash import. Plan to re-enroll MFA in Supabase — communicate it, and gate sensitive actions until re-enrollment for those users.
  • Disabled accounts. Don’t silently reactivate banned or disabled users during import. Check the disabled flag in your exported JSON and either skip those records or mark them so your app keeps enforcing the ban.

Decide the policy for each bucket before you run the production import. Auth choices like these are exactly what I weigh in Better Auth vs Clerk vs Supabase for a Mexican SaaS, and they feed directly into the total cost of a SaaS MVP — a migration that forces resets and re-enrollments is far more expensive than it looks on the invoice.

Pin the scripts and Console paths before you run — the repo moves

One last operational note, and it matters: the firebase-to-supabase repo is community-maintained and evolves, and the Firebase Console occasionally reshuffles where the password hash parameters live. Before you run anything against production, re-read the current Firebase Auth migration guide, confirm the script names and argument order still match, and re-locate the hash parameters under Authentication → Users in the Console. Pin to a known commit of the repo for reproducibility. Then do the full dry run on staging — export, import, and a real old-password login — and only after that green light do you migrate production. Getting this right is the difference between a migration nobody notices and a support queue that never empties.

For the data side of the same move — flattening Firestore documents into normalized Postgres tables and rewriting Security Rules as RLS policies — see the companion Firestore-to-Postgres migration guide.