Migrate from Supabase Auth to Own Auth
Export the users and identities tables from Supabase's auth schema in one database snapshot and retain each user UUID. Supabase can move password hashes between Supabase projects, but auth.createUser does not import those hashes. Create users without passwords and verify them by magic link.
Export Supabase Auth users
- Export the users and identities tables from Supabase's auth schema in the same database snapshot with SQL or
pg_dump. - Retain each UUID, email and phone confirmation timestamps,
banned_until,deleted_at,is_anonymous,raw_user_meta_data,raw_app_meta_data, and linked identities. Record verified MFA enrolment separately without copying factor secrets. - Find public-table foreign keys to the Supabase auth users table,
storage.objects.owner_idvalues, and Row Level Security policies that call Supabase'suid()orjwt()helpers. - Find browser and mobile queries that depend on a Supabase access token. An Own Auth session will not satisfy those policies.
Map Supabase Auth records
- Normalize the Supabase user UUID to
id. SelectnameandimageUrlonly from the profile fields the application uses. Exclude deleted users and users whose ban is still active. - Use
raw_user_meta_dataonly for non-authoritative profile fields. Users can change that object, so it must not become authorization data. - Translate only the trusted fields used from
raw_app_meta_datainto application roles or permissions. - Remove foreign keys to the Supabase auth users table and change dependent UUID columns where needed before writing Own Auth user IDs.
- Update product records and
storage.objects.owner_idthrough the UUID mapping. - Replace policies that use Supabase's
uid()andjwt()RLS helpers with backend authorization using the mapped Own Auth user ID before switching client traffic. - Transform the matching
identitiesrows from Supabase's auth schema into a sign-in-method inventory. Do not copy identity data or provider tokens into Own Auth. Require Apple, GitHub, or Google authentication again before linking the provider to the mapped account. - Require new MFA enrolment. Create phone-only users through Own Auth phone verification. Link an anonymous Supabase account only while its existing Supabase session can prove access to the application data being reassigned.
Map Supabase user UUIDs to Own Auth users
Transform each source record into the importer fields below. This Postgres table keeps the Supabase user UUID mapping separate from the Own Auth user record. The migration metadata lets a rerun find a previously created user and complete its mapping row.
npm install pg
npm install --save-dev @types/pgCREATE TABLE auth_user_migrations (
source text NOT NULL,
source_user_id text NOT NULL,
own_auth_user_id text NOT NULL,
PRIMARY KEY (source, source_user_id),
UNIQUE (source, own_auth_user_id)
);import { Pool } from "pg";
import { auth } from "../auth";
const databaseUrl = process.env.DATABASE_URL!;
const db = new Pool({
connectionString: databaseUrl,
allowExitOnIdle: true,
});
const source = "supabase-auth";
export type ExportedUser = {
id: string;
email: string | null;
name?: string | null;
imageUrl?: string | null;
disabled?: boolean;
};
export async function importUser(exportedUser: ExportedUser) {
if (exportedUser.disabled) {
return {
status: "skipped",
legacyUserId: exportedUser.id,
reason: "disabled_account",
} as const;
}
if (!exportedUser.email) {
return {
status: "skipped",
legacyUserId: exportedUser.id,
reason: "missing_email",
} as const;
}
const mapped = await db.query<{ own_auth_user_id: string }>(
`SELECT own_auth_user_id
FROM auth_user_migrations
WHERE source = $1 AND source_user_id = $2`,
[source, exportedUser.id],
);
if (mapped.rows[0]) {
return {
status: "imported",
ownAuthUserId: mapped.rows[0].own_auth_user_id,
} as const;
}
let user = await auth.storage.getUserByEmail(exportedUser.email);
if (user) {
const matchesSource =
user.metadata.migrationSource === source &&
user.metadata.legacyUserId === exportedUser.id;
if (!matchesSource) {
throw new Error(`Email collision for ${exportedUser.id}`);
}
} else {
user = await auth.createUser({
email: exportedUser.email,
name: exportedUser.name ?? undefined,
imageUrl: exportedUser.imageUrl ?? undefined,
metadata: {
migrationSource: source,
legacyUserId: exportedUser.id,
},
});
}
await db.query(
`INSERT INTO auth_user_migrations
(source, source_user_id, own_auth_user_id)
VALUES ($1, $2, $3)`,
[source, exportedUser.id, user.id],
);
return {
status: "imported",
ownAuthUserId: user.id,
} as const;
}Transform Supabase Auth records
Join each users row to its identities rows from the same exported Supabase auth-schema snapshot. The transformation keeps editable profile metadata separate from trusted application metadata and sends only supported user fields to the importer.
import type { ExportedUser } from "./import-users";
type SupabaseUser = {
id: string;
email: string | null;
phone: string | null;
email_confirmed_at: string | null;
banned_until: string | null;
deleted_at: string | null;
is_anonymous: boolean;
raw_user_meta_data: Record<string, unknown> | null;
raw_app_meta_data: Record<string, unknown> | null;
};
type SupabaseIdentity = {
user_id: string;
provider: string;
};
function stringValue(value: unknown) {
return typeof value === "string" ? value : null;
}
export function transformSupabaseUser(
record: SupabaseUser,
identities: SupabaseIdentity[],
now = new Date(),
) {
const profile = record.raw_user_meta_data ?? {};
const banExpiresAt = record.banned_until
? new Date(record.banned_until)
: null;
const user: ExportedUser = {
id: record.id,
email: record.email,
name: stringValue(profile.name),
imageUrl: stringValue(profile.avatar_url),
disabled:
record.deleted_at !== null ||
(banExpiresAt !== null && banExpiresAt > now),
};
return {
user,
providers: identities
.filter((identity) => identity.user_id === record.id)
.map((identity) => identity.provider),
phone: record.phone,
isAnonymous: record.is_anonymous,
emailWasVerified: record.email_confirmed_at !== null,
trustedMetadata: record.raw_app_meta_data ?? {},
};
}Verify each imported account
Pass the token from the email link to auth.verifyMagicLink. If it returns mfa_required, complete the challenge. Continue only after complete, which contains the new Own Auth session.
Replace Supabase user UUIDs in application records
Update application records from Supabase user UUIDs to Own Auth user IDs only after the user completes an Own Auth sign-in. Never link records by email alone.
Session replacement
Supabase access and refresh tokens do not become Own Auth sessions. After an Own Auth sign-in succeeds, create the Own Auth session, sign out the Supabase client, and stop accepting Supabase JWTs for the mapped UUID. An issued access token remains valid until its exp anywhere that still accepts it.