DEV Community

Moving Supabase users to Open Source Cloud without resetting their passwords

By Simon Frisk, Eyevinn Technology

I had a small project on Supabase with users in it, and I wanted to move it to Open Source Cloud. One requirement mattered more than the rest: nobody should get an email telling them to reset their password. A forced reset is a bad first impression, since some people ignore it and some mistake it for phishing. This post shows how I moved the users over so they log in with the password they always had.

What the import actually does

Supabase never stores your users' passwords. It stores a bcrypt hash of each one, a string that starts with $2a$10$ in the auth.users.encrypted_password column. Bcrypt is one way: you can check whether a password matches the hash, but you cannot get the password back out. So there is no plaintext to copy, and a leaked export is not a plaintext leak.

You do not need the plaintext. Keycloak, the open source identity server, can take an existing bcrypt hash as is. Upstream Keycloak does not ship a bcrypt provider of its own, but the Keycloak service on OSC accepted bcrypt hashes when I tested it. When the user signs in, Keycloak checks their password against that hash exactly the way Supabase did, so the user never notices the move.

The setup on OSC is two services: a PostgreSQL database and a Keycloak server that stores its data in it.

Getting it running on OSC

Open Source Cloud (OSC) runs open source services as managed instances. I did this through Claude connected to OSC via the OSC MCP server. I described the problem in plain language, and the agent created the PostgreSQL and Keycloak instances (Step 3) without me looking up service IDs or copying connection strings between them. The Keycloak API call and the Python script are plain copy-paste, and you run them yourself.

Step 1: Connect your agent to OSC

Add https://mcp.osaas.io/mcp as an MCP server in Claude Code, Claude Desktop, or any other MCP-compatible tool. Setup guides for each tool are on the OSC MCP page.

Step 2: Export the users from Supabase

In the Supabase dashboard, open the SQL Editor and run:

select id, email, encrypted_password, email_confirmed_at, created_at
from auth.users;
Enter fullscreen mode Exit fullscreen mode

Then choose Export and Download CSV.

Supabase SQL Editor showing the auth.users query, four result rows with emails and password hashes blurred, and the Export menu open with Download CSV highlighted

Treat this file carefully. Hashes are not passwords, but they are still sensitive. Keep it off shared drives and delete it when you are done.

Step 3: Create PostgreSQL and Keycloak

Ask your agent for both services. For example:

Create a PostgreSQL instance, and a Keycloak instance that uses it as its database, with an admin user and a strong admin password.

The services are birme-osc-postgresql and keycloak-keycloak, and you can also create them from the OSC dashboard. Keep the database private so only Keycloak talks to it.

The Keycloak service page in the OSC dashboard with one running instance named sbmigtest, status running

Step 4: Create a realm for your app

Sign in to the Keycloak admin console with the admin user and create a realm for your app. A realm is Keycloak's word for a separate set of users and settings.

One setting to change first. Keycloak asks every user for a first and last name, which Supabase does not have. Left on, the first sign-in fails with "Account is not fully set up". In the realm, go to Authentication, then Required actions, and turn off Verify Profile. If you do have names, you can import them instead.

Keycloak Authentication, Required actions tab, with the Verify Profile row highlighted and its toggle switched Off

Step 5: Import each user with their existing hash

Start with one user you control, verify the login in Step 6, then run the rest.

Keycloak's admin API creates users with POST /admin/realms/<realm>/users, and the body can carry an already hashed password:

{
  "username": "user@example.com",
  "email": "user@example.com",
  "emailVerified": true,
  "enabled": true,
  "credentials": [{
    "type": "password",
    "secretData": "{\"value\": \"$2a$10$<rest of the hash>\"}",
    "credentialData": "{\"hashIterations\": 10, \"algorithm\": \"bcrypt\"}"
  }]
}
Enter fullscreen mode Exit fullscreen mode

secretData and credentialData are JSON strings, not nested objects.

Set emailVerified from email_confirmed_at, so users who confirmed their address on Supabase do not have to do it again. In the script, bool(row["email_confirmed_at"]) works because an unconfirmed user has an empty cell in the CSV.

For more than a handful of users, loop over the CSV. A short Python script with only the standard library is enough:

