auditmyapp

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

  1. Sign up for your own app with a throwaway email address.
  2. Use it like a real user would: fill in a profile, create something, upload a file.
  3. Copy that user's ID from Authentication in the Supabase dashboard.
  4. Delete the account the way a user would, using whatever your app offers.
  5. 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:

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:

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.