Two people share a tablet. The first signs out, the second signs in, and for a moment the new session shows the previous person's favorites, their display name in the header, or a premium screen they never paid for. A second later the UI corrects itself. Nothing in the logs looks wrong.
Sign-out in a Flutter app is two separate jobs, and signOut only does the first one. The documentation for FirebaseAuth.signOut states one effect: it signs out the current user, and "if successful, it also updates any authStateChanges, idTokenChanges or userChanges stream listeners." Every provider, listener, cached image, saved preference and local draft in the app is the second job, and it is yours. To clear cached user data on logout in the right order:
- Raise a session marker so in-flight work can tell it is stale.
- Cancel session-scoped stream subscriptions and wait for their cleanup.
- Invalidate the providers the UI reads.
- Clear the caches your app owns, which is not the same set as the caches the SDKs own.
- Call
signOutlast, and route from the auth state event rather than from the button handler.
The order matters because steps 2 and 3 in the other sequence undo each other: clear the state first and a listener that is still open writes the previous user's document straight back into it.
Which data belongs to the session, and which belongs to the app
Before writing a teardown, sort every data source into one of two lifetimes. Session-scoped data must not survive sign-out. App-lifetime data, like a theme choice or an onboarding-seen flag, should survive it, and wiping it makes the next sign-in look broken in a different way.
| Source | Lifetime | What actually clears it |
|---|---|---|
| Firebase Auth session | Session | signOut, which updates the three auth streams |
| Stream subscriptions on user documents | Session | cancel() on each subscription |
| Provider or notifier state holding user data | Session | Invalidating or disposing the provider |
| Firestore local cache | Spans sessions | Nothing on sign-out. Needs its own decision |
| Flutter image cache | Spans sessions | imageCache.clear() plus clearLiveImages() |
| Saved preferences and persisted app state | Spans sessions | Removing the keys you wrote |
| Unsent local drafts | Depends on your retention choice | A product decision, not a cache eviction |
Two rows in that table surprise people. On native platforms the auth state itself is persisted on device between app restarts, as the Flutter auth setup guide describes, so a signed-in session is already designed to outlive the process. And the Firestore cache keeps its own copy: the offline persistence guide states plainly that "Cloud Firestore's cache isn't automatically cleared between sessions."
The order that keeps cleanup from undoing itself

