Notes / Before launch
Delete your own account: the test most AI-built apps fail
Sign up, delete your account, then look in the database. In most AI-built apps the rows are still there, and the app said it worked.
Of all the things that get an app into trouble after launch, this is the one I would check first. It takes five minutes, and almost nothing else is this easy to get wrong without noticing.
The test
- Sign up for your own app with a throwaway email address.
- Use it like a real user would: fill in a profile, create something, upload a file.
- Copy that user's ID from Authentication in the Supabase dashboard.
- Delete the account the way a user would, using whatever your app offers.
- Open the SQL Editor and run this, with the ID pasted in:
select
(select count(*) from auth.users where id = 'PASTE-USER-ID') as auth_user,
(select count(*) from public.profiles where id = 'PASTE-USER-ID') as profile_rows,
(select count(*) from public.orders where user_id = 'PASTE-USER-ID') as order_rows,
(select count(*) from storage.objects
where (storage.foldername(name))[1] = 'PASTE-USER-ID') as files;Swap in your own table names. Every number should be zero. In most AI-built apps the first one is zero and the rest are not.
What you will probably find
There are three common outcomes, and all three look identical to the person deleting their account.
There is no delete option at all
Very common. The app has sign-up, sign-in and password reset, because those came with the template, and no way to leave.
The button just signs them out
Also common. The session ends, the app says the account has been deleted, and nothing was deleted. Supabase gives the browser no way for a user to delete their own account, so a button wired to signOut() is an easy thing to ship by mistake.
The login is gone and the data is not
This is the interesting one, and what happens depends on how your tables were defined. I tested all three cases on a real database:
- No foreign key to
auth.users, which is what AI-generated schemas usually produce. The login is deleted and every row the user created stays behind, now pointing at a user who no longer exists. Nothing errors, and nothing shows it. - A foreign key with no cascade rule. The delete fails outright with a constraint error. If your code doesn't check for that error, the user is still told their account was deleted.
- A foreign key with
on delete cascade. The rows go when the user goes, which is what you want.
In all three cases, files in storage stay exactly where they are. No cascade rule on your tables covers storage objects.
Wire up the cascade
For each table holding user data, in the SQL Editor. Check the constraint name against your own schema first:
alter table public.orders drop constraint if exists orders_user_id_fkey;
alter table public.orders
add constraint orders_user_id_fkey
foreign key (user_id) references auth.users(id) on delete cascade;With this in place, deleting the user removes their rows in the same step, instead of relying on your code remembering every table forever.
Deleting properly, in order
Deletion has to happen on the server, because it needs the secret key. In a Supabase Edge Function:
import { createClient } from 'npm:@supabase/supabase-js@2';
Deno.serve(async (req) => {
// 1. Who is asking? Answer with their session, never with the secret key.
const asUser = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('PUBLISHABLE_KEY')!,
{ global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } } },
);
const { data: { user } } = await asUser.auth.getUser();
if (!user) return new Response('Unauthorized', { status: 401 });
const admin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SERVER_SECRET_KEY')!,
);
// 2. Files first: no cascade rule covers storage.
const { data: files } = await admin.storage.from('avatars').list(user.id);
if (files?.length) {
await admin.storage.from('avatars')
.remove(files.map((f) => `${user.id}/${f.name}`));
}
// 3. Anything not wired to cascade.
await admin.from('orders').delete().eq('user_id', user.id);
// 4. The account last, so a failure above doesn't orphan the rest.
const { error } = await admin.auth.admin.deleteUser(user.id);
if (error) return new Response(error.message, { status: 500 });
return new Response(JSON.stringify({ deleted: true }),
{ headers: { 'Content-Type': 'application/json' } });
});Deploy this one with the usual login check, unlike a Stripe webhook, because it needs the caller's session to know whose account to delete.
The copies that live outside your database
Deleting the user in Supabase does nothing to the copies held elsewhere. Before calling this done, work through the services your app sends user data to. Usually some of these:
- Your payment provider still holds the customer record. Stripe keeps its own records, but you can delete the customer object where there is no live subscription or reason to keep it.
- Your email provider still has them on a list.
- Your analytics or session replay tool still has their sessions.
- Your backups still contain them, which is normal and worth being able to explain.
Then run the test again
Create another throwaway account, delete it, and re-run the query at the top. All zeros means you can describe what your app does with confidence rather than hope.
This is a factual question rather than a legal one: someone asked to be deleted, and either was or wasn't. What your particular market requires is a question for your lawyer. If you would like this and the rest of the pre-launch list checked against your running app, that is what a launch readiness review is for.