Delete the row in the users table and most of the person is still there: their uploads in the storage bucket, their comments, their Stripe customer, their address in the email tool. A complete account deletion feature removes or anonymizes all of it in one tested job, keeps only what a written retention exception allows, and records what it did.

What an account deletion feature is, and what “complete” means

An account deletion feature is the flow that removes a person from every place the app put them: 8 places in a typical SaaS, from the users table and its related rows to stored files, the auth provider, the payment provider, outside tools, logs and backups. Complete means each place has a decided outcome.

The reason it is a feature and not one delete statement: removing a profile alone can leave personal data scattered through the system. Account deletion is one of the controls in the authentication checklist, next to sign-in flows, sessions and the role model.

If you are answering an erasure request, the identifier-by-identifier map of where to search is the GDPR runbook’s section “Map every place the person’s data may live”. The table below is the build side of the same idea: what the deletion job does in each place, and what breaks when it skips one.

Where the person livesWhat is thereOutcome on deletionWhat breaks if you skip it
The users or profiles tableName, email, avatar path, settingsDeleteThe person still shows up in admin lists, and their email cannot sign up again
Rows in other tables keyed to the userPosts, comments, orders, membershipsDelete, anonymize or reassign, decided per tableThe delete fails on a foreign key, or a cascade takes other people’s work with it
Stored filesAvatars, uploads, generated exportsDelete through the storage APIThe files stay in the bucket with nothing pointing at them
The auth provider’s user recordThe login and its linked social identitiesDelete, after everything keyed to itThe account can still sign in or refresh a session
The payment providerCustomer, subscriptions, payment methodsCancel subscriptions; delete or keep the customer, as decidedSomeone with no account keeps getting billed
Outside tools that received the personTransactional email, marketing list, analytics, support inbox, error tracker, model provider logsDelete or suppress in each toolMarketing email keeps arriving after the account is gone
Application logsEmails and ids written into log linesAges out with the log retention periodA log search still finds the person long after the account is gone
BackupsFull copies of the databaseAges out, on the conditions the GDPR runbook sets out for backupsA restore quietly brings an erased person back

Deactivation is a different thing: the account cannot sign in, but every row and file stays where it was. A soft delete is different again: a flag on the row that hides it, with nothing removed.

Where the feature lives in the stack, as my working rule: one server-side job that holds a service credential, never a chain of calls from the browser. Supabase’s docs put their own admin call on the server too: deleteUser “should only be called on a server”.

What goes wrong without it

Without a planned job, deletion fails in five ways, and each one has a symptom someone can see.

SymptomCauseThe step in the flow that prevents it
A deleted user’s files are still in the bucketThe job removed rows but never listed the person’s storage objectsStep 4: list and delete the files through the storage API
The delete fails with a foreign key error, so support deletes rows one at a time and misses someA table references the user and nothing removes or reassigns its rows firstStep 5: decide each related table’s outcome
Removing one team member deletes the team’s shared projectsON DELETE CASCADE on a team or workspace relationshipStep 5: reassign shared records before the delete
A customer with no account is still billedThe subscription was never canceled at the payment providerStep 2: cancel billing while the account still exists
A phone signed in before the deletion still worksSessions and tokens were never revokedStep 3: revoke sessions and tokens

The last row is a session problem as much as a deletion one, and ending every session for one user is the core of what is active session management. The opposite leftover, rows keyed to an id nobody can sign in as any more, is what a periodic sweep from a database cleanup checklist catches after the fact; a correct deletion job leaves it nothing to find.

The foreign key row can be subtle. In my July 2026 audits, a point-of-sale app declared a foreign key as both NOT NULL and ON DELETE SET NULL, so removing a team member could fail outright. PostgreSQL’s manual says that SET NULL and SET DEFAULT “do not excuse you from observing any constraints”, and a not-null constraint is one of the constraints that same chapter defines. The lesson I take from it: a delete rule nobody has exercised is a guess, and the seeded test in “How to verify it” is where it fails first.

Deleted user still has files in storage: why the cascade stops at the database

A deleted user still has files in storage because a database cascade stops at the database. The bucket is a separate system with no foreign key to your users table, so the deletion job lists the person’s objects by path or owner and removes them through the storage API, then checks that none remain.

On a laptop or in Google Workspace, a deleted user’s leftover files are an operating-system or admin-console question, and a different one. In an app, as I read it, the row that held a file’s path was the app’s only index to that file, so once the row is gone nothing in the app knows the object exists.

