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.

A first-class bulk-import API (CSV → ApplicationUser rows pre-populated, gate=closed so only listed emails can sign in) is in development. Today the migration path relies on lazy first-sign-in bridging by email instead of pre-population — same user-side outcome, slightly different ops shape. Track progress on github.com/021is/elvix/issues.

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). During the cutover window, keep the gate public so legacy users can claim their email seamlessly. Once you are confident every active user has migrated, flip to closed to lock out any new email.

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.