Moving users off Firebase Auth without forcing password resets
A lazy migration that recreates each user on first login instead of importing hashes, why the bulk-import shortcut is closed, and how to run both systems for a week.
You can’t carry Firebase’s password hashes into Infrai, so the migration that works is lazy: keep Firebase as the credential authority for as long as it takes, create each user on the Infrai side the first time they sign in, and cut over when the tail is small enough to email. POST /v1/auth/user/create, GET /v1/auth/user/get_by_email and POST /v1/auth/identity/resolve are the three calls you need, and none of them require the old hash.
The alternative — a bulk import plus a forced reset for everyone — is a support incident with a date on it. Don’t.
Why the hashes can’t come with you
Firebase uses a scrypt variant with a project-specific salt separator and signer key. Infrai stores argon2id. There is no transformation between them, which means no import path exists for the secrets themselves, and any vendor claiming otherwise is asking you to keep the old KDF forever.
That’s the constraint. Everything below is how to live with it gracefully.
The lazy migration, in one function
Your login handler tries Infrai first. If the user isn’t there yet, it falls back to Firebase, and on a successful Firebase login it creates the Infrai user with the password the person just typed — which is the only moment you legitimately hold the plaintext.
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
SESSION = requests.Session()
SESSION.headers.update(HEADERS)
def infrai_user(email: str) -> dict | None:
resp = SESSION.get(f"{API}/v1/auth/user/get_by_email", params={"email": email}, timeout=10)
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()["data"]
def create_migrated_user(email: str, password: str, display_name: str | None,
legacy_uid: str) -> dict:
"""Create the user with the password the person just proved they know, and
keep the old uid in metadata so support can still match records."""
resp = SESSION.post(
f"{API}/v1/auth/user/create",
json={
"email": email.strip().lower(),
"password": password,
"name": display_name,
"metadata": {"legacy_provider": "firebase", "legacy_uid": legacy_uid,
"migrated_at": "2026-09-21T02:24:30Z"},
},
timeout=15,
)
body = resp.json()
if not body.get("ok"):
# Two requests racing on the same address: treat the loser as a success.
if body.get("error", {}).get("code") == "AUTH_USER_EXISTS":
return infrai_user(email)
raise RuntimeError(body["error"]["code"])
return body["data"]
def login(email: str, password: str, firebase_signin) -> dict:
"""firebase_signin(email, password) -> dict | None, your existing call."""
existing = infrai_user(email)
if existing:
session = SESSION.post(
f"{API}/v1/auth/session/create",
json={"email": email, "password": password, "method": "password"},
timeout=15,
)
session.raise_for_status()
return session.json()["data"]
legacy = firebase_signin(email, password)
if not legacy:
raise PermissionError("invalid credentials")
user = create_migrated_user(email, password, legacy.get("displayName"), legacy["localId"])
session = SESSION.post(
f"{API}/v1/auth/session/create",
json={"user_id": user["user_id"], "method": "password"},
timeout=15,
)
session.raise_for_status()
return session.json()["data"]
Handling AUTH_USER_EXISTS as success matters more than it looks: two tabs logging in at once will race, and a migration that throws on the loser produces a failed login for a user whose credentials were correct.
Social logins don’t need the password dance
A Firebase user who only ever signed in with Google has no password to carry. Resolve the provider identity instead:
curl -sS -X POST "https://api.infrai.cc/v1/auth/identity/resolve" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"type": "external",
"value": "104829173640192837465",
"provider": "google",
"email": "ada@example.com",
"verified": true,
"create": true
}'
Use the provider’s stable subject id as value, not the email — Google emails change and subject ids don’t. Pass verified only when the provider actually asserted it.
Running both for a week
| Phase | Reads from | Writes to | Cut-over signal |
|---|---|---|---|
| 1. Shadow | Infrai, fall back to Firebase | Infrai on success | nothing yet |
| 2. Majority | same | same | daily fallback rate under a few percent |
| 3. Tail | same | same | email the remainder a reset link |
| 4. Done | Infrai only | Infrai | fallback code deleted |
Watch the fallback rate as your progress bar. When it flattens, the people left are dormant accounts, and a password-reset email to that group is a reasonable end — POST /v1/auth/password/reset_request per address, throttled.
Count what’s arrived with a paged read:
curl -sS "https://api.infrai.cc/v1/auth/user/list?limit=100" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Follow next_cursor to the end; the metadata.legacy_provider stamp lets you separate migrated users from natives.
What you’ll miss, honestly
Firebase’s client SDKs are a genuine loss. Anonymous sessions that upgrade in place, the offline-first auth state listener, the tight coupling with Firestore rules — none of that has an equivalent here, and if your mobile app leans on it you’d be better off staying put than rebuilding it. Custom claims wired into database rules are the specific thing people underestimate.
Supabase Auth is the closer migration target if you want to keep that SDK-plus-database shape; SuperTokens is the one to look at if self-hosting is the goal.
What you gain is a plain REST surface and the rest of the stack on the same credential. The reset email at phase three goes out through POST /v1/email/send on the key that just created the user; the per-address throttle rides on POST /v1/queue/publish; the failures land in POST /v1/errors/capture. During a migration, having the notification and queueing already on the account is worth more than it sounds — those are exactly the pieces you’d otherwise be procuring under time pressure.
Auth routes report billing_class: free in discovery, so the migration itself isn’t billed per call; identity is metered per monthly active user, the same shape Firebase’s own pricing uses above its free tier. Read your figure from GET /v1/account/usage (verified 2026-09-21), and note the reset emails at the end are the part that costs anything — GET /v1/discovery carries that live rate, which tends to fall over time.