Supabase’s guide to managing user data adds a rule of its own: “You cannot delete a user if they are the owner of any objects in Supabase Storage.” Its advice is to try deleting all the objects for that user, or reassign ownership to another user. The objects have to go through the API, too. The page on deleting objects in Supabase Storage warns that deleting them “via a SQL query will not remove the object from the bucket and will result in the object being orphaned”, so the job calls the Storage API’s remove method, which takes up to 1000 objects at a time.

What makes the files findable, as my working rule: a path convention that starts with the user id, such as user-id/avatar.png, or an owner column on a files table that the job reads before it deletes any rows. Generated files count too: exports, invoices rendered to PDF, thumbnails, model outputs. Who can list and read a bucket in the first place is a separate topic, covered by an object storage security checklist.

How to build it on Supabase, Firebase or plain Postgres

The build has four parts: the order the job runs in, what happens to related rows, what you keep, and the record of what was done. The notes for Supabase, Firebase and plain Postgres sit inside each part.

The order of operations, in nine steps

The account deletion flow runs in 9 steps, and the order matters: confirm identity, cancel billing, revoke sessions, delete files, delete or anonymize rows, delete the auth user, tell the outside tools, write the record, send the confirmation. Deleting the auth user first strands everything that was keyed to it.

  1. 01 Confirm it is the account owner: a fresh sign-in or a one-time code, not only an emailed link
  2. 02 Cancel subscriptions and decide the payment customer's outcome while the account still exists
  3. 03 Revoke sessions and tokens, including Sign in with Apple tokens if the app offers it
  4. 04 List and delete the person's files through the storage API
  5. 05 Delete, anonymize or reassign related rows inside one transaction
  6. 06 Delete the auth user, once nothing else depends on its id
  7. 07 Tell the outside tools to delete or suppress the person
  8. 08 Write the deletion record
  9. 09 Send a confirmation to the address, then forget the address

Identity comes first because nothing after it can be undone, and my rule is that an emailed link alone is too weak a check for that. Apple’s guidance allows identity steps “such as by entering a code from an email or phone number already associated with the account”, and adds that apps that make deletion “unnecessarily difficult” will not pass review.

Billing goes second because the payment record is found through the account. Stripe’s delete-a-customer reference reads: “Permanently deletes a customer. It cannot be undone. Also immediately cancels any active subscriptions on the customer.” It adds that deleted customers “can still be retrieved through the API in order to be able to track their history”. Subscriptions sold through Apple are billed by Apple, not your provider; for those, Apple’s page says to “notify them that their billing will continue through Apple and request that they cancel their subscription before continuing”.

Sessions come third. On Supabase, deleting the auth user with the default shouldSoftDelete: false “removes the row from auth.users, which cascades to auth.sessions and invalidates the user’s refresh tokens”, so step 3 is for any other session store the app keeps. The same docs note that deleting a user does not automatically sign them out, because a JWT already issued stays valid until it has expired, and their advice is to keep the access token expiry short. Sign in with Apple tokens are revoked through Apple’s REST API; the Apple section below has the endpoint.

Files go before the auth user, and on Supabase they must, because the auth user cannot be deleted while it owns Storage objects. Rows follow, in one transaction, so a failure halfway leaves the database as it was (the next section covers what each table gets).

The auth user goes last among the deletes, because every earlier step finds its data through that id. On Supabase that call is auth.admin.deleteUser, run server-side with the service_role key; its shouldSoftDelete option defaults to false, so the row is removed, not soft-deleted. On Firebase it is the Admin SDK’s deleteUser(uid). Firebase’s Delete User Data extension “Deletes data keyed on a userId from Cloud Firestore, Realtime Database, or Cloud Storage when a user deletes their account”, needs the Blaze plan, and “will only delete data that it is explicitly configured to delete”. Its page also carries a banner: “Firebase Extensions will shut down on March 31, 2027.” After that date, the banner says, you “will not be able to install or edit your existing extensions”. So a new build writes these deletes into its own job, and the extension matters only to apps that already run it.

The outside tools come after the database, using the email and vendor ids the job read at the start. Google Play’s page sets the expectation for service providers: “you should delete the data from your own servers and request the service provider to do the same”. The vendor-by-vendor list of those calls belongs to the data subject request handling checklist. The record and the confirmation close the run, and then the job forgets the address.

My working rule is that the job must be safe to run twice: a retry after a half-finished run finishes the rest and never errors on what is already gone. A grace window before billing is canceled is a product choice; the questions at the end cover it.