Write the teardown as one function that runs to completion, not as five actions spread across a button, a route guard and an initState. Each step has a reason to sit where it does.
Raise the session marker first. It is a plain integer that any async continuation can compare against. Nothing else in the sequence can stop work that has already left your code, so this is the only step that protects the ones after it.
Cancel subscriptions, and await the cancellation. The Dart documentation for StreamSubscription.cancel is specific: "After this call, the subscription no longer receives events", and the returned future "is completed once the stream has finished its cleanup." Awaiting it means the next step is not racing a listener that is still tearing down.
Invalidate provider state after the listeners are closed. If you use Riverpod, ref.invalidate "will destroy the current provider state", and whether a fresh state is created depends on whether anything is still listening, per the automatic disposal documentation. Automatic disposal is enabled by default with code generation and is opt-in otherwise, so do not assume a provider dies when the last screen closes. Check which mode your project is in.
Clear only the caches you own. SharedPreferences.clear() clears the preference store, so removing individual session keys is safer than clearing everything when the same store holds a theme or locale choice. For imagery, ImageCache.clear() "Evicts all pending and keepAlive entries from the cache", and live references need the separate clearLiveImages() call, because an image counts as live while its listener count has never dropped back to zero. The ImageCache reference also gives the defaults worth knowing before you touch them: up to 1000 images and 100 MB.
Sign out last, and route from the stream. The auth setup guide lists when authStateChanges fires, and one of those events is when the current user is signed out. Routing from that event, rather than from the tap handler, means a session that ends for any other reason, such as a disabled account or a token failure, takes the same path.
A late response is what refills a cleared cache
Here is the failure that survives a careful teardown. A profile screen starts a read, the user logs out half a second later, the teardown runs, and then the read completes and writes the previous user's name into state that was just emptied.
Cancelling the future does not solve this the way it reads. CancelableOperation from package:async wraps a future so you can cancel it, and after cancellation its value will not complete with either a result or an error. The class reference adds two conditions that matter here: if value has already completed, cancelling does nothing, and the page does not claim the underlying computation stops. The network call may well finish; you have only stopped listening to it. Awaiting a cancelled value directly hangs, which is why valueOrCancellation exists.
So make staleness checkable instead of relying on cancellation alone. Give each session an identity, carry it into the async work, and compare on the way back.
// Reference implementation, not a tested library. Run the checks at the
// end of this article against your own app before relying on it.
final sessionIdProvider = StateProvider<int>((ref) => 0);
class SessionGuard {
SessionGuard(this._ref);
final Ref _ref;
int get current => _ref.read(sessionIdProvider);
/// Awaits [work] and returns null if the session changed while it ran.
Future<T?> guard<T>(Future<T> work) async {
final startedIn = current;
final result = await work;
if (startedIn != current) return null;
return result;
}
}
A caller that writes to UI state goes through guard and treats null as "discard this result". The check costs one comparison and it covers every path, including the ones you forgot to make cancellable.
A teardown you can drop into an existing app
Collect session subscriptions in one place so cancellation is not a search through the widget tree.
class SessionSubscriptions {
final _subs = <StreamSubscription<void>>[];
void add(StreamSubscription<void> sub) => _subs.add(sub);
Future<void> cancelAll() async {
final pending = _subs.toList();
_subs.clear();
await Future.wait(pending.map((sub) => sub.cancel()));
}
}
Then the teardown is readable top to bottom, and the order in it is the order from the section above.
Future<void> signOutAndClearSession(Ref ref) async {
// 1. Any async result that started before this line is now stale.
ref.read(sessionIdProvider.notifier).state++;
// 2. Close listeners and wait for the streams to finish cleanup.
await ref.read(sessionSubscriptionsProvider).cancelAll();
// 3. Drop the state the UI is reading.
for (final provider in sessionScopedProviders) {
ref.invalidate(provider);
}
// 4. Clear the caches this app wrote, not the whole preference store.
final prefs = await SharedPreferences.getInstance();
for (final key in sessionPrefKeys) {
await prefs.remove(key);
}
PaintingBinding.instance.imageCache
..clear()
..clearLiveImages();
// 5. Sign out last. A listener on authStateChanges does the routing.
await FirebaseAuth.instance.signOut();
}
sessionScopedProviders and sessionPrefKeys are lists you maintain by hand, and that is the point: a new provider holding user data is not covered until someone adds it, so keep both lists next to the teardown where a reviewer will see them.
Firestore's local cache is not part of sign-out
The SDK offers no clear-the-cache-now call for a logout handler, and the reason is in the preconditions of the one method that does clear it. On Android and Apple platforms Firestore's offline persistence is enabled by default, and on the web it is disabled by default, according to the offline data guide. The cache holds a copy of the data the app is actively using, and it is not cleared between sessions.
There is an API for clearing it, and its preconditions decide where you can call it. clearPersistence "Must be called while the FirebaseFirestore instance is not started", and on startup it must run before any other method except settings. If the instance is still running, the future fails. Its own documentation describes it as "primarily intended to help write reliable tests that use Cloud Firestore", and notes that it "does not attempt to securely overwrite or otherwise make cached data unrecoverable".
You can reach the not-started condition at runtime with terminate, after which clearPersistence is the only method that still works and "Any other method will throw a FirebaseException". Termination also does not cancel pending writes. For an app that holds sensitive per-user documents on a shared device, the honest options are: clear persistence during the next cold start while no Firestore call has happened yet, or do not enable persistence for that data at all, which is what the offline guide suggests for web apps handling sensitive information. Treat a clear-all on the logout button as the thing the SDK does not offer, rather than a line you are missing.
Drafts need a decision, not a delete
An unsent review, a half-filled form, a photo queued for upload: these are the user's work. Deleting them at sign-out loses data the person expected to find again. Leaving them on screen for the next account exposes one user's writing to another. Both are bugs, and a cache-eviction call picks one of them for you by accident.
Pick one of three, write it down, and make the UI say which one you chose:
- Keep the draft keyed by the owner's uid, hidden from any other session, and restored when that person signs back in on the same device.
- Discard it at sign-out after one explicit confirmation that names what will be lost.
- Flush it to the server before the teardown runs, and treat a failed flush as a reason to stop and ask.
Option 1 is the only one that keeps both properties, and it is also the one that puts previous-user content on the device deliberately. Your rules for it should be the same as for any other owned document, which is the next section.
What the FlutterFlow Logout action covers
In FlutterFlow, logout is a built-in action under Backend/Database > Firebase Authentication. The documentation for it says it "enables users to securely log out of their account and clear their session data from the app". Read that scope narrowly: the page does not mention App State, local state or where the user goes afterwards.
App State is the part worth checking in your own project. A persisted App State field is "saved to disk so that it can be loaded when the app is restarted", and the App State documentation lists authentication tokens among the things people keep there, with no runtime clearing step described. Non-persisted fields reset on restart, which is not the same as resetting at sign-out, because sign-out does not restart the process.
So the FlutterFlow build of this teardown is: the Logout action, plus an explicit Update App State action for every session-scoped field, plus a custom action for anything the editor cannot express, which is where cancelling your own stream subscriptions and clearing the image cache belongs. Check the generated code for your project rather than the version in any article, this one included, because the actions available in the editor change.
The server has to reject the read on its own
Client teardown stops the flash. It does not stop a request. A build that has the previous user's document id in memory can still ask for that document, and the only thing that can refuse is a rule.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, update, delete: if request.auth != null
&& request.auth.uid == userId;
allow create: if request.auth != null;
}
match /favorites/{favoriteId} {
allow read, update, delete: if request.auth != null
&& request.auth.uid == resource.data.ownerId;
allow create: if request.auth != null
&& request.auth.uid == request.resource.data.ownerId;
}
}
}
One behavior of these rules decides how your queries have to be written. The rules conditions guide states that "security rules are not filters": a query is evaluated against its potential results and rejected if any of them could be unreadable for that client. A collection query over favorites therefore needs the owner constraint in the query itself, not only in the rule, or the whole query fails. The same guide notes that you are billed for document reads even when rules reject the request, so a loop retrying a rejected read costs money while returning nothing.
If the account switch also changes paid access, the identity you send to the billing SDK is a separate problem with its own failure mode. I wrote up that one in how to keep RevenueCat entitlements correct when users switch accounts, and the ownership model underneath all of this is compared across backends in Firestore rules versus Postgres row-level security for multi-tenant apps. The wider stack these pieces sit in is in this walkthrough of a Flutter and Firebase SaaS mobile build.
Running the switch test

