Skip to content
Tech Stack11 October 2026 · 9 min read

Link Anonymous Firebase Users Without Losing Data

A guest's records survive signup only if the credential is attached to the session they already have. The one call that does it, the existing-account fork, and how to verify ownership.

Link Anonymous Firebase Users Without Losing Data

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.

FlowCallIdentity afterwardsGuest records
Start a guestsignInAnonymously()New anonymous userCreated under this uid
Upgrade the guestcurrentUser.linkWithCredential()Same user, extra credentialStill reachable
Sign in normallysignInWithCredential()Whichever user owns that credentialLeft 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.

A brass key held against a lock plate whose keyhole is already filled by another key, with three inlaid channels leading away from it: a wide cyan-lined channel to an intact stack of blank record cards, a narrow channel to an empty tray, and a braided channel to a tray holding only part of a stack

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:

  1. Which collections move. Usually drafts and in-progress work. Rarely anything with a server-assigned ID or a payment trail.
  2. 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.
  3. Which side wins a conflict. Newest timestamp is the usual answer, and it is still a decision, not a default.
  4. 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.

The same surface split down the middle: on the left an identity tag with an empty keyhole, corded to a stack of five blank record tags, and on the right the same tag with a brass key seated and the same five tags, with a magnifier across the seam enlarging the knot that ties tag to records

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

  1. Print the uid immediately before and after the link call. Same value, with the email now listed among the provider data.
  2. Re-read the list after linking. Same record count, no empty state in between.
  3. Submit an email that already has an account. Confirm you receive credential-already-in-use and that the app has not signed in to anything.
  4. Cancel at that prompt. The guest records are still listed and still readable.
  5. Sign out and sign back in with the new email and password. The same records appear.
  6. Run the rules tests with a foreign uid and 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?

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

Does linkWithCredential keep the same Firebase user ID?

Firebase describes one user record reachable through every linked provider, so the account that was the guest is the account you still have afterwards. The Flutter guide states that users "are identifiable by the same Firebase user ID regardless of the authentication provider they used to sign in", and the anonymous auth guide adds that on success "the user's new account can access the anonymous account's Firebase data." Neither page describes a migration step, because there is nothing to migrate. If you want proof rather than inference for your SDK version, print the uid immediately before and after the call; that one line settles it in your own build.

How do I merge guest data into an existing account in Flutter?

You write that merge yourself, because Firebase does not do it for you. The documentation is direct about where the responsibility sits: "you must handle merging the accounts and associated data as appropriate for your app". Decide four things before any code: which collections move, what makes two records the same, which side wins a conflict, and what happens if the copy stops halfway. Run the copy authenticated as the destination account so every document is written with the destination owner ID, and keep the guest records in place until the copy is confirmed. The documented sample deletes the previous account last, not first.

What does the credential-already-in-use error mean?

It means the credential you tried to attach already belongs to another Firebase account, so the link cannot complete. Firebase describes the condition as the account for that credential already existing or already being linked to a user. Treat it as a product decision rather than a logging line: you can cancel and keep the guest session intact, sign in to the other account and accept that the guest records stay behind, or sign in and copy selected records under a policy you defined. The failure itself destroys nothing. What destroys data is calling a sign-in method in the catch block and treating the empty screen as expected.

Can FlutterFlow upgrade an anonymous user without custom code?

Plan the upgrade as custom code. FlutterFlow documents the anonymous entry point as a built-in Log In action under Backend/Database with Auth Provider set to Anonymous, including a Create User Document toggle that writes an empty record into users. That page documents no built-in action for linking credentials to the current user. So the guest session is one click to create and a custom action to upgrade, and the merge branch is custom code plus a server consideration. Budget the sprint around that split rather than discovering it after the guest flow ships.

Do anonymous Firebase accounts expire?

Only where the project has been upgraded to Firebase Authentication with Identity Platform and automatic clean-up is switched on. In that configuration Firebase may delete anonymous accounts older than 30 days, at any point after the thirty-day mark rather than exactly on it, and existing accounts become eligible 30 days after you enable the setting. Switching it back off does not cancel deletions that are already scheduled. Accounts that have been linked to a sign-in method are exempt, which is a useful way to read the feature: linking is what turns a guest into a permanent user.