Users
Migrate users in and out
Bring an existing user base over without asking anyone to reset a password.
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 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, 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:
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.
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—idbecomes yourexternalId,emailthe address,email_confirmed_atdecidesemailVerified(a timestamp means yes,nullmeans no), andencrypted_passwordis the hash. It is bcrypt, in the$2a$form, which imports as it stands. Skip rows withdeleted_atset, and decide deliberately what to do withbanned_until.auth.identities— one row per linked login.providerisgoogleorgithub, andprovider_idis the subject id you need. Rows with provideremailare the password login and are not identities to import.raw_user_meta_datais written by the user’s own browser in Supabase, so bring it in asunsafeMetadata, not as something to trust.raw_app_meta_datais yours and maps toprivateMetadata— 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:
- Which algorithm, and with which parameters? Find the code that hashes a password, not the column comment. A
bcryptlibrary 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. - Do you know which addresses are verified? If there is no such column, the answer is no, and everything imports as unverified.
- 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:
- They type their password. Lessly Users sees the hash is a foreign one, and verifies the password with the algorithm that hash belongs to.
- If it matches, they are signed in exactly as anyone else is.
- 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
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.
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.
- 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.
- 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.
- 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. - 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
externalIdupdates them in place, including the newer hash. - 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.
- 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.
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.
Next steps
- Configure authentication: the password policy, the linking rule and the OAuth credentials referred to here.
- Manage your end-users: running an import from the management App and reading its result.
- Receive user events: keeping your own tables in step once the cutover is done.
- Run a waitlist: importing a pre-launch list instead of a user base.