# 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](/sign-in-setup/importing-users).

## What carries over

| From the old system | In BuildBase                                                                                                      |
| ------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Email address       | The account's address, and what an import matches on. A record without one is not imported.                       |
| Verified status     | Kept, so a verified address is not asked to verify again.                                                         |
| Name                | The account's name.                                                                                               |
| The user's id       | Stored on the user as `externalId`, so your app can map its own rows to the new account.                          |
| Password hash       | Checked at the next sign-in with the old algorithm. See [Passwords](#passwords).                                  |
| Social sign-ins     | Google, GitHub, Microsoft, Apple and LinkedIn, connected to the account. See [Social sign-ins](#social-sign-ins). |
| Phone number        | The account's phone and whether it was verified. See [Phone numbers](#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](/authentication/two-step) 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](/authentication/social-providers#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](/authentication/phone) 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

```bash
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](#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.

| Option                      | What it does                                                                              |
| --------------------------- | ----------------------------------------------------------------------------------------- |
| `--passwords <file>`        | A separate file of password hashes: Auth0's, or Clerk's dashboard CSV. See below.         |
| `--overwrite`               | Update users that already exist instead of skipping them.                                 |
| `--dry-run`                 | Convert 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](/reference/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:

```bash
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:

```bash
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.

```bash
curl -X POST https://api.console.buildbase.app/api/users/import \
  -H "Authorization: Bearer $BUILDBASE_API_TOKEN" \
  -F file=@users.ndjson \
  -F source=legacy-app \
  -F overwrite=true
```

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

| Field       | What it holds                                                                                                                               |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `file`      | Required. NDJSON: one JSON object per line, one user per line.                                                                              |
| `source`    | Optional. The system the export came from, such as `auth0`: letters, digits, `_` or `-`, at most 40. Each `user.imported` event carries it. |
| `overwrite` | `true` 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:

```text
{"email":"ada@example.com","emailVerified":true,"name":"Ada Lovelace","externalId":"user_2abc","password":{"hasher":"bcrypt","hash":"$2a$10$..."},"links":[{"provider":"google","providerAccountId":"110022003300440055006"}]}
```

| Field           | What to send                                                                                                                                   |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `email`         | Required. The person's address.                                                                                                                |
| `emailVerified` | `true` or `false`.                                                                                                                             |
| `name`          | The person's name.                                                                                                                             |
| `externalId`    | Their id in the old system. Stored on the user, and the second thing a line is matched on.                                                     |
| `password`      | A hash and the `hasher` that made it, in one of the shapes below. A plaintext password is refused.                                             |
| `links`         | Social sign-ins: `provider` is `google`, `linkedIn`, `github`, `microsoft` or `apple`, and `providerAccountId` is that provider's id for them. |
| `phone`         | The person's phone number, in E.164 form, such as `+14155550123`. A number on another account is skipped with a warning.                       |
| `phoneVerified` | `true` or `false`: whether the old system had proven the number.                                                                               |
| `username`      | The person's [username](/authentication/usernames). 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

| `hasher`              | `password`                                                                                                                           |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `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

```json
{
  "success": true,
  "total": 3,
  "created": 1,
  "updated": 0,
  "skipped": 1,
  "failed": 1,
  "rows": [
    {
      "line": 1,
      "email": "ada@example.com",
      "status": "created",
      "userId": "6650f1c2a1b2c3d4e5f60718"
    },
    {
      "line": 2,
      "email": "bob@example.com",
      "status": "skipped",
      "userId": "6650f1c2a1b2c3d4e5f60719",
      "reason": "exists"
    },
    {
      "line": 3,
      "email": "cy@example.com",
      "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.

| `reason`             | Meaning                                                                                                                                        |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `exists`             | The user is already here, matched by email or `externalId`. Skipped unless `overwrite` is set.                                                 |
| `invalid_row`        | The line is not a JSON object, or a field has the wrong type. `message` says which.                                                            |
| `invalid_email`      | `email` is missing or is not an email address.                                                                                                 |
| `duplicate_in_file`  | An earlier line in the same file has the same email or `externalId`.                                                                           |
| `plaintext_password` | `password` is the password itself rather than a hash. The user is not imported.                                                                |
| `unknown_hasher`     | `hasher` is not one of the five above.                                                                                                         |
| `invalid_hash`       | The hash is not in the shape its hasher needs.                                                                                                 |
| `limit_reached`      | Your plan's end-user limit is reached. This user and every later new one fail.                                                                 |
| `error`              | Something 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](/authentication/social-providers) - set up the providers your imported users signed in with, before they try.
- [Connected Accounts](/authentication/connected-accounts) - how people add and remove sign-in providers afterwards.
- [Webhooks](/webhooks/overview) - subscribe to `user.imported`.
