Docs
Migrate

Move existing users to elvix.

Coming from Auth0, Clerk, Supabase, or your own auth. The default is a same-email claim flow: users sign in with elvix and their existing rows attach silently.

What good migrations look like

A user-visible re-auth is the only step you cannot skip safely. Everything else (the id, the profile fields, the role + scope grants) bridges via the user's email on the first elvix sign-in. The user types their address, gets a code at the same inbox, signs in. Your old database keeps its primary keys; elvix's token verifies against the email-keyed bridge you already have.

elvix does NOT and CANNOT import password hashes from your old system. It does not need them. The OTP / Google / passkey paths are strictly stronger than re-used passwords ever were. Your migration replaces "password recovery" with "sign in once via OTP" — same user, same email, same data, zero password reset support tickets.

The five-minute version

  1. Create an Application in Console (/console/applications/new). Mint one Write API key (see /docs/mcp.md under Authentication).
  2. Pick the id strategy below.
  3. Mount <ElvixSignIn> on a new route alongside your existing sign-in. The first time each legacy user lands on it, they OTP-verify on their existing email and continue into your app with full continuity.
  4. After every active user has signed in once, delete your old sign-in route and the old sessions table.

Two paths, and you can mix them. Lazy bridging needs no import: a legacy user signs in with the email they already have and their elvix rows are created on the spot. Pre-population posts your user list to POST /api/applications/{id}/users/bulk first, which lets you set the sign-in gate to closed on day one so only the addresses you imported can get in. Pre-populate when you do not want strangers signing up alongside your migration; bridge lazily when an open gate is fine.

Choosing an id strategy

elvix issues a stable user.id (u_<random>) per User row. Your existing app already has its own ids. You don't need to rewrite the world — pick the approach that costs you least and keeps your existing data correct.

PatternWhat changes in your appWhen to use
Keep your legacy id as PK; bridge by emailAdd nothing. verifyElvixToken returns the email; SELECT your row by it.Largest legacy schemas. Zero schema churn.
Add an elvix_user_id column on your users tableOne nullable column. Fill it lazily on first sign-in (write the elvix user.id you got from verify).Pragmatic middle. Removes the email join after every user has signed in once.
Adopt elvix.user.id everywhereMigrate every FK that references your old user id.Greenfield rewrites or apps with very few user-FK columns.

Whichever you pick, email is the canonical bridge during cutover. verifyElvixToken returns r.user.email on every successful verify; that string joins one-to-one to your existing users table by email. The elvix id is shadow until you decide to adopt it.

Same-email OTP claim flow

First-time sign-in after the cutover is a normal <ElvixSignIn> mount. The user types the email they used in the old system, gets a code at that inbox, types the code. elvix issues a session token. Your server verifies the token and resolves the legacy row by email.

app/sign-in/page.tsx
"use client";
import { ElvixProvider, ElvixSignIn } from "@elvix.is/sdk/react";
/**
* Cutover sign-in. First time a migrated user signs in we email them a
* one-time code at the same address they used in the old system, they
* verify, and elvix issues a session token. Your old database keeps its
* id; elvix's token verifies via verifyElvixToken on every request.
*
* No password reset, no re-onboarding, no support tickets.
*/
export default function SignInPage() {
return (
<ElvixProvider clientId={process.env.NEXT_PUBLIC_ELVIX_CLIENT_ID!}>
<ElvixSignIn />
</ElvixProvider>
);
}

Server-side, verify the session token as normal and bridge to your legacy row by email. The result is the user looking at the dashboard they already know, with their existing data, the same minute they were prompted to re-sign-in.

app/(authed)/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { verifyElvixToken } from "@elvix.is/sdk/server";
export default async function AppPage() {
const token = (await cookies()).get("elvix_token")?.value;
if (!token) redirect("/sign-in");
const r = await verifyElvixToken({
token,
clientId: process.env.NEXT_PUBLIC_ELVIX_CLIENT_ID!,
});
if (!r.ok) redirect("/sign-in");
// r.user.email is the join key back to your existing rows. Look up
// your legacy user_id by email and you have a bridge: every elvix
// sign-in resolves to the same row you already know about.
const legacy = await prisma.user.findUnique({
where: { email: r.user.email },
select: { id: true, name: true },
});
if (!legacy) {
// First sign-in by a new email after public cutover. Either create
// a fresh legacy row, or refuse and surface a "request access" page.
}
return <h1>Hello, {legacy?.name ?? r.user.name ?? r.user.email}</h1>;
}

Cutover checklist

1. Pull a list of every active user from your old system. You will need email at minimum; name + username + created_at are nice to have. 2. Pick the id strategy from the table above and announce it to the team. The wrong call costs a quarter; the right call costs an afternoon. 3. Create an elvix Application in Console (/console/applications/new). Copy the clientId. Mint one Write API key for any scripted work later. 4. Mount <ElvixSignIn> on a NEW route in your app (/elvix-signin or /sign-in/v2) without removing the old sign-in yet. Run both in parallel for a few days; watch for SDK errors in your logs. 5. When you are ready to cut over: replace your old /sign-in route with the elvix one. The next time a user logs in, they type their email, get a one-time code, and land back in your app. The first request after the OTP arrives carries the new elvix session token; you verify it server-side and bridge to your legacy row by email. 6. Drop the old sessions table when you are confident every active user has signed in once. 7. (Optional) Flip the Application's signinGate to closed afterward if you want to lock out any email not already in your legacy users table. New signups then need an explicit invite path you build.

