# Migrate users in and out

You import each account together with the password hash your old system stored. The first time that person signs in, Lessly Users verifies their password against the old hash and quietly re-stores it as its own. Nobody is emailed, nobody notices, and after a while your old hashes have all been replaced.

Exporting is the same promise in reverse, and it is at the end of this page. An authentication service you cannot leave is not one worth adopting.

## 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, and the practical way to reconcile an import you ran twice: a record whose `externalId` already exists updates that user rather than creating a second one. It is not something you can search the directory by — the search box matches an email address or a name — so keep your own mapping as well.

Then run it:

### UI

In the management App (`app.lessly.com`), **Import** runs it in three steps. Paste the rows or upload the file — CSV, TSV or a JSON array. Say which column is the email address, and, if you have them, which are the display name, the password hash and the algorithm that hash belongs to. Then read step three before anything is sent: it counts the rows that will go, counts the rows it rejected, and names each rejection by row number and reason. Rows are sent in batches of at most 500, and the report that comes back names every row the import itself refused.

The screen maps those four columns and no others. A record with metadata, a linked provider account, an `externalId` or a verification flag is a `users_users_import` call, not an upload.

### MCP

Hand the file to your agent and ask:

```text
Import these users into Acme. Validate the file first and show me the rejections before you write anything.
```

The agent needs to be allowed to import users into your product's directory.

Import in batches of a few thousand and give the run time to finish rather than firing everything at once — a large migration is a job to watch, not a request to make. The result tells you, per record, whether a user was created, matched an existing one, or was rejected, and why. 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.

> **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

`passwordHash` is the string your old system stored, in the format it stored it. Four families are understood, and you name which one a hash belongs to rather than leaving it to be guessed: the import takes a foreign hash together with its algorithm, and **Import** rejects a row that carries a hash with no algorithm beside it. An argon2id PHC string of ours is the one exception — it goes in as it is.

| 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=…$…$…` |

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

`emailVerified: true` imports the address as verified, and it 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 `user.updated` before step 5 is worth the ten minutes: your own tables stay in step from the first real sign-up. There is no sign-in event to subscribe to, so if you want to watch the cutover itself, watch your own application's sign-ins rather than waiting for one from us. 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.

> **WARNING**
> **No export carries password hashes**, neither one of ours nor an imported foreign one. Exporting a credential is gated behind an 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.

### UI

**Users → Export** downloads the list exactly as you have it filtered on screen, walking every page and counting the rows as it goes. What you get is a CSV of one row per user — id, primary identifier, name, status, whether that identifier is verified, when the record was created, and when the user last signed in. It is a directory snapshot rather than a migration file; for the full records, and for anything narrower than the whole filtered list, use the tool.

### MCP

Ask your agent:

```text
Export every end-user of Acme with their metadata bags.
```

The agent needs to be allowed to export users from your product's directory.

> **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.