Related records get one of four outcomes on account deletion: cascade delete for data only that person owned, anonymize for content other people still read, reassign for anything a team depends on, and keep under a written exception for invoices and the like. The choice is made per table, before the first request arrives.

Record typeOutcomeHow it is done in Postgres
Their drafts, API keys, notification settingsCascade deleteON DELETE CASCADE on the user column
A comment in a shared threadAnonymize: shows as “deleted user”ON DELETE SET NULL on a nullable author column, plus an UPDATE that clears the text if the product’s rule says so
Projects in a shared workspaceReassign to the workspace ownerAn UPDATE that moves the owner before the delete, with ON DELETE RESTRICT as the backstop
InvoicesKeep under a written exceptionON DELETE SET NULL on the user column, so the invoice stays and loses its link to the login

The last column uses PostgreSQL’s foreign key actions as the manual defines them: with CASCADE, rows referencing a deleted row “should be automatically deleted as well”; SET NULL sets the referencing columns to null; RESTRICT “prevents deletion of a referenced row”; and with nothing written, the default is NO ACTION. My working rule is RESTRICT on anything shared, so a missed table fails loudly instead of deleting quietly. Deleting the last owner of a workspace stays blocked until ownership moves.

What each action does in depth, and how to pick one per kind of data, is under choosing deletion behavior by data class. Whether a person’s data must be deleted, anonymized, restricted or kept, and how to judge shared records that hold other people’s data, is a legal and product decision; the GDPR runbook’s sections on erasure categories and shared records cover it.

One catch for App Store apps: Apple’s page says people expect all data associated with their account to be deleted, and “This includes user-generated content that’s shared with others, such as photos, video, text posts, and reviews.” If an iOS app plans to anonymize shared posts instead, check that choice against Apple’s FAQ before review.

On Supabase, a table that references auth.users should reference its primary key, and Supabase’s own profiles example declares references auth.users on delete cascade: right for a profile row, wrong for anything shared. On Firestore, every collection keyed by the uid goes on the deletion job’s list by name, or, on an app that already runs the extension, into its configuration, since it deletes only what it is configured to.

Retention exceptions: what you keep, why, and for how long

A retention exception is a written reason to keep data after an account is deleted, such as invoices kept for tax law or a fraud marker. In the EU and UK, GDPR Article 17(3) lists grounds on which erasure does not apply, to the extent processing is necessary. Each exception names the data, reason, period and readers.

Data keptReasonPeriodWho can read it
Invoices and payment recordsTax and accounting lawPer your accountant; it depends on the countryWhoever handles finance
A hash of the email, as a fraud or abuse markerSo a banned user cannot simply sign up againSet in your retention scheduleThe few people who handle abuse
The deletion record itselfProof of what was removed and what was keptSet in your retention scheduleSupport and engineering leads
Data under a legal holdA dispute or claim in progressUntil the hold is liftedNamed people only

The law, once. The EU text of GDPR Article 17 says paragraphs 1 and 2 “shall not apply to the extent that processing is necessary” on five grounds, among them “for compliance with a legal obligation which requires processing by Union or Member State law to which the controller is subject” and “for the establishment, exercise or defence of legal claims”. The UK GDPR words the legal-obligation ground as processing “under domestic law”. That is what the regulation says; the four rows above are my working rule for a small SaaS, not a list the law sets, and none of this is legal advice.

Whether a ground applies to a given request is worked through in the GDPR delete request runbook, in its section on what erasure means for each category. The same runbook covers backups: how backup copies are handled depends on applicable law, and a restore must not silently reactivate erased data. Backup retention itself is part of a database backup checklist for startups, and the schedule these exceptions belong to is a data retention schedule for a small SaaS.

The deletion record, and deletion driven by a customer’s identity provider

My working rule for the deletion record: it holds no personal data beyond what an exception allows, only an internal id or a hash, the time, who started it (the user, support, or an automated job), each step’s result, and the exceptions applied. It is written to the audit log, the subject of audit logging best practices. When support starts a deletion, it runs the same job through an admin action, never ad hoc SQL against production, so an emailed request gets the same steps and the same record.

The privacy-request side of the same job (a request that arrives by email, the identity checks, the response deadline, and the vendor-by-vendor propagation table that step 7 uses) belongs in a data subject request handling checklist.

Business customers add one more path. When a customer manages its people through single sign-on, removing someone in the customer’s identity provider does not delete them in your app unless the app handles the deprovisioning event; that is my reading of how those set-ups work. The sign-in side of that set-up is the OpenID Connect vs SAML choice, and deprovisioning is a separate signal the app has to act on.

