Migrating from Auth0 to Self-Hosted Supabase Auth

Move your users from Auth0 to self-hosted Supabase Auth without password resets. Export hashes, import via the Admin API, and drop per-MAU pricing for good.

Cover Image for Migrating from Auth0 to Self-Hosted Supabase Auth

Auth0's pricing model has a way of sneaking up on you. The free tier is generous right up until the moment it isn't, and then you're staring at a per-MAU bill that scales with your success whether or not those users generate revenue. It's one of the most common complaints in indie hacker and startup communities — and one of the most common reasons teams end up evaluating self-hosted Supabase as a replacement.

The good news: Supabase Auth (GoTrue) supports importing users with their bcrypt password hashes intact, which means you can migrate without forcing a single password reset. The less-good news: getting those hashes out of Auth0 requires a support ticket, and a few Auth0 features have no direct equivalent.

This guide walks through the full migration — export, import, OAuth re-setup, and cutover — with the trade-offs stated plainly.

Why teams leave Auth0 for self-hosted Supabase Auth

Three reasons come up repeatedly:

  1. Cost scaling. Auth0's Essentials plan runs ~$35/month for 500 MAUs and climbs steeply from there; B2B plans with organizations and SSO jump into four figures fast. Self-hosted Supabase Auth costs whatever your server costs — it's the same flat infrastructure bill whether you have 500 users or 500,000. We've broken down those numbers in the true cost of self-hosting Supabase.

  2. Data ownership. Your user table lives in your Postgres database, on your hardware, subject to your retention and residency rules. For EU teams with GDPR obligations, that alone can justify the move.

  3. One system instead of two. If your application data is already in Postgres, keeping identities in a third-party SaaS means syncing user records across systems. With Supabase Auth, auth.users is a table you can join against, trigger on, and back up with everything else.

One real-world data point worth reading: a team documented migrating 125,000 users from Auth0 to Supabase with zero password resets. The approach below follows the same shape, adapted for self-hosted instances.

What migrates cleanly — and what doesn't

Be honest with yourself about this before committing:

Auth0 featureMigration path
Email + password users✅ Clean — bcrypt hashes import directly
Email verification status✅ Carried over via email_confirm
Social logins (Google, GitHub, etc.)⚠️ Re-configure providers; users re-link by email on first login
User metadata✅ Maps to user_metadata / app_metadata
MFA enrollments (TOTP)❌ Secrets generally don't export; users re-enroll
Rules / Actions⚠️ Rebuild as Supabase Auth Hooks
Organizations / fine-grained authorization⚠️ Rebuild with RLS and custom claims
Bot detection / attack protection⚠️ Replace with CAPTCHA + rate limiting

The MFA gap is the sharpest edge. If a large portion of your user base has TOTP enabled, plan a re-enrollment flow — our MFA setup guide for self-hosted Supabase covers getting TOTP running on the Supabase side.

Step 1: Export your users from Auth0

Auth0 splits the export into two parts, and only one of them is self-service.

User profiles export via the Management API or the User Import/Export extension — email, metadata, verification status, linked identities.

Password hashes do not export by default. You must open a ticket with Auth0 support and request them (this is available on paid plans; expect a turnaround of days, not hours). The export arrives as a JSON file where each record's oid matches the user_id in your profile export, with the bcrypt hash in passwordHash. The official Supabase migration guide documents this flow.

Request the hash export early — it's the long pole in the schedule. While you wait, build and test everything else against staging accounts.

Step 2: Prepare your self-hosted Supabase instance

Before importing anyone, make sure the target is production-ready:

  • SMTP is configured and deliverable. Verification emails, password resets, and magic links all die silently without it. See our guide to configuring SMTP and email templates.
  • SITE_URL and redirect URLs point at your production domain, not localhost:3000.
  • Password policy matches or relaxes Auth0's. If Supabase enforces a stricter minimum length than Auth0 did, imported users with short passwords can still log in (the hash verifies), but it's worth aligning policies deliberately.
  • Take a backup first. You're about to bulk-write to auth.users; a restore point before the import is non-negotiable. Supascale's automated backups give you a one-click restore if the import goes sideways.

