BuildBaseBuildBase

Importing Users

Move users from Clerk, Auth0, Firebase or Supabase with their passwords, verified emails and social sign-ins, without a password reset.

Moving auth providers usually means emailing everyone a password reset and losing the people who never click it. An import avoids that. Each user arrives with their email, its verified status, their name, their id in the old system, their password hash and their social sign-ins, and signs in to your app with the same password and the same Google button as before.

There are two ways in: the SDK's command line, which reads a Clerk, Auth0, Firebase or Supabase export as the vendor hands it to you, and the API it calls, for anything else.

Step by step, with screenshots: Import users.

What carries over

From the old systemIn BuildBase
Email addressThe account's address, and what an import matches on. A record without one is not imported.
Verified statusKept, so a verified address is not asked to verify again.
NameThe account's name.
The user's idStored on the user as externalId, so your app can map its own rows to the new account.
Password hashChecked at the next sign-in with the old algorithm. See Passwords.
Social sign-insGoogle, GitHub, Microsoft, Apple and LinkedIn, connected to the account. See Social sign-ins.
Phone numberThe account's phone and whether it was verified. See Phone numbers.

Sessions, passkeys and authenticator apps do not carry over. People sign in once more, and set up a passkey or two-step sign-in again if they want one. Passkeys could not move anyway: a passkey belongs to the domain it was made on.

What an imported user gets

  • No mail. An imported user is not a sign-up, so nobody is sent a welcome or a verification email. Tell people about the move yourself, when you are ready to.
  • user.imported, not user.registered. Each user created by an import emits user.imported, with actorId, userId, email, name, and externalId and source when they are known. A workflow or webhook on user.registered does not run for them. Subscribe to user.imported to sync them to your own systems.
  • They count toward your plan's end-user limit, the same as anyone who signs up. Once the limit is reached, each further new user fails with limit_reached while the users before it stay imported, so check the limit against the size of the export before you start.

Passwords

BuildBase checks an imported password with the algorithm that made it, at the person's next password sign-in. When it matches, BuildBase stores a bcrypt hash of the password (cost 12) in its place and forgets the old hash, so after one sign-in the account is like any other. Resetting or changing the password removes the old hash too.

Sign-in compares the password exactly as typed, so one with <, > or & in it matches its imported hash. A password shorter than the 8 characters BuildBase asks for at sign-up, which Firebase allows down to 6, signs in too: the minimum applies only when a password is set here.

When an export holds a hash BuildBase cannot check, the command line imports that user with no password and says so in its output. They can still sign in with a magic link, an email code or a social provider, or set a new password with Forgot password?. Through the API, a line with a hasher BuildBase does not know fails with unknown_hasher.

Social sign-ins

Google and GitHub give a person the same id in every app, so an imported Google or GitHub link matches at their next sign-in whichever OAuth app you set up in BuildBase.

Microsoft and Apple do not. Microsoft's user id is different for each app registration, and Apple's for each developer team. An imported Microsoft or Apple link matches only when your BuildBase method uses the same app registration as the old system: the same Microsoft application (client) ID, or a Services ID in the same Apple team. With a new registration, those links never match, and the person is found by their verified email address instead, as How sign-in finds an account describes. The import's answer says so, in notices, when a file carries Microsoft or Apple links.

LinkedIn's user id is different for each LinkedIn developer app as well (its OpenID Connect discovery document lists its subjects as pairwise), so an imported LinkedIn link matches only while your LinkedIn method uses the same app as the old system. Otherwise the person is found by their verified email address. The import's answer says this in notices too, when a file carries LinkedIn links.

A link to a provider identity that already belongs to another user here is skipped with a warning, and the rest of that user is imported.

Phone numbers

A line can carry the person's phone number and whether the old system had verified it. Either kind works for phone sign-in as soon as your client offers it, and an unverified number is marked verified the first time a code sent to it signs the person in.

A number already on another account here is skipped with a warning, and the user is imported without it. One number belongs to one account, so an import never moves a number from one person to another. Each imported number emits user.phone_added, with verified saying which kind it was.