The Apple account deletion requirement and Google Play’s, if you ship a mobile app

The Apple account deletion requirement covers apps that support account creation: users must be able to start deleting their account inside the app. Google Play asks for the same in-app path plus a web link where users can request deletion without re-downloading the app. Both are build work on the server, not just a settings screen.

A web-only SaaS with no app in either store can skip this section. Every cell below comes from the stores’ own pages, read on October 3, 2026.

StoreWhat is requiredWhere the path must beWhat may be keptThe dates the page gives
Apple App StoreApps that support account creation must offer account deletion within the app (Guideline 5.1.1(v))Inside the app; Apple’s page says it is typically in the account settingsData the developer is legally required to maintain, with users toldStarting June 30, 2022
Google PlayAn in-app path to delete app accounts and associated data, and a web link where users can request deletionIn the app, and on a web page whose link goes in the Data safety form in Play ConsoleData kept for security, fraud prevention or regulatory compliance, disclosed to usersData deletion questions due December 7, 2023; an extension could be requested to May 31, 2024

Apple: what the review expects beyond the button

Apple’s rule itself, its start date, the line that deactivating is not enough and the link for deletions that finish on a website are quoted in the App Store submission checklist for AI-built apps. What follows is what Apple’s guidance on offering account deletion adds beyond that rule.

Support flows: apps not in highly regulated industries “should not require people to make a phone call, send an email, or go through other support flows”, while apps in highly regulated industries, as Guideline 5.1.1(ix) describes them, may use additional customer service flows. So a delete button that opens an email to support is, in my reading, an easy way to fall short.

Sign in with Apple: apps that support it “should use the Sign in with Apple REST API to revoke user tokens”. The Sign in with Apple revoke endpoint exists to “Invalidate the tokens and associated user authorizations for a user when they are no longer associated with your app”, and takes client_id, client_secret, token and token_type_hint.

Timing: a manual or slow process is acceptable. Apple’s words are “Inform the user how long it will take to delete the account and provide a confirmation when the deletion has been completed”, and the time taken must comply with local laws where the app is available. The page also asks you to follow the legal requirements for storing and retaining account information, and, if you have questions about your legal obligations, to check with your legal counsel.

The guideline behind all of it, 5.1.1(v), reads: “If your app supports account creation, you must also offer account deletion within the app.”

Google Play’s account deletion requirements say that an app which enables account creation must “provide users with an in-app path to delete their app accounts and associated data” and “provide a web link resource where users can request app account deletion and associated data deletion”. The web resource should let users ask for deletion “without sending the user back to the app and requiring them to re-download it to submit their request”.

The link goes into the Data deletion questions of the Data safety form, on the App content page in Play Console. Some data may be kept “for legitimate reasons such as security, fraud prevention or regulatory compliance”, as long as you clearly inform users about those retention practices, for example in your privacy policy. After May 31, 2024, the page says, non-compliant apps “may face additional enforcement actions in the future, such as the removal of your app from Google Play”.

When Play Console flags the deletion link on your Data safety form as invalid, my reading of the page’s three requirements for the web link gives three things to check. The link must be functional, “for example, loads without error”; it must be relevant in scope, with the deletion pathway prominent on the page; and it must “reference the app or developer name (that is, as it appears on your store listing in Google Play)”.

How to verify it

Account deletion is verified with a seeded account and 7 checks: rows by user id across every table, objects in the bucket, the auth user, live sessions, the payment customer, the outside tools, and the deletion record with its listed exceptions. Anything left over without a written exception is a failure.

A test that shows an account deletion removes related records runs on a staging copy or a clearly named test tenant, never on a real customer. Seed one account that touches everything: a profile, rows in each related table, two uploads, a generated file, a subscription bought through your own checkout in the payment provider’s test mode, a session on a second device, and an entry in each outside tool. Record its ids. Then delete it through the app’s own button, not with SQL.

Start in the browser before any SQL: your database dashboard’s table editor, filtered on the user id column table by table, and the storage browser opened at the user’s folder. That first look is quick and catches the obvious leftovers. Then run the seven checks.

  1. 01 Rows by user id across every table, with the query below. Pass: zero rows except in tables a written retention exception names, such as invoices and the deletion record. Evidence: the output, dated
  2. 02 Objects under the user's storage prefix. Pass: zero. Evidence: the storage browser view or an empty list result
  3. 03 The auth user is gone, and the same email can sign up fresh without inheriting anything, unless a fraud marker is meant to stop it. Evidence: the new account's empty state
  4. 04 The second device's session is refused when it next refreshes. Evidence: the refused request
  5. 05 The payment customer and subscription match the decision, deleted or kept. Evidence: the provider's record
  6. 06 Each outside tool shows the person deleted or suppressed. Evidence: the vendor's response id
  7. 07 The deletion record exists, and its exceptions match what is actually left. Evidence: the record

