Skip to content

Migrate users in and out

Import accounts and supported password hashes, plan the cutover, and export users.

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.

What moves

MovesDoes not move
The account and its profileSessions — everyone signs in once more
Email addresses, with their verified stateSecond factors — TOTP secrets are not exported by anyone
Password hashes in the four supported formatsPassword reset links already in flight
Linked Google and GitHub accountsAnything 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 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.

{
  "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.

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.usersid 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:

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

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.

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.

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.

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.

Next steps

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect