# Migrate users in and out

Import accounts with password hashes in a supported format. When a user next signs in successfully, Lessly Users verifies the password against the imported hash and replaces it with its own hash.

The final section explains how to [export users](#export-again).

## What moves

| Moves | Does not move |
|---|---|
| The account and its profile | Sessions — everyone signs in once more |
| Email addresses, with their verified state | Second factors — TOTP secrets are not exported by anyone |
| Password hashes in the four supported formats | Password reset links already in flight |
| Linked Google and GitHub accounts | Anything the old system's ids are the key to |

The one item on the right that costs you work is the last. Lessly Users issues its own stable opaque id for every user, and that is the id in every token and every webhook from then on. Keep your old id: store it on the imported user so your existing rows still resolve, and switch your own tables to the new id when it suits you. [Lessly Users](/ship/users) explains why the email address is not the key on either side.

## Prepare the import records

An import is a list of records. Only `email` is required; everything else is there if you have it.

```json
{
  "externalId": "42817",
  "email": "ada@example.com",
  "emailVerified": true,
  "firstName": "Ada",
  "lastName": "Lovelace",
  "passwordHash": "$2b$12$K3Jq8p1L2mN4oP5qR6sT7uV8wX9yZ0aB1cD2eF3gH4iJ5kL6mN7oO",
  "identities": [
    { "provider": "google", "providerUserId": "104773829105551029382" }
  ],
  "publicMetadata": { "plan": "pro" },
  "privateMetadata": { "legacyCustomerId": "cus_88213" },
  "createdAt": "2023-04-11T08:22:00Z"
}
```

`externalId` is your old identifier; it is stored on the user and is what your own rows resolve through afterwards.

Re-running a file is safe on its own account: the import is idempotent by canonical identifier, so a row whose address already exists verified in this product is **skipped** rather than duplicated. The run answers with a per-row report — the row, what happened to it, the user id, and the reason where there is one — plus the counts, and one malformed row never fails the call.

**Users → Import** is the screen for it, and it is a wizard of three steps: paste the rows or choose a file, say which column is which, then read what it is about to do and commit. It parses CSV, TSV and a JSON array, sends the rows in batches under the hood, and finishes on a report that names every row it could not import and why. You can also hand the file to an agent and ask it to run the import instead.

The wizard maps four of those fields — the address, the display name, the password hash and the algorithm it is in. A record carrying more than that, which is most real migrations, goes through the product's API rather than the screen: `externalId`, the verified state, the identities, the metadata bags and the original `createdAt` have no column to map to on **Users → Import**.

`users_users_import` takes **at most 500 rows a call**, so batch beyond that on your side, and give a large run time to finish rather than firing everything at once — a migration is a job to watch, not a request to make.

The result of a run tells you, per record, whether a user was created, matched an existing one, or was rejected, and why. On the screen that is the report the wizard ends on. The step before it, which counts what will be sent, only ever reports rows it could not read; a row that collides with an address already in the directory looks fine there and comes back as a rejection in the report. Rejections are usually a malformed address, a duplicate inside the same file, or a hash string that cannot be read. Fix those records and re-run the file — an address that already landed is skipped rather than imported twice.

> **NOTE**
> An import does not send email. Nobody is notified that they have been moved, which is what you want during a cutover — and which also means the verification and invitation emails below are yours to trigger deliberately.

### Where to import from

**Clerk.** Read the user list through Clerk's backend API. It gives you the id, the email addresses with their verification state, the profile fields, the metadata bags and the external accounts including each provider's subject id — everything the import record wants except one thing. Password hashes are not part of that response; ask Clerk for an export of them, which they provide for customers who are leaving, and they will tell you which algorithm the digests use. Plan for the request to take some days and run the rest of your migration in the meantime. Their metadata maps across almost directly: Clerk's public, private and unsafe bags mean the same here as they do there.

**Supabase Auth.** Everything is in the `auth` schema of your own database, so this one is a `SELECT`.

- `auth.users` — `id` becomes your `externalId`, `email` the address, `email_confirmed_at` decides `emailVerified` (a timestamp means yes, `null` means no), and `encrypted_password` is the hash. It is bcrypt, in the `$2a$` form, which imports as it stands. Skip rows with `deleted_at` set, and decide deliberately what to do with `banned_until`.
- `auth.identities` — one row per linked login. `provider` is `google` or `github`, and `provider_id` is the subject id you need. Rows with provider `email` are the password login and are not identities to import.
- `raw_user_meta_data` is written by the user's own browser in Supabase, so bring it in as `unsafeMetadata`, not as something to trust. `raw_app_meta_data` is yours and maps to `privateMetadata` — but read it first, because that is where roles usually live, and roles are an authorisation decision you are about to make somewhere new.

Users with a `null` `encrypted_password` signed up through a provider and never had a password. Import them with their identity and no hash; they carry on clicking the same button.

**A home-grown database.** The work is the same, and the only real question is what your `users` table does for a password. Read your schema and answer three things:

1. **Which algorithm, and with which parameters?** Find the code that hashes a password, not the column comment. A `bcrypt` library call gives you a string you can import unchanged. A hand-rolled PBKDF2 with the salt and the iteration count in separate columns needs assembling into the format below. A digest with a pepper mixed in cannot be imported at all.
2. **Do you know which addresses are verified?** If there is no such column, the answer is no, and everything imports as unverified.
3. **Is there more than one row per person?** Home-grown schemas often grow a second row for a social login. Those are one user with two ways in, and the import record already has room for both — deduplicate before you export, not after.

Write the export as a script that produces the record shape above, run it against a copy, and import that into a non-production product. Keep the script: step 4 of the cutover runs it again.

## Supply the password hashes

A password comes in as a hash and never as a password. Pass `password.phcHash` for an argon2id PHC string of ours, which goes in as it is; for a hash from the system you are leaving, pass `password.foreignHash` together with `foreignHashAlgo`, which names the family rather than leaving it to be guessed. Four families are understood:

| Family | What the string looks like |
|---|---|
| bcrypt | `$2a$`, `$2b$` or `$2y$`, then the cost and the salted digest |
| argon2 | `$argon2id$v=19$m=…,t=…,p=…$…$…` (also `argon2i`, `argon2d`) |
| PBKDF2 | `$pbkdf2-sha256$` or `$pbkdf2-sha512$`, then the iteration count, salt and digest |
| scrypt | `$scrypt$ln=…,r=…,p=…$…$…` |

A foreign hash is stored inert — nothing verifies it until that person signs in, where it is checked once and rehashed — so set `requiresReset: true` on rows whose source hash you do not trust.

If your hashes are stored in columns rather than one string — a digest here, a salt there, an iteration count in a third — assemble them into the format above before you import. The parameters must be the ones the hash was made with; a PBKDF2 digest imported with the wrong iteration count simply never matches.

Nothing happens to an imported hash until its owner signs in. Then:

1. They type their password. Lessly Users sees the hash is a foreign one, and verifies the password with the algorithm that hash belongs to.
2. If it matches, they are signed in exactly as anyone else is.
3. In the same moment the password is re-hashed with argon2id and the old hash is discarded.

From their second sign-in on there is no trace of the old system. Sign-ins keep working throughout the transition, and there is no batch job to run: the upgrade happens one person at a time, as they come back.

The password policy in [Configure authentication](/ship/users/configuration) is not applied to an imported hash — it cannot be, since nobody has the password. It applies the next time that person changes it. The breached-password check runs after a successful sign-in rather than during it, so a user whose password turns up in a public breach is flagged and asked to change it without their sign-in being blocked.

## Handle hashes that cannot come over

Some hashes cannot come with you: MD5 or SHA-1 with a scheme of your own, a peppered hash whose pepper you would rather not move, a format outside the four above, or a provider who will not export hashes at all.

Import those users without a `passwordHash`. The account, the addresses, the metadata and the social logins all arrive; only the password is missing, and the user is marked as needing to set one. What they experience:

- Any password they type is refused — there is nothing to compare it to, and the message tells them to set a new password rather than that their password is wrong.
- The recovery flow works from their verified address, and setting a password there clears the mark.
- If you would rather reach them first, send the invitation email at import time. It carries a link that sets their first password, and it is the kinder option when you know in advance that a whole segment is affected.

Where a user has a linked Google or GitHub account, or where you have their verified address and email codes are switched on, they have a way in that never involves a password and this is barely an interruption at all.

## Set emailVerified honestly

**Imported identifiers default to verified**, unlike a user you create one at a time — the import assumes you are carrying over addresses your old system already confirmed. Pass `verified: false` per identifier for the ones it never did.

An address imported as verified stays verified: nobody is asked to confirm an address they confirmed years ago.

Import it as `false` when you are not certain. Verification lives on the address, not on the person, so an unverified import is a normal state: the user is asked to confirm the next time it matters, and every other address they hold is unaffected.

> **DANGER**
> A verified address is what lets an account be recovered by email, and what lets a Google or GitHub login attach itself to an existing account. Importing an unproven address as verified is handing over an account to whoever controls that mailbox. If your old system did not track verification at all, import everything as unverified and let it settle.

## Carry the linked social accounts

A social login is a link between your user and the account they hold with the provider. To carry it over you need the provider's own subject id for that person — a long numeric string for Google, a numeric id for GitHub — which is what `providerUserId` in the record above is. The email address is not enough and is not a substitute: people change the address on their Google account.

With the link imported, "Sign in with Google" lands them on the account they already had. Without it, the same click is judged by the ordinary linking rule: the provider account attaches to an existing user only when the provider says the email is verified and it matches a verified address of exactly one of your users, and it creates a new account otherwise. That rule is safe, but it means an unverified import can leave one person holding two accounts. Register your own Google and GitHub applications before the cutover and put their credentials in the configuration. The shared development credentials are for trying the flow out, not for a live user base.

## Plan for sessions

Sessions do not come across. There is no way to import one: a session here is minted by a completed flow and carries credentials only Lessly Users ever issued.

So plan for everyone signing in once, and decide when. Ending every session in the old system on cutover day is the honest version — one wave of sign-ins, one clear moment, no ambiguity about which system is authoritative. Letting old sessions run down on their own is gentler on the user and harder on you, because for as long as they last you have two systems that both believe they know who is signed in.

> **WARNING**
> If you do let them run down, keep the old system read-only for that window: serve the session, refuse anything that changes a credential. A password changed in the old system after the import is a password that quietly stops working.

## Run a phased cutover

You do not have to move everything on one evening.

1. **Import into a non-production product first.** Take a copy of your user table, run it through, and sign in as three or four people: one with a bcrypt password, one whose hash could not come over, one with a social login. This is where a malformed hash format shows up, and it costs nothing here.
2. **Import into production, with the old system still live.** Nobody is emailed and nothing is switched on. Your users carry on signing in where they always did, and you now have a directory that mirrors them.
3. **Reconcile.** Read your product's users back through `@lessly/users`, and check the count and a sample against your own table. Store the new user id alongside your old one — this is the moment to write that column.
4. **Re-import the delta.** Between step 2 and the switch, people signed up and changed their passwords in the old system. Export the accounts that changed since your first export and run the same import again; matching on `externalId` updates them in place, including the newer hash.
5. **Switch sign-in over.** Point your application at Lessly Users. Now, and not before, is when it matters that your callback and origins are allowed, that your production keys are deployed, and that the invitation emails for the password-less users go out.
6. **Freeze the old system.** Read-only immediately, so a credential can never change in two places, and deleted once you have watched the sign-in rate for a few days and the daily delta re-imports come back empty.

Subscribing to `user.created` and `session.created` before step 5 is worth the ten minutes: your own tables stay in step from the first real sign-in. See [Receive user events](/ship/users/webhooks).

## Export again

Your users are yours, and you can take them out. `users_users_export` pages through full records — the ids, the addresses and their verification state, the profile fields and the metadata bags — using the same filters the directory listing takes. Every call is audited.

> **WARNING**
> **No export carries password hashes**, neither one of ours nor an imported foreign one. Exporting a credential is deferred behind an explicit elevated approval that does not exist yet, so today the people you move on with will set a password once, the same way an unverifiable hash is handled above. Plan the cutover with that in it.

**Export** on **Users → Users** is the screen. It downloads the list exactly as you have it filtered on screen, walking every page of the result rather than the one you can see and counting the rows as it goes. What you get is a CSV — a directory snapshot rather than a migration file; for the full records, and for anything narrower than the whole filtered list, use the tool.

> **DANGER**
> An export is a privileged operation even without hashes in it: it is every address and every metadata bag you hold, in one file. Treat that file the way you treat your server key.

## Next steps

- [Configure authentication](/ship/users/configuration): the password policy, the linking rule and the OAuth credentials referred to here.
- [Manage your end-users](/ship/users/user-management): running an import from the management App and reading its result.
- [Receive user events](/ship/users/webhooks): keeping your own tables in step once the cutover is done.
- [Run a waitlist](/ship/users/waitlist): importing a pre-launch list instead of a user base.
