Mo Tawfik Khalifa · Product leader and builder
All notes

How to migrate from Firebase to Supabase without logging out a single user

9 min read · Mo Tawfik Khalifa
Try it below

Export your Firebase users with their password hashes and create them through Supabase’s admin API, which has checked Firebase’s scrypt hashes natively since Supabase Auth v2.162.0 (September 2024). Every user keeps their password and nobody gets a reset email.

Then reshape Firestore documents into tables, copy Storage, switch on row level security before anyone can reach the API, and cut over with either a short write freeze or a dual-write window.

People are making this move toward a database they like. In the Stack Overflow 2025 Developer Survey (July 2025), 47.2% of developers who used Supabase wanted to keep using it, against 40.1% for Cloud Firestore. PostgreSQL, which Supabase is built on, topped the list at 65.5%.

I have moved apps onto Supabase and fixed database security on live ones. This is the order I use, with the commands, checked against Supabase’s migration guides and its firebase-to-supabase tools.

What should you inventory before you migrate?

Write down everything the app touches in Firebase before you write any SQL. The list decides your plan.

git clone https://github.com/supabase-community/firebase-to-supabase.git
cd firebase-to-supabase/firestore
# add firebase-service.json (Firebase console > Project settings > Service accounts)
node collections.js

Set your app’s shape below to get the plan in order, with what goes wrong at each step.

Try itDescribe your Firebase app. Get the migration plan in order, with what can go wrong at each step.

Your app today

Settings for the move. The plan on the right updates as you change them.

Users
6
Your app has
Cutover
The common mistake

Your cutover

Inventory, schema, users, data, storage, RLS, rehearse, cut over

The plan follows the article above: design tables as a migration with RLS in it, import users with their password hashes, load and reshape the data, copy Storage, rehearse, then freeze and cut, or dual-write if you cannot stop. Keep Firebase read-only for rollback.

Order of operations

