Migrate from Firebase Authentication to Own Auth
Paginate the Firebase Admin SDK's listUsers() API and retain each UID. auth.createUser does not import Firebase password hashes, so create users without passwords and verify them by magic link. Firebase ID tokens, refresh tokens, and session cookies remain Firebase credentials.
Export Firebase Authentication users
- Paginate
getAuth().listUsers(1000, pageToken)and retainuid, email,emailVerified,displayName,photoURL,phoneNumber,disabled,customClaims,providerData, andmultiFactor.enrolledFactors. - For Identity Platform tenants, enumerate each tenant and call
listUsers()through itsTenantAwareAuth. Preserve the tenant ID with every user mapping. - Find application records and Firebase Security Rules that use the Firebase UID or token claims.
- Keep disabled users out of the active import. Treat UIDs referenced by application data but absent from Firebase as deleted accounts.
Map Firebase Authentication records
- Normalize
uidtoid,displayNametoname, andphotoURLtoimageUrl. Exclude disabled users from the active import. - Translate only the custom claims used by the application into explicit roles or permissions.
- Preserve each tenant ID. Map it to an Own Auth organisation only when the Firebase tenant represented the same product organisation.
- Transform
providerDatainto a sign-in-method inventory. Do not copy provider UIDs or 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 Firebase account only while its existing Firebase session can prove access to the application data being reassigned.
- Own Auth sessions are not accepted by Firestore, Realtime Database, or Cloud Storage Security Rules. Replace Firebase-token authorization before switching those clients.
Map Firebase UIDs to Own Auth users
Transform each source record into the importer fields below. This Postgres table keeps the Firebase UID 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 = "firebase-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 Firebase Authentication records
Transform the Admin SDK UserRecord explicitly. The importer consumes only user. Keep the remaining fields in migration state to select the first sign-in path and reconcile tenant access.
import type { UserRecord } from "firebase-admin/auth";
import type { ExportedUser } from "./import-users";
type OAuthProvider = "apple" | "github" | "google";
const oauthProviders: Record<string, OAuthProvider> = {
"apple.com": "apple",
"github.com": "github",
"google.com": "google",
};
export function transformFirebaseUser(
record: UserRecord,
tenantId: string | null,
) {
const providers = record.providerData.flatMap(({ providerId }) => {
const provider = oauthProviders[providerId];
return provider ? [provider] : [];
});
const user: ExportedUser = {
id: record.uid,
email: record.email ?? null,
name: record.displayName ?? null,
imageUrl: record.photoURL ?? null,
disabled: record.disabled,
};
return {
user,
tenantId,
providers,
phone: record.phoneNumber ?? null,
emailWasVerified: record.emailVerified,
mfaWasEnabled: Boolean(record.multiFactor?.enrolledFactors.length),
};
}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 Firebase UIDs in application records
Update application records from Firebase UIDs to Own Auth user IDs only after the user completes an Own Auth sign-in. Never link records by email alone.
Session replacement
Firebase ID tokens, refresh tokens, and session cookies do not become Own Auth sessions. After an Own Auth sign-in succeeds, create the Own Auth session, stop accepting Firebase credentials for the mapped UID, and revoke its Firebase refresh tokens. Existing ID tokens remain usable until expiry unless Firebase verification checks revocation.