Step 3: Import users with the Admin API

Supabase Auth's admin createUser endpoint accepts a password_hash directly — this is the mechanism that makes a reset-free migration possible. Supabase supports bcrypt (what Auth0 uses) and Argon2.

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

const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
  auth: { autoRefreshToken: false, persistSession: false },
});

for (const user of auth0Users) {
  const hash = hashesByOid.get(user.user_id); // from the support-ticket export

  const { error } = await supabase.auth.admin.createUser({
    email: user.email,
    email_confirm: user.email_verified,
    password_hash: hash, // e.g. "$2b$10$..."
    user_metadata: {
      full_name: user.name,
      auth0_id: user.user_id, // keep for reconciliation
    },
  });

  if (error) console.error(`Failed: ${user.email}`, error.message);
}

Practical notes from real migrations:

  • Rate-limit yourself. Batch in groups of 50–100 with a short delay; hammering GoTrue with tens of thousands of serial requests on a small VPS will back up the connection pool.
  • Store the Auth0 user_id in metadata. When something doesn't reconcile later — and something won't — you'll want the cross-reference.
  • Users without password hashes (social-only accounts) should be created with email_confirm: true and no password; they'll sign in via OAuth as before.
  • Log every failure to a file, not just stdout. Re-run the failures as a second pass.

This is the same Admin API surface covered in our user management guide for self-hosted Supabase, which is worth reading before you script against it.

Step 4: Re-create your social login providers

Social identities don't transfer as credentials — but they don't need to. When a user signs in with Google using the same email address, Supabase Auth matches them to the imported account automatically (ensure GOTRUE_MAILER_AUTOCONFIRM and your identity-linking settings are what you expect).

You will need to configure each OAuth provider against your self-hosted instance — new redirect URIs, new client secrets. On a stock self-hosted setup that means a round of .env edits and container restarts per provider; Supascale replaces that with a provider configuration UI that applies changes without manual restarts. And if you want Google's consent screen to show your brand instead of your API domain, that's solvable — see fixing Google OAuth branding without a custom domain.

Step 5: Replace Rules and Actions with Auth Hooks

Auth0 Rules/Actions logic — adding roles to tokens, blocking signups by domain, syncing to a CRM — maps to Supabase Auth Hooks: Postgres functions (or HTTP endpoints) that run at token issuance, signup, and other lifecycle points. Custom claims that lived in Auth0's app_metadata become claims your hook injects into the JWT, which your RLS policies can then read.

We've written a full walkthrough of auth hooks, custom claims, and RBAC — treat that as the companion piece to this step.

Cutover strategy

A migration of any size should run in this order:

  1. Freeze window. Announce a short maintenance window; disable signups in Auth0 so the export doesn't drift.
  2. Final export + import delta. Re-run the import for users created since your first pass (the auth0_id metadata makes dedup trivial).
  3. Flip the SDK. Deploy the app version using supabase-js auth against your instance.
  4. Keep Auth0 alive, read-only, for 2–4 weeks. It's your rollback path and your reference for support tickets ("I can't log in" → check both systems).
  5. Watch the logs. Failed logins spike briefly during any auth migration — most are stale sessions, but a pattern of bcrypt verification failures means a hash export problem.

Only after the dust settles do you cancel the Auth0 subscription — which was, after all, the point.

The honest summary

Migrating off Auth0 to self-hosted Supabase Auth trades a recurring per-MAU bill for a one-time engineering effort plus ongoing self-hosting responsibility. Password-based users move cleanly with no resets; MFA users re-enroll; Rules become Auth Hooks you now own and maintain. For most teams past Auth0's free tier, the payback period is measured in months — especially when the Supabase instance is already running your database. If you're weighing the broader decision, our self-hosted vs cloud comparison covers the operational side, and Supascale's one-time license (from $99, unlimited projects) handles the backup, domain, and OAuth plumbing so the migration is the hard part — not the years after it.

Further Reading