The auth record is the last thing you delete, not the first. Everything hard about account deletion happens before that call, in stores that cannot roll back together.
Deleting a user account looks like a one-line operation. In Firebase it very nearly is:
admin.auth().deleteUser(uid) and the account is gone. That call is also the least
important part of the job, and treating it as the whole job leaves a system that has told the user
their data is deleted while most of it is still sitting there.
I have built this flow more than once now, and the useful framing is that account deletion is not a feature. It is a distributed transaction across systems that do not share a transaction manager, performed on behalf of someone who has already decided they do not trust you, and with a legal clock attached. Every one of those clauses changes the design.
A signed-in user in a typical Firebase application exists in at least three places, and probably four.
The instinct is to delete the auth record first, because that is the one that feels like the account. It is exactly the wrong order.
The moment the auth record is gone, the ID token is worthless, the security rules that let the
user reach their own documents evaluate against a null request.auth, and any
client-driven cleanup that had not finished is now unauthorised. Worse, the uid is the only key you
had. If your deletion routine crashes halfway, you have orphaned data and no longer any identity to
associate it with. It is not deleted and it is not findable, which is the worst of both.
Delete the auth record last, as the commit step of a sequence that was already complete.
Since there is no transaction spanning Auth, Firestore and Storage, the design has to assume the process dies at the least convenient moment. The property to aim for is not atomicity, which is unavailable, but idempotent resumability: running the sequence twice must be harmless, and running it again after a crash must finish the job.
The order that gives that:
Step three is where the real work is, and where the surprises live.
"Delete everything belonging to this user" is a satisfying sentence and an incoherent instruction, because a lot of the data does not belong solely to them.
In a marketplace or a multi-tenant product, a user's records are entangled with other people's records by design. A message has a sender and a recipient. A booking has a client and a provider. An invoice has a payer and a payee, and a legal retention period attached to it. Deleting the sender's copy of a conversation while leaving the recipient's is not deletion; deleting both is deleting somebody else's data on the say-so of a person who does not own it.
The workable distinction is between personal data and transaction records.
This has to be decided per collection, in advance, and written down. The thing that makes it hard is not the code. It is that the answer differs by collection and nobody can derive it from the schema — you have to know what each record is for.
Firestore deletions are visible: a document disappears from a console you look at. Storage objects are not, and they are the single most commonly orphaned artefact in an account deletion flow.
Two properties of Cloud Storage make this worse than it looks. There is no recursive delete on a prefix — "folders" do not exist, so you list objects under a prefix and delete them one at a time. And the listing is paginated, so a naive implementation that deletes the first page and declares victory will quietly leave everything after it.
The design decision that prevents most of this pain is upstream of the deletion code: put every user-owned object under a single uid-scoped prefix, from the first day. If uploads are scattered across several top-level buckets and paths, deletion needs a manifest of everywhere the user might have written, and that manifest will fall out of date the first time somebody adds a feature.
Storage is also where the cost argument and the privacy argument point the same way. Orphaned objects are billed monthly, for ever, for data nobody can reach and nobody is allowed to hold.
The engineering is half of it. The other half is that deletion is one of the few product surfaces where regulators, app stores and users all have explicit expectations, and they broadly agree.
A grace period — the account is disabled immediately, the data is destroyed after some days — is a reasonable design, and it genuinely does prevent regret. It is only reasonable if it is disclosed on the screen where the user confirms, and if there is a scheduled job that actually executes it. An undisclosed grace period is just a delay you did not mention. A disclosed one with no job behind it is a promise to delete that nothing keeps.
The obvious implementation is a privileged backend function holding a service account. It works, and on a portfolio built to stay inside free tiers it is also the most expensive way to solve the problem, because it introduces a deployed, billed, permanently-privileged component for an operation that runs rarely.
The alternative is to let the client do it with the user's own credentials, and to let the
security rules be the enforcement. The user is authenticated, the data is theirs, and rules already
scope every path to request.auth.uid. A client that can read its own documents can
delete them, and a rule that permits it cannot be tricked into permitting somebody else's, because
the uid in the token is not something the client controls.
The honest limits of that approach are worth stating plainly. Anonymising a shared record means writing to a document the departing user does not own, which rules will correctly refuse unless you carve out a narrow, field-limited permission — and widening a rule to make a feature work is how deny-by-default quietly stops being deny-by-default. Deleting the auth record from the client requires a fresh credential. And a client-driven process can be abandoned mid-flight when someone closes the tab, which is precisely why step two writes a marker.
The design I have settled on is a hybrid with the boundary drawn at ownership: the client deletes everything it owns, under rules that already constrain it; the small number of shared-record anonymisations are expressed as a narrowly-scoped rule that permits exactly one field transition and nothing else; and the deletion marker makes the whole thing resumable. No always-on privileged service, no service account key in circulation, and no broad rule added to make it convenient.
The test that matters is not that the function returned. It is that nothing is left.
Create an account through the auth REST API rather than the sign-up screen, so no verification mail is sent to an address that does not exist. Have it write into every collection and upload into every storage prefix the product supports. Run the deletion. Then go looking, with admin credentials, for anything still keyed to that uid: each collection, each storage prefix, the auth record itself. Assert absence, one by one. Then run the deletion a second time on the same uid and confirm it completes cleanly rather than throwing, because a resumable process that cannot tolerate being resumed is not resumable.
The failure this catches is always the same one, and it is always a collection added six months after the deletion routine was written by someone who did not know the routine existed. Which is the real lesson: the deletion path is not a feature you finish. It is a piece of infrastructure that every new collection has to be registered with, and the only reliable way to keep it honest is a test that enumerates the collections and fails when it finds one nobody handled.
Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.