Account deletion that satisfies the app stores (and GDPR)
Apple and Google require in-app account deletion. The traps are the order you delete in and trusting a client-supplied id. Here's the Supabase version that doesn't orphan storage or delete the wrong user.
The symptom
TravelThread is an Expo app, and to ship on the App Store and Play Store I needed an in-app “delete my account” button. Apple’s review guideline 5.1.1(v) and Google’s data-deletion policy both require it: if a user can create an account, they must be able to delete it from inside the app, not by emailing you. “Add a delete button” sounds like one line. The first version I wrote left files behind on every deletion, and an earlier draft could have deleted the wrong user entirely.
What was actually happening
Two traps, both easy to miss:
- Cascade does not touch Storage. I had
ON DELETE CASCADEforeign keys, so deleting the auth user cleanly removed the profile, posts, comments, every row in Postgres. But a user’s uploaded files in Supabase Storage are not rows; cascade never sees them. Every deletion silently orphaned a folder of images that kept costing storage. - Trusting a client-supplied id. The obvious shape is
deleteAccount(userId)with the app passing the id. But an edge function that deletes whatever id it is handed lets any authenticated user delete any account. The id has to come from the caller’s own verified token, never the request body.
The fix
A Supabase Edge Function that identifies the user from their JWT, then deletes in the right order: storage first, the auth user last.
// identify the caller from THEIR token, never a body param
const { data: { user } } = await userClient.auth.getUser();
if (!user) return new Response("Unauthorized", { status: 401 });
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
// 1. wipe storage first (cascade ignores it), every per-user bucket
for (const bucket of ["avatars", "uploads"]) {
const { data } = await admin.storage.from(bucket).list(user.id);
if (data?.length) {
await admin.storage.from(bucket).remove(data.map((o) => `${user.id}/${o.name}`));
}
}
// 2. delete the auth user; ON DELETE CASCADE removes every owned row
await admin.auth.admin.deleteUser(user.id);
The data export (the GDPR right to access) is the same idea in reverse: identify the user from their token, then read their rows out of each table into one JSON download. Both functions run with the service-role key, which bypasses RLS, so they live in the edge-function environment only and act solely on user.id from the verified JWT.
The lesson
Account deletion is two requirements at once, a store rule and a GDPR one, and the part that bites is ordering. Delete Storage before the auth user, because cascade cannot reach files, and take the user id from the token, never the request, so the function can only ever delete the caller.
Discussion
Powered by GitHub. Sign in to leave a comment.