import csv, json, urllib.parse, urllib.request

KEYCLOAK = "https://<your-keycloak-host>"
REALM = "<your-realm>"
ADMIN_USER = "<admin user>"
ADMIN_PASSWORD = "<admin password>"

def admin_token():
    data = urllib.parse.urlencode({
        "grant_type": "password",
        "client_id": "admin-cli",
        "username": ADMIN_USER,
        "password": ADMIN_PASSWORD,
    }).encode()
    url = KEYCLOAK + "/realms/master/protocol/openid-connect/token"
    with urllib.request.urlopen(urllib.request.Request(url, data=data)) as resp:
        return json.load(resp)["access_token"]

with open("users.csv", newline="") as f:
    for row in csv.DictReader(f):
        body = json.dumps({
            "username": row["email"],
            "email": row["email"],
            "emailVerified": bool(row["email_confirmed_at"]),
            "enabled": True,
            "attributes": {"supabase_id": [row["id"]]},
            "credentials": [{
                "type": "password",
                "secretData": json.dumps({"value": row["encrypted_password"]}),
                "credentialData": json.dumps({"hashIterations": 10, "algorithm": "bcrypt"}),
            }],
        }).encode()
        req = urllib.request.Request(
            KEYCLOAK + "/admin/realms/" + REALM + "/users",
            data=body,
            headers={
                "Content-Type": "application/json",
                "Authorization": "Bearer " + admin_token(),
            },
            method="POST",
        )
        with urllib.request.urlopen(req) as resp:
            print(row["email"], resp.status)
Enter fullscreen mode Exit fullscreen mode

Admin tokens are short lived, so the script gets a fresh one per user. The Supabase user id comes along as an attribute, handy if other tables point to it.

I used Python rather than a shell loop because bcrypt hashes are full of $ characters, which the shell will happily try to expand.

Step 6: Verify with a real password

This step tells you it actually worked. Use an account whose password you know, ideally a test account of your own, and ask the realm for a token:

POST /realms/<your-realm>/protocol/openid-connect/token
grant_type=password&client_id=admin-cli&username=me@example.com&password=the-original-password
Enter fullscreen mode Exit fullscreen mode

This request uses the password grant, which needs Direct access grants enabled on the client. I used the built-in admin-cli client. If the request returns unauthorized_client, open the client in your realm and switch Direct access grants on, or create a throwaway test client with it enabled, and switch it off again when you are done.

With the original Supabase password I got HTTP 200 and an access token back. Then I tried a wrong password on purpose and got HTTP 400:

{ "error": "invalid_grant", "error_description": "Invalid user credentials" }
Enter fullscreen mode Exit fullscreen mode

You want to see both. The first proves the hash came across intact. The second proves the check is real and not accepting anything.

After a user's first successful sign-in, Keycloak re-hashes the password with its own default, so users move to Keycloak's native format on their own.

Step 7: Connect your app

Keycloak speaks standard OpenID Connect, and its login pages and endpoints are reachable directly. Your realm's configuration lives at:

https://<your-keycloak-host>/realms/<your-realm>/.well-known/openid-configuration
Enter fullscreen mode Exit fullscreen mode

Any OIDC library can use that, for example Auth.js (NextAuth) with its Keycloak provider. Users land on Keycloak's sign-in page and log in with their old password.

Users who later forget a password can reset it through Keycloak, so a reset becomes their choice instead of something forced on everyone.

Other hash types

The import works for bcrypt hashes, which start with $2a$, $2b$ or $2y$. Supabase uses bcrypt, so the steps apply as written. For other hash types the same import does not apply, so check the prefix before you start.

Wrapping up

The best moment was the token endpoint answering 200 for a password set on a completely different platform. My users now live in standard PostgreSQL and Keycloak, which can run anywhere you can run a container.

If you have users on Supabase and want them on open source infrastructure, try it with one test account on Open Source Cloud. If a step does not behave the way this post describes, tell us in the community Slack or on GitHub.

What is Open Source Cloud?

Managed open-source cloud for developers who want infrastructure, not DevOps. Run unmodified open-source services, deploy your own code alongside them as a My App, and spin up Postgres, Valkey and S3-compatible storage without touching Kubernetes or cloud config.

Top comments (0)