Your guest saves four items, taps Create account, and the list comes back empty. The records are still in Firestore. They belong to a user ID the app is no longer signed in as.
One call fixes it, and one decision has to come before you write that call. Adding a credential to the session the guest is already using keeps that session. Signing in starts a different one. Picking the second by accident is what empties the screen.
This article covers only the guest-to-permanent transition. For the stack choices around it, see the walkthrough of a Flutter and Firebase SaaS stack. For the isolation question sitting underneath the rules below, see Firestore security rules compared with Postgres RLS.
Every code block here is a reference implementation. None of it was executed while writing this article, so the last two sections are checks you run on your own build rather than results I am reporting.
Why does signing up empty the screen?
Because the app created a second user instead of attaching a credential to the first one, and the documents are still filtered by the first user's ID.
Anonymous auth is not a placeholder state. signInAnonymously() returns a real Firebase user with a real uid, and Firestore writes made during that session carry it as the owner. Firebase calls these "temporary anonymous accounts", which describes their lifetime, not their reality.
So when a sign-up screen calls createUserWithEmailAndPassword or signInWithCredential, nothing breaks and nothing is deleted. The app simply ends up holding a different uid, and every query scoped to the current user now matches nothing. The guest's rows sit exactly where they were, unreachable by a session that has no claim to them.
Three flows that look like one button
Guest mode hides three separate operations behind the same "Create account" control. Treating them as one is the root of the bug.
| Flow | Call | Identity afterwards | Guest records |
|---|---|---|---|
| Start a guest | signInAnonymously() | New anonymous user | Created under this uid |
| Upgrade the guest | currentUser.linkWithCredential() | Same user, extra credential | Still reachable |
| Sign in normally | signInWithCredential() | Whichever user owns that credential | Left behind under the old uid |
The middle row is the one most apps skip. Firebase's own framing is that "users are identifiable by the same Firebase user ID regardless of the authentication provider they used to sign in" — one user record, several ways in. Linking adds a way in. Signing in picks a record.
Which call actually upgrades a guest?
Pass a credential to linkWithCredential() on the current user. Build the credential with the provider's helper, and stop short of the sign-in method, which would replace the session instead of extending it.
The documented sequence is to complete the provider flow up to, but not including, the signInWith methods, then hand the credential to the current user (Firebase, anonymous auth for Flutter):
// Reference implementation. Not executed for this article.
Future<void> upgradeGuest({
required String email,
required String password,
}) async {
final user = FirebaseAuth.instance.currentUser;
if (user == null || !user.isAnonymous) return;
final credential = EmailAuthProvider.credential(
email: email,
password: password,
);
try {
final result = await user.linkWithCredential(credential);
debugPrint('still the same user: ${result.user?.uid}');
} on FirebaseAuthException catch (error) {
switch (error.code) {
case 'credential-already-in-use':
// Another account owns this email. Do not sign in yet.
await promptForMergeDecision(email);
break;
case 'operation-not-allowed':
// The provider is not enabled in the Firebase console.
break;
default:
rethrow;
}
}
}
On success, the documentation is specific about the outcome: "the user's new account can access the anonymous account's Firebase data." Nothing moves. No migration job runs. The same record now answers to an email and a password.
Two details decide whether this works in practice. The isAnonymous guard keeps the call off a session that is already permanent, where it would fail for a different reason. And the credential has to be built, never signed in with — a single stray signInWithEmailAndPassword above the link call undoes the whole design.
What happens when the email already belongs to someone?
The link fails with credential-already-in-use, which Firebase describes as the account for that credential already existing or already being linked to a user. That failure is a fork in your product, not an error string to log.