The query for check 1 finds every column named like the user key, plus every column with a foreign key to the users table whatever its name, because a created_by or author_id column slips past a name match. It reads columns from information_schema.columns, foreign keys from the pg_constraint catalog, and counts rows for one id with query_to_xml and the %I and %L specifiers of format. Run it as a role that can read every table, since, in the manual’s words, “Only those columns are shown that the current user has access to”, and the information schema’s foreign key views also hide tables your role does not own.

-- Rows left for one user id. Run as a role that can read every table.
with targets as (
  select table_schema, table_name, column_name
  from information_schema.columns
  where (column_name like '%user_id' or column_name = 'owner_id')
    and table_schema not in ('pg_catalog', 'information_schema')
  union  -- plus every column with a foreign key to the users table
  select n.nspname, c.relname, a.attname
  from pg_constraint k
  join pg_class c on c.oid = k.conrelid
  join pg_namespace n on n.oid = c.relnamespace
  join pg_attribute a on a.attrelid = k.conrelid and a.attnum = any (k.conkey)
  where k.contype = 'f' and k.confrelid = 'auth.users'::regclass  -- or 'public.users'
)
select table_schema, table_name, column_name,
  (xpath('/row/n/text()', query_to_xml(format('select count(*) as n from %I.%I where %I::text = %L',
    table_schema, table_name, column_name, 'paste-the-seeded-user-id'), false, true, '')))[1]::text::int as rows_left
from targets
order by rows_left desc;

On Supabase, check 4 should pass as soon as the auth user is deleted, because its refresh tokens are invalidated with it; the access-token window is the one the sessions step above describes.

Then run the deletion job a second time for the same id, from the job runner or the support tool, since the account’s own button went with the account. My working rule for a pass: it finishes with no error and changes nothing. Keep the test in the suite, so a table added next month fails check 1 until someone decides its outcome.

Deliverable 1.8 of the sprint is verified this way: delete a seeded account and inspect its related records, files, and recorded retention exceptions.

Where the sprint does this

Deliverable 1.8 is where we build the account deletion flow, covering related records and stored assets under the documented retention rules, checked with the seeded-account test in the previous section. Deliverable 12.2 is its privacy-side twin: we implement authenticated export and deletion workflows covering related systems and documented retention exceptions, and it is verified this way: export and delete a seeded user’s data; verify completeness, authorization, and recorded exceptions. New features that change the product’s core capabilities are separate work; the sprint includes only the supporting interfaces the listed controls need, such as session management, account deletion and billing self-service. The production readiness report, deliverable 13.1, delivers the result for every scope item, the work completed and its verification evidence, and is verified this way: account for all 123 IDs; keep failures visible until resolved and explain genuine non-applicable items. Legal advice and certification are separate services; the sprint implements and documents the technical data-handling controls. The exact wording sits under deliverable 1.8 in the published scope.

Common questions about deleting user accounts

What’s the difference between deactivating and deleting an account?

Deactivating blocks sign-in and keeps the data; deleting removes or anonymizes the data in every place the app put it. Google Play’s requirement is an in-app path to delete app accounts and associated data, and Apple’s page calls deactivation alone insufficient, as the App Store section above notes.

How long does an account deletion take?

It takes as long as the slowest place the person’s data sits: the job inside your own database is the quick part, while outside tools and backups take longer, because each vendor runs its own deletion and backups age out on their own schedule. Apple accepts a slow process if you say how long it takes and confirm when it is done, and Google Play asks developers to complete requests “within a reasonably quick period of time”.

How long can I deactivate my account before it deletes?

As long as your product sets: the gap between deactivating and deleting is a grace window you choose and state. My working rule is a grace window of about 14 to 30 days, told to the user up front, with billing canceled at the start and the deletion job scheduled for the end of it. On the App Store, Apple’s page allows scheduling deletion later to line up with a subscription’s expiry, “as long as there is also an option to delete the account immediately”.

Are deleted accounts really deleted?

They are when the deletion job covers all 8 places in the first table, apart from data kept under a written exception and backup copies. Backups follow the conditions in the GDPR runbook’s backup answer, and the seeded-account test in “How to verify it” is how you know the rest is gone instead of assuming it.