The three gate stages are: public (anyone can sign in), private_beta (only emails on the BetaInterest allowlist), closed (only existing members of the Application can sign in). If you pre-populate your users first, you can set the gate to closed before the cutover starts and never run an open door. If you rely on lazy bridging alone, keep the gate public for the cutover window and flip to closed once every active user has claimed their email.

Pre-populating users before the cutover

POST /api/applications/{id}/users/bulk creates the elvix membership for each address up front, so a closed gate still lets your migrating users in while refusing everyone else. Send up to 500 rows per request and page; the endpoint is idempotent, so re-sending a batch after a partial failure is safe and creates nothing twice.

migrate-users.sh
# 500 rows per request; page through your export and re-run freely.
curl -X POST https://elvix.is/api/applications/$APP_ID/users/bulk \
-H "Authorization: Bearer $ELVIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"users": [
{
"email": "[email protected]",
"roleSlugs": ["organizer"],
"profile": {
"username": "ada",
"givenName": "Ada", "familyName": "Lovelace",
"locale": "en-GB", "timezone": "Europe/London",
"languages": [{ "code": "en", "level": "NATIVE" }]
}
},
{ "email": "[email protected]" }
]
}'
# => { "success": true, "data": {
# "summary": { "requested": 2, "created": 2, "linked": 0,
# "alreadyMember": 0, "skipped": 0 },
# "results": [ { "email": "[email protected]", "outcome": "created", "userId": "..." } ],
# "unknownSlugs": { "roles": [], "scopes": [], "memberships": [] } } }

Every row reports its own outcome (created, linked, already_member, skipped) so one bad address never fails the batch, and any role slug your app does not have comes back under unknownSlugs rather than being silently dropped.

Bringing profile data across

The optional profile block on each row carries what you already know about the person: username, givenName, familyName, fullName, locale, timezone and languages. Without it a migrated user arrives as an email with no name and no handle, and has to retype everything they already gave you.

An import FILLS BLANKS. It never overwrites a value the user has set or edited in elvix — the same editedFields marker that stops an OAuth refresh clobbering a hand-typed name. That is what makes re-running a batch safe, and what makes it acceptable for an admin key to write fields the user owns.

A username already taken by a DIFFERENT user fails that row with username_taken:<name> and the rest of the batch still lands. It is deliberately not auto-suffixed to ada-2: a username is a URL, and silently pointing an indexed address at a different person is not something you can detect afterwards. Resolve the clash and re-run. A user who ALREADY has a username keeps it — their own choice outranks your list.

Checking usernames before you import

POST /api/applications/{id}/usernames/check (admin key) answers up to 500 handles in one call, so you resolve clashes BEFORE the import instead of discovering them in its error rows. Each result carries the input you sent, the normalised username, and a status of available, taken or format_invalid. Validation is the same rule set the import and the account wizard use, so a name this calls available is one they will accept.

check-usernames.sh
# Up to 500 handles per call. Resolve clashes BEFORE importing.
curl -X POST https://elvix.is/api/applications/$APP_ID/usernames/check \
-H "Authorization: Bearer $ELVIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "usernames": ["ada", "Grace", "x"] }'
# => { "success": true, "data": {
# "results": [
# { "input": "ada", "username": "ada", "status": "taken" },
# { "input": "Grace", "username": "grace", "status": "available" },
# { "input": "x", "username": "x", "status": "format_invalid" }
# ],
# "summary": { "requested": 3, "available": 1, "taken": 1, "formatInvalid": 1 } } }

Usernames are unique PER APPLICATION, so a handle in use in another elvix app does not block it in yours. format_invalid means the name can never be claimed as written — do not spend a clash-resolution pass on it, rewrite it.

Importing an address does NOT mark it verified, and sends no email. An import is you asserting a list; the user proves the address the first time they actually sign in. Plan your comms separately. The imported users hear nothing from elvix until they sign in themselves.

Migrating from specific systems

  • Auth0: export users via GET /api/v2/users?per_page=100 (paginate). The fields you need are email, email_verified, name. Password hashes are not portable; do not try.
  • Clerk: their backend API GET /v1/users returns the same shape. Email-based bridge keeps working the same day you flip.
  • Supabase Auth: query auth.users directly via SQL. email, email_confirmed_at, raw_user_meta_data.full_name are what you need.
  • Firebase Auth: use the firebase-admin Admin SDK's listUsers to paginate; project email, displayName, emailVerified. Firebase scrypt hashes are not portable.
  • Home-rolled: nothing special. Your existing users table already has email + names. You do not need an export at all — the bridge is live SQL.

Operational notes

  • Run the new and old sign-in routes side by side during cutover. There is no flag day. Each user migrates the moment they next sign in.
  • Sessions in your old system stay valid until they expire naturally. You do not invalidate them; you simply stop issuing new ones from the legacy path.
  • Email deliverability is the only thing that can derail an OTP cutover. Confirm Resend (or your forwarding domain) is healthy before the flip. elvix's OTP path uses elvix.is's own sender, so your DNS does not need any new records.
  • Once every active user has signed in once, you can safely DROP the old passwords / sessions / password_reset_tokens tables. The data is no longer load-bearing.

Do not delete your legacy users table during the migration window — it is the bridge target for every elvix sign-in. Once you have either populated elvix_user_id on every row OR migrated every FK to elvix's id, you can stop maintaining the bridge.