Migrating User Authentication from Supabase to Clerk.dev: A Step-by-Step Guide
This step-by-step guide simplifies the process of migrating user authentication from Supabase to Clerk.dev. With just a few steps, you will learn to prepare your Clerk.dev environment, extract user data from Supabase, and programmatically create users in Clerk.dev

Do you find the idea of migrating auth from Supabase to Clerk.dev intimidating? Let me assure you, it's less complex than it seems. In this guide, we'll simplify the process and walk you through every step of the migration. With just a handful of slight code tweaks, you'll be on the fast track to integrating Clerk.dev. So, let's dive right in and demystify this process together with a comprehensive, easy-to-follow guide.
More interested on why I switched? Check out the post below:

Step 1: Set Up Your Clerk.dev Account
Navigate to the User & Authentication
settings and select Email, Phone, Username
. This is the stage where you'll want to ensure that the settings mirror those of your existing Supabase account.

Step 2: Establishing Required Secrets as Environment Variables
In order to facilitate the creation of new users, we need to communicate with the Create new User Endpoint. This can be accomplished by using our Clerk API Keys:

Next, we'll store the CLERK_SECRET_KEY
as a Python environment variable within the .env
file, making it accessible from our script.
Lastly, we'll need to secure the database connection string from our existing Supabase instance. This can be achieved by storing it as an environment variable. You can retrieve this string from Project Settings
> Database
.

Our final .env
file should look like this:
CLERK_SECRET_KEY="{YOUR_CLERK_SECRET_KEY}"
PSQL_CONNECTION_STRING="{YOUR_SUPABSE_PSQL_CONNECTION_STRING}"
Step 3: Preparing the Migration Script
In the following I will show you how I set up my migration script. Feel free to edit and adjust the parts that will be slightly different for you.
Setting up env and imports
To get started we will import the following libraries and set up our environment variables form a .env
file:
import os
import psycopg2
import psycopg2.extras # Import for RealDictCursor
from dotenv import load_dotenv
import requests
import time
from tqdm import tqdm # For the progress bar
load_dotenv(
dotenv_path=os.path.join(os.path.dirname(__file__), ".env.local"),
override=True,
verbose=True,
)
Connecting to Supabase and fetching all Users
Next we connect to the instance and run the a sql query to get all the users:
# Get the connection string from the environment variable
connection_string = os.getenv("PSQL_CONNECTION_STRING")
# Connect to the PostgreSQL database
conn = psycopg2.connect(connection_string)
# Create a cursor object using RealDictCursor
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
sql_query = """
WITH user_data AS (
SELECT
id,
email,
encrypted_password,
created_at,
username
FROM auth.users
LEFT JOIN usernames ON usernames.user_id = users.id
),
user_profiles AS (
SELECT
user_id,
first_name,
last_name
FROM public.profiles
)
SELECT
user_data.id::TEXT AS external_id,
user_data.username,
user_profiles.first_name,
user_profiles.last_name,
ARRAY[user_data.email] AS email_address,
encrypted_password AS password_digest,
to_char(user_data.created_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS created_at,
'bcrypt' AS password_hasher,
ARRAY[]::TEXT[] AS phone_number,
ARRAY[]::TEXT[] AS web3_wallet,
NULL AS password,
CAST(true AS BOOLEAN) AS skip_password_checks,
CAST(true AS BOOLEAN) AS skip_password_requirement,
NULL AS totp_secret,
ARRAY[]::TEXT[] AS backup_codes,
'{}'::jsonb AS public_metadata,
'{}'::jsonb AS private_metadata,
'{}'::jsonb AS unsafe_metadata
FROM user_data
LEFT JOIN user_profiles ON user_data.id = user_profiles.user_id
"""
# Execute the SQL query
cur.execute(sql_query)
# Fetch the results
results = cur.fetchall() # Fetching only the first row
# Close the cursor and connection
cur.close()
conn.close()
The SQL query logic works in the following way:
- Get the main
user_data
in one CTE - Get all the
user_profiles
info from a separate table, e.g.first_name
andlast_name
in a second CTE. - Piece all the info together and add any missing parts
The final SQL Select directly corresponds with the needed post body:
SELECT
user_data.id::TEXT AS external_id,
user_data.username,
user_profiles.first_name,
user_profiles.last_name,
ARRAY[user_data.email] AS email_address,
encrypted_password AS password_digest,
to_char(user_data.created_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS created_at,
'bcrypt' AS password_hasher,
ARRAY[]::TEXT[] AS phone_number,
ARRAY[]::TEXT[] AS web3_wallet,
NULL AS password,
CAST(true AS BOOLEAN) AS skip_password_checks,
CAST(true AS BOOLEAN) AS skip_password_requirement,
NULL AS totp_secret,
ARRAY[]::TEXT[] AS backup_codes,
'{}'::jsonb AS public_metadata,
'{}'::jsonb AS private_metadata,
'{}'::jsonb AS unsafe_metadata
FROM user_data
LEFT JOIN user_profiles ON user_data.id = user_profiles.user_id

One of the most awesome things is that we can straight away export the encrypted_password
to Clerk.dev, since Supabase is using bcrypt
as mentioned here:

So all we need to do is just specify the following in SQL:
'bcrypt' AS password_hasher

Connecting to Clerk.dev BackendAPI and creating new users
Finally using the set up environment variable in Step 1, we connect to Backend API and iterate over the rows to create each user in Clerk.dev.
# API endpoint
url = "https://api.clerk.com/v1/users"
# Authorization token (replace with your actual token)
token = f"Bearer {os.environ.get('CLERK_SECRET_KEY')}"
# Headers for the POST request
headers = {"Content-Type": "application/json", "Authorization": token}
# For rate limiting, if you can do 20 requests per 10 seconds,
# you need to wait 0.5 second between each request
wait_time = 0.5
# Send the POST request
for result in tqdm(results):
response = requests.post(url, json=result, headers=headers)
time.sleep(wait_time) # Wait for a bit to respect the rate limit
data = response.json()
if "errors" in data and len(data.get("errors")) > 0:
if data["errors"][0].get("message") == "That email address is taken. Please try another.":
print(f"User with email {result.get('email_address')[0]} already exists")
continue
else:
print(data["errors"])
raise Exception("Error creating user")
else:
print(f"User with email {result.get('email_address')[0]} created successfully")
Migrating user authentication from Supabase to Clerk.dev may initially seem challenging, but as we've seen, it's an achievable task with just a few steps. By following this guide, you've learned to prepare your Clerk.dev environment, extract user data from Supabase, and programatically create users in Clerk.dev.