The command line

BUILDBASE_SERVER_URL=https://api.console.buildbase.app \
BUILDBASE_API_TOKEN=$BUILDBASE_API_TOKEN \
npx buildbase import --from auth0 users.json

--from is clerk, auth0, firebase or supabase, and the file is that vendor's export. The command converts it to the lines the API takes, sends them in batches of 500, and prints the totals and every row that failed. Running it again skips the users already imported, so a run cut short can simply be run again.

OptionWhat it does
--passwords <file>A separate file of password hashes: Auth0's, or Clerk's dashboard CSV. See below.
--overwriteUpdate users that already exist instead of skipping them.
--dry-runConvert the export and print the first few users as they would be sent. Nothing is sent.
--server-url <url>Your BuildBase server, in place of BUILDBASE_SERVER_URL.
--signer-key <base64>Firebase only: the project's base64_signer_key. Prefer BUILDBASE_FIREBASE_SIGNER_KEY.
--salt-separator <base64>Firebase only: the project's base64_salt_separator.
--rounds <n>Firebase only: the project's rounds.
--mem-cost <n>Firebase only: the project's mem_cost.

BUILDBASE_API_TOKEN is an org API token whose role can create users, made under Settings → API keys (see Admin API). BUILDBASE_SERVER_URL is your BuildBase server; --server-url overrides it for one run. Start with --dry-run: it needs neither, and shows you what the export turns into before anything is written.

npx buildbase runs the copy of the SDK in your project. Without the SDK installed, npx @buildbase/sdk import does the same.

Getting each export

Clerk. Export your users as CSV from the Clerk Dashboard. The CSV carries password hashes but not social sign-ins. To keep both, list your users through Clerk's Backend API as JSON and give that as the file, with the dashboard CSV as --passwords.

Auth0. Run a bulk user export (a users export job, through the Management API or an extension), as JSON. The file may arrive gzipped; the command reads it either way. Auth0's export never contains password hashes. Auth0 provides those only on request to their support, as a separate file: pass it as --passwords. Without it, people come across with no password and sign in another way until they set one.

Firebase. Export with the Firebase CLI's auth:export command, in JSON format:

firebase auth:export users.json --format=JSON

Firebase hashes passwords with its own variant of scrypt, keyed per project. The four values the command needs to check them, the signer key, salt separator, rounds and memory cost, are the project's password hash parameters, shown in the Authentication section of the Firebase console.

The signer key is the secret one: with it, anyone holding the export can test guessed passwords against every hash. Give it to the command in BUILDBASE_FIREBASE_SIGNER_KEY rather than on the command line, so it stays out of your shell history, and pass the other three as flags. read -rs takes it from a paste without echoing it:

read -rs BUILDBASE_FIREBASE_SIGNER_KEY && export BUILDBASE_FIREBASE_SIGNER_KEY
npx buildbase import --from firebase users.json \
  --salt-separator <base64_salt_separator> --rounds <rounds> --mem-cost <mem_cost>

--signer-key still works, and wins over the variable when both are set.

Supabase. Query the auth.users table and save the result as JSON or CSV. Add each user's rows from auth.identities to bring their social sign-ins along. Supabase stores passwords as bcrypt, which carries over as it is.

The API

POST /api/users/import takes an upload of up to 1,000 users and 5 MB at a time; the command line sends larger exports in batches. Call it with a console token, or with an org API token whose role can create users.

curl -X POST https://api.console.buildbase.app/api/users/import \
  -H "Authorization: Bearer $BUILDBASE_API_TOKEN" \
  -F [email protected] \
  -F source=legacy-app \
  -F overwrite=true

It is a multipart upload rather than a JSON body, with three fields:

FieldWhat it holds
fileRequired. NDJSON: one JSON object per line, one user per line.
sourceOptional. The system the export came from, such as auth0: letters, digits, _ or -, at most 40. Each user.imported event carries it.
overwritetrue updates users that already exist instead of skipping them. Leave it out to skip them.

