I was shipping a mobile consumer Expo app on Firebase Auth. Email was the login. Some accounts were orphans: an email record with no password provider, leftover from an older import or a half-finished sign-up. Reclaim tried to fix them by writing a new password onto every match. People who already had a password got locked out of their own phone.
They type the password that worked yesterday. Firebase says invalid. Support says we migrated you. They did not ask to be migrated. They asked to open the app.
The reclaim screen lived in an admin tool, not in the consumer app. Ops pasted an email, typed a temporary password, and hit reclaim so the person could finish sign-up on the phone. That flow felt symmetric: email in, password hash out. Firebase Admin made it look that simple. It wasn't.
getUserByEmail does not mean blank account
I looked up the email with the Firebase Admin SDK. User exists. I called updateUser({ password }) so they could finish sign-up. That writes a password hash. It does not ask whether they already had one. It does not care that providerData already listed password. It just overwrites.
An orphan is a user whose providerData has no password provider. That is the only time setting a password is reclaim. Everyone else already has a credential. getUserByEmail answering with a user record only means the email is taken. The account might already be finished. The password field on the reclaim form is not automatically safe to hash. I had collapsed those facts into one updateUser call.
The first time I noticed, it was a real user who had signed up months earlier on another device. Their email was in Auth with a password provider. Reclaim treated them like an import stub and replaced the hash. From Firebase's side the old password simply stopped working. From their side we broke a working account to fix a problem they did not have.
Branch on providerData, then reset or reclaim
const user = await admin.auth().getUserByEmail(email);
const hasPassword = user.providerData.some((p) => p.providerId === 'password');
if (hasPassword) {
await admin.auth().generatePasswordResetLink(email);
return { status: 'reset_sent' };
}
await admin.auth().updateUser(user.uid, { password: newPassword });
return { status: 'reclaimed' };Client sign-in stays signInWithEmailAndPassword. The orphan path is admin-only. If getUserByEmail throws auth/user-not-found, I create the user. There is no user to patch. Create belongs to a missing email, update to the orphan, reset to an existing password.
The form can still collect a password. The admin path decides whether that string is allowed to become a hash. Existing password users get generatePasswordResetLink and { status: 'reset_sent' }. Orphans get updateUser and { status: 'reclaimed' }. Missing users get created.
If the status is reset_sent, I do not sign the person in with the form password. That string is not their credential. They already have one. The reset link is the path back in. If the status is reclaimed, the orphan now has a password provider and the client can use signInWithEmailAndPassword as usual.
I return distinct statuses so the admin UI can show the right copy. "Reclaimed" means the orphan can use the password ops just set. "Reset sent" means do not paste that password into the app; the user must follow email. Mixing those up trains support to hand out temp passwords that Firebase never applied.
Firebase will overwrite if you ask
Firebase will let you overwrite a password. The SDK does not inspect providerData for me. The reclaim path has to. An existing email in Auth can already have a password. I check providerData before I write one.
After the branch landed, I added a small fixture test: orphan user record with only a phone provider, existing user with password in providerData, and missing email. The admin handler must never call updateUser({ password }) on the second case. That test is boring. It is also the thing that stops a well-meaning ops click from becoming a lockout ticket.
Happy coding! Sander