Use two synthetic accounts with obviously different content, and watch the first frame after each transition rather than the settled screen. A flash is the symptom; a settled screen hides it.
- Sign in as the first account, open the screens that cache content, and sign out. Sign in as the second account and look at the first frame of each of those screens. Any previous-user value means a provider or a listener outlived the teardown.
- Start a slow read, throttle the network if you need to, and sign out before it resolves. The result must be discarded rather than written into state.
- Sign out, force-stop the app, and relaunch. Content that reappears here came off disk, so it is in the Firestore cache, a preference key, or persisted app state.
- While signed in as the second account, request a document owned by the first. The server must reject it. Test the rule with the emulator and the rules unit-testing library instead of reading it and agreeing with yourself.
- Repeat the switch with the app backgrounded between sign-out and sign-in, since a resumed screen rebuilds from whatever state survived.
Check 4 is the one most often skipped, and it is the only one that still holds when the person requesting the data is not a cooperative user of the same device.
Where this breaks down
The teardown above is a reference implementation, not a tested library, and nothing in this article was executed against a device or a Firebase project. Treat the code as a shape to adapt and the checks above as the part that produces evidence in your project.
It also assumes the two users are cooperative people sharing a device. If one of them is motivated to read the other's data, client cleanup is not the control that matters, and neither is clearPersistence, which its own documentation says does not make cached data unrecoverable. For that threat the answer is to keep the sensitive documents out of the local cache in the first place, and to make the server rule the thing you test.
Start with the inventory table. Write your own version with the real provider names, preference keys and subscription sites from your project, then put that list in the same file as the teardown so a reviewer sees both at once. The teardown covers exactly what the list names, so the first useful change is making the list complete, and the first test to run against it is check 1.