The command line sends the vendor's name as source.

Each line of file is one user:

{"email":"[email protected]","emailVerified":true,"name":"Ada Lovelace","externalId":"user_2abc","password":{"hasher":"bcrypt","hash":"$2a$10$..."},"links":[{"provider":"google","providerAccountId":"110022003300440055006"}]}
FieldWhat to send
emailRequired. The person's address.
emailVerifiedtrue or false.
nameThe person's name.
externalIdTheir id in the old system. Stored on the user, and the second thing a line is matched on.
passwordA hash and the hasher that made it, in one of the shapes below. A plaintext password is refused.
linksSocial sign-ins: provider is google, linkedIn, github, microsoft or apple, and providerAccountId is that provider's id for them.
phoneThe person's phone number, in E.164 form, such as +14155550123. A number on another account is skipped with a warning.
phoneVerifiedtrue or false: whether the old system had proven the number.
usernameThe person's username. One that breaks the rules, or is taken here, is left off with a warning.

Send providerAccountId as a string, never a number. Google's ids are 21 digits long, and a JSON number that long loses its last digits, which turns it into somebody else's id or nobody's. A custom provider's links cannot be imported.

A line is matched to an existing user by email first, then by externalId. With overwrite, an existing user is brought up to the line, but never has the address they sign in with changed, and a password they have set in your app since is kept rather than replaced by the old one.

Password hashers

hasherpassword
bcrypt{ hash }
argon2id, argon2i{ hash }, a PHC string such as $argon2id$v=19$m=...
pbkdf2_sha256{ hash }, in Django's format: pbkdf2_sha256$<iterations>$<salt>$<base64>
firebase_scrypt{ hash, salt, params: { signerKey, saltSeparator, rounds, memCost } }, all base64 except rounds and memCost, which are numbers

The answer

{
  "success": true,
  "total": 3,
  "created": 1,
  "updated": 0,
  "skipped": 1,
  "failed": 1,
  "rows": [
    {
      "line": 1,
      "email": "[email protected]",
      "status": "created",
      "userId": "6650f1c2a1b2c3d4e5f60718"
    },
    {
      "line": 2,
      "email": "[email protected]",
      "status": "skipped",
      "userId": "6650f1c2a1b2c3d4e5f60719",
      "reason": "exists"
    },
    {
      "line": 3,
      "email": "[email protected]",
      "status": "failed",
      "reason": "limit_reached",
      "message": "..."
    }
  ],
  "notices": [
    "Apple user ids are per team: imported Apple links match only if this app uses the same Apple developer team as the old system."
  ]
}

Every line gets a row, with a status of created, updated, skipped or failed. notices holds what applies to the whole file, said once rather than on every row, such as the Microsoft, Apple and LinkedIn rule above. One bad line fails only itself: a 1,000-line file with three bad lines imports 997 users and says which three did not. A row can also carry warnings, such as a link skipped because that identity belongs to another user.

reasonMeaning
existsThe user is already here, matched by email or externalId. Skipped unless overwrite is set.
invalid_rowThe line is not a JSON object, or a field has the wrong type. message says which.
invalid_emailemail is missing or is not an email address.
duplicate_in_fileAn earlier line in the same file has the same email or externalId.
plaintext_passwordpassword is the password itself rather than a hash. The user is not imported.
unknown_hasherhasher is not one of the five above.
invalid_hashThe hash is not in the shape its hasher needs.
limit_reachedYour plan's end-user limit is reached. This user and every later new one fail.
errorSomething else went wrong saving this user. message says what failed when the server can tell, and otherwise asks you to try the line again.

The whole request is refused with a 400, and nothing is imported, when there is no file, the file is not NDJSON, or it is over 1,000 lines or 5 MB.

Next steps

  • Social Providers - set up the providers your imported users signed in with, before they try.
  • Connected Accounts - how people add and remove sign-in providers afterwards.
  • Webhooks - subscribe to user.imported.