Three branches exist, and only one of them is automatic:
- Keep the guest. Cancel, stay signed in as the anonymous user, and tell the person that this email already has an account. Their records stay exactly where they are. This is the safe default and the one most apps never offer.
- Sign in and leave the guest data. Legitimate when the guest session holds nothing worth keeping. It has to be a stated choice, because the records become unreachable from the app the moment the session changes.
- Sign in and copy, under a policy. The only branch that needs real work, and the one that goes wrong quietly.
Firebase is explicit that it will not do the third one for you: "you must handle merging the accounts and associated data as appropriate for your app". The documented sample signs in to the target account, writes the merged data there, then deletes the previous account. Note the order. Deleting first is how people lose the thing they were trying to keep.
Decide the merge policy before writing the copy loop
A copy-everything loop is not a merge policy. It duplicates records the account already has, overwrites newer values with older ones, and rewrites ownership on documents whose rules never expected a second owner.
Write down four answers first, because each one changes the code:
- Which collections move. Usually drafts and in-progress work. Rarely anything with a server-assigned ID or a payment trail.
- What makes two records the same. A natural key beats a generated one here. Without a deduplication key, a guest who retries the merge twice gets two of everything.
- Which side wins a conflict. Newest timestamp is the usual answer, and it is still a decision, not a default.
- What happens on partial failure. Half a merge is worse than none, so the copy needs to be resumable and the guest records should survive until the copy is confirmed.
Run the copy with the destination account's credentials, writing documents that carry the destination uid as owner. Writing them as the guest and hoping rules allow it is where this usually fails — correctly, because the rules are doing their job.
Subscription identity is a boundary to review here rather than solve. If entitlements are tied to a user ID, a merge moves data under a different identity than the one the purchase was attached to, and that reconciliation belongs in its own implementation.
How do you prove which identity owns the records?
Read the owning user ID directly, before and after the link, and confirm the record count under the identity you expect. A success toast proves the call returned, not that ownership landed where you intended.

Ownership lives in your rules, so that is the layer worth testing offline. The rules unit testing library runs against the emulator and "never touches your production resources", and it gives you a foreign-user case that is awkward to produce by hand:
// Reference implementation. Not executed for this article.
const env = await initializeTestEnvironment({
projectId: "demo-link-anonymous",
firestore: { rules: fs.readFileSync("firestore.rules", "utf8") },
});
const guest = env.authenticatedContext("guest-uid").firestore();
const stranger = env.authenticatedContext("other-uid").firestore();
const item = "items/demo-checklist-1";
await assertSucceeds(guest.doc(item).set({ ownerUid: "guest-uid", label: "demo" }));
await assertFails(stranger.doc(item).get());
Emulators have to be running for that to execute. The assertion worth keeping is the second one: a record that the wrong uid can read is a bug that no amount of correct linking will cover.
Six checks on a real device
- Print the
uidimmediately before and after the link call. Same value, with the email now listed among the provider data. - Re-read the list after linking. Same record count, no empty state in between.
- Submit an email that already has an account. Confirm you receive
credential-already-in-useand that the app has not signed in to anything. - Cancel at that prompt. The guest records are still listed and still readable.
- Sign out and sign back in with the new email and password. The same records appear.
- Run the rules tests with a foreign
uidand confirm the read is denied.
Do checks three and four in a release build. A Preview or editor surface shows a rendered state; it does not prove native credential behavior.
Where this stops working
Linking protects the account, and it cannot recover a guest who never linked. Anonymous credentials live on one installation, so the person who reinstalls before signing up arrives as a new user with no path back to those records. Treat unlinked guest data as data you are willing to lose.
There is a documented deletion path too. On projects upgraded to Firebase Authentication with Identity Platform, enabling automatic clean-up allows Firebase to delete anonymous accounts older than 30 days, at any time after that point rather than exactly on day 30. Existing accounts become eligible 30 days after you enable it, and turning the setting back off does not cancel deletions already scheduled. Linked accounts are exempt, which is a useful way to read the whole feature: linking is what makes a guest permanent, in billing terms as well as identity terms.
On the FlutterFlow side, the boundary is worth knowing before you plan a sprint. The anonymous entry point is a built-in action — the Log In action under Backend/Database, with Auth Provider set to Anonymous and a Create User Document toggle that writes an empty record into users. That page documents no built-in action for linking credentials, so the upgrade step is custom code, and the merge branch is custom code plus a server consideration.
Open your sign-up handler and find the line that authenticates. If it calls a sign-in method while a guest session is active, that single line is the bug. Which of the three branches above does your app currently take when the email already exists?