Step by step

    Risk levels are my rules of thumb from the steps in this article, not a quote. Sample people and data are made up.

    How do you turn Firestore documents into Postgres tables?

    Design the tables first, as a migration, then load data into them. Firestore rewards copying data into every document that shows it. Postgres rewards storing each fact once and joining.

    1. One table per collection. Keep the document ID in a firestore_id text unique column so every row traces back.
    2. Subcollections become child tables with a foreign key to the parent.
    3. Arrays of objects become child tables. Arrays of plain values can stay as text[].
    4. Maps you filter on become columns. Maps you only display can stay jsonb.
    5. Denormalised copies, such as the author’s name on every post, get dropped and replaced by a join.
    6. Firebase user IDs are free-form strings and Supabase user IDs are UUIDs, so keep firebase_uid on the profile and remap every reference through it.
    supabase link --project-ref your-project-ref
    supabase migration new firebase_schema
    
    -- supabase/migrations/<timestamp>_firebase_schema.sql
    create table public.profiles (
      id uuid primary key references auth.users on delete cascade,
      firebase_uid text unique not null,
      display_name text
    );
    create table public.orders (
      id bigint generated always as identity primary key,
      firestore_id text unique not null,
      user_id uuid not null references public.profiles (id),
      status text not null,
      total_pence integer not null,
      created_at timestamptz not null
    );
    create table public.order_items (  -- was the items array inside each order
      order_id bigint not null references public.orders (id) on delete cascade,
      sku text not null,
      qty integer not null check (qty > 0)
    );
    create index on public.orders (user_id);
    
    -- RLS goes in the same migration as the tables, never later
    alter table public.profiles enable row level security;
    alter table public.orders enable row level security;
    alter table public.order_items enable row level security;
    revoke all on public.profiles, public.orders, public.order_items from anon, authenticated;
    grant select on public.profiles, public.orders, public.order_items to authenticated;
    create policy "own profile" on public.profiles for select to authenticated
      using ((select auth.uid()) = id);
    create policy "own orders" on public.orders for select to authenticated
      using ((select auth.uid()) = user_id);
    create policy "own items" on public.order_items for select to authenticated
      using (exists (select 1 from public.orders o
                     where o.id = order_id and o.user_id = (select auth.uid())));
    
    supabase db push --dry-run
    supabase db push

    How do you migrate Firebase Auth users without resetting passwords?

    1. In the Firebase console, open Authentication, then Users, then the three-dot menu, then Password hash parameters. Copy base64_signer_key, base64_salt_separator, rounds and mem_cost. Treat them like a password.
    2. Export the users with the Firebase CLI. Its auth:export writes each user’s passwordHash and salt in standard base64, plus customAttributes, which holds custom claims. Users Firebase imported from elsewhere with another hash come out with an empty hash and will need a reset.
    3. Create each user through the admin API with a password_hash in Supabase’s Firebase format. The supabase-js types say password_hash supports bcrypt, Firebase scrypt and argon2.
    firebase auth:export users.json --format=json --project your-firebase-project
    npm i @supabase/supabase-js
    
    // import-users.mjs   run: node import-users.mjs users.json
    import { readFileSync } from 'node:fs';
    import { createClient } from '@supabase/supabase-js';
    
    const e = process.env; // SUPABASE_URL, SUPABASE_SECRET_KEY, FB_SIGNER_KEY, FB_SALT_SEPARATOR, FB_ROUNDS, FB_MEM_COST
    const sb = createClient(e.SUPABASE_URL, e.SUPABASE_SECRET_KEY, { auth: { persistSession: false } });
    const { users } = JSON.parse(readFileSync(process.argv[2], 'utf8'));
    
    for (const u of users) {
      const hash = u.passwordHash &&
        `$fbscrypt$v=1,n=${e.FB_MEM_COST},r=${e.FB_ROUNDS},p=1,ss=${e.FB_SALT_SEPARATOR},sk=${e.FB_SIGNER_KEY}$${u.salt}$${u.passwordHash}`;
      const { error } = await sb.auth.admin.createUser({
        email: u.email,
        phone: u.phoneNumber,
        email_confirm: !!u.emailVerified,
        phone_confirm: !!u.phoneNumber,
        ...(hash && { password_hash: hash }),
        app_metadata: { firebase_uid: u.localId, ...JSON.parse(u.customAttributes || '{}') },
        user_metadata: { name: u.displayName, avatar_url: u.photoUrl },
      });
      if (error) console.error(u.localId, error.message);
    }

    Four things catch people here.

    insert into public.profiles (id, firebase_uid, display_name)
    select id, raw_app_meta_data ->> 'firebase_uid', raw_user_meta_data ->> 'name'
    from auth.users;

    How do you move the Firestore data itself?

    Export each collection to JSON, load it raw into a holding table, then reshape it with SQL. Two warnings from reading the export script. It pages with offset, and Firestore charges a read for every skipped document, so the bill grows with the square of a large collection. And it writes timestamps as {"_seconds": ..., "_nanoseconds": ...} objects. For anything big, export with a cursor and convert as you go.

    // export.mjs   run: node export.mjs orders   (writes orders_raw.json)
    import { readFileSync, createWriteStream } from 'node:fs';
    import { initializeApp, cert } from 'firebase-admin/app';
    import { getFirestore, FieldPath, Timestamp, DocumentReference } from 'firebase-admin/firestore';
    
    initializeApp({ credential: cert(JSON.parse(readFileSync('firebase-service.json', 'utf8'))) });
    const plain = v => v instanceof Timestamp ? v.toDate().toISOString()
      : v instanceof DocumentReference ? v.path
      : Array.isArray(v) ? v.map(plain)
      : v && v.constructor === Object ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x)]))
      : v;
    const name = process.argv[2], out = createWriteStream(`${name}_raw.json`);
    let last, n = 0;
    out.write('[');
    for (;;) {
      let q = getFirestore().collection(name).orderBy(FieldPath.documentId()).limit(1000);
      if (last) q = q.startAfter(last);
      const snap = await q.get();
      if (snap.empty) break;
      for (const d of snap.docs) out.write((n++ ? ',\n' : '\n') + JSON.stringify({ firestore_id: d.id, ...plain(d.data()) }));
      last = snap.docs.at(-1);
    }
    out.end('\n]\n');
    console.log(n, 'documents');

    Load it with the Supabase tool, which creates a table named after the file in public with RLS off. Move it out of the API’s reach straight away, reshape, then compare counts.

    node json2supabase.js ./orders_raw.json
    
    create schema if not exists staging;
    alter table public.orders_raw set schema staging;
    
    insert into public.orders (firestore_id, user_id, status, total_pence, created_at)
    select r.firestore_id, p.id, r.status, round(r.total * 100), r.created_at::timestamptz
    from staging.orders_raw r
    join public.profiles p on p.firebase_uid = r.uid;
    
    insert into public.order_items (order_id, sku, qty)
    select o.id, i ->> 'sku', (i ->> 'qty')::int
    from staging.orders_raw r
    join public.orders o using (firestore_id)
    cross join jsonb_array_elements(r.items) i;
    
    select (select count(*) from staging.orders_raw) as firestore,
           (select count(*) from public.orders) as postgres;

    If the two counts differ, the join dropped rows, usually orders whose user no longer exists. Find them before cutover, not after. The functions used are in the Postgres JSON docs.

    How do you move Firebase Storage?

    node download.js "" downloads pulls the bucket to disk and node upload.js "" downloads avatars pushes it to a Supabase bucket, which the tool creates as private. Two follow-ups: rewrite any firebasestorage.googleapis.com links saved inside your documents, and write storage policies. If your paths look like users/{uid}/... with the Firebase user ID, match the folder against the ID you imported.

    create policy "read own files" on storage.objects for select to authenticated
    using (bucket_id = 'avatars'
      and (storage.foldername(name))[2] = (select auth.jwt() -> 'app_metadata' ->> 'firebase_uid'));

    Why must row level security be on before go-live?

    Firebase blocks a client that has no matching rule. Supabase works the other way round. Its RLS guide says a table in an exposed schema without RLS is readable and writable by any role with a grant on it, and the publishable key shipped in your app makes requests as the anon role, which on many projects still holds those grants. In March 2025 a researcher scanned 1,645 apps featured by the Lovable app builder and found 170 of them exposing data through insufficient RLS (his write-up, filed as CVE-2025-48757). Write a policy test with supabase test db, and run the Supabase security checklist before the switch.

    If you used onSnapshot, add each table to the supabase_realtime publication. Supabase’s Realtime docs say RLS is not applied to delete events, and suggest Broadcast past about 3,000 subscribers on the same changes.

    Freeze-and-cut or dual-write?

    Freeze-and-cut suits most apps that can take an hour of maintenance. Announce it, make Firestore read-only with rules (allow write: if false;), pause Cloud Functions too because the Admin SDK ignores rules, export the final state, import, check counts, ship the app config that points at Supabase, reopen.

    Dual-write is for apps that cannot stop, and for mobile apps where old versions keep talking to Firebase for weeks. New code writes to both, a backfill copies history, a nightly job compares counts, reads switch once the numbers match, and Firebase writes stop last. It costs more code and every write path has to be found.

    Rollback. Keep the Firebase project untouched and read-only for at least a month. Keep the previous app build ready to point back. Anyone who signs up or changes their password after cutover exists only in Supabase, so export them before you roll back.

    How do you fix a broken Supabase migration history?

    It breaks when someone changes the schema in the dashboard, renames a migration file, or a push fails halfway. supabase db push then stops with “Remote migration versions not found in local migrations directory.” The CLI reference fix: compare, mark, pull.

    supabase migration list                       # LOCAL vs REMOTE, by timestamp
    
    # remote has a version you don't have locally: drop it from the history
    supabase migration repair 20260101120000 --status reverted
    supabase db pull                              # capture the real schema as a new file
    
    # you ran a local file by hand: record it as applied without running it again
    supabase migration repair 20260102090000 --status applied
    
    supabase db push --dry-run

    repair only edits the history table, supabase_migrations.schema_migrations. It never changes your schema, which is why it is safe and why it is not a fix on its own.

    Questions people ask

    Can I migrate Firebase Auth to Supabase without password resets?

    Yes. Export users with firebase auth:export, then create them with auth.admin.createUser and a password_hash in the $fbscrypt$ format built from your project’s hash parameters.

    How long does a Firebase to Supabase migration take?

    It depends on the number of collections and write paths more than on the number of users. A full rehearsal on a staging project tells you the real cutover time.

    Do I keep my Firebase user IDs?

    Not as the primary key, because Supabase user IDs are UUIDs. Keep the old ID in app_metadata and on the profiles table, and remap references through it.

    What is supabase migration repair for?

    Fixing the migration history table when it no longer matches your migration files. It marks versions applied or reverted without running any SQL.

    Do I need a database migration consultant or database migration services?

    For a small app, this page is enough. For live payments, many write paths, or a mobile app that cannot stop, an outside review of the cutover and the policies is cheap insurance. More on how I work as a forward deployed engineer, and on keeping AI agents away from your production database.

    If you need a migration like this planned or done, write to me at m@tawfik.tv.

    Read next

    See all notes