Munchable keeps the sensitive half of its data on the phone. The conditions you pick, your sensitivities and your scan history are never sent to us, which is written into the privacy policy in those words.
That shape makes most of a GDPR erasure easy, because there is nothing on the server to erase. It also means an erasure has a half that no server can perform. Deleting an account therefore has a client side, and the client side is this:
export async function wipeLocalData(): Promise<void> {
try {
await AsyncStorage.multiRemove([
'munchable-profile', // useProfile persist (health profile)
'munchable-history', // useHistory persist (scan history + food log)
'munchable-products-v1',// productCache (pre-v2 key, still cleared)
'munchable-products-v2',// productCache
'hints:sent', // legacy: ingredient-hint memory, no longer written
'taxonomy:overlay', // taxonomy overlay
'taxonomy:meta',
]);
} catch {
// Best effort: a storage error must not block deletion.
}
}
Twenty five lines including the comment. Five of those seven keys are written by code that exists. Two are not.
The dead keys are the point of writing the list out
munchable-products-v1 is the old key of a cache that moved to v2 when the stored shape changed. The reason for that bump is its own small horror story: a new optional field rehydrated from an old entry as undefined, and undefined read as a real answer rather than as "we do not know", so after an app update the capture screen told people nobody had added a barcode that we had a name for. The fix was to bump the key and let old entries be ignored.
Ignored, though, is not gone. An entry written by a build from before that bump is still sitting in storage on any phone that upgraded. hints:sent is the same situation from a deleted feature: nothing has written it for months, and it is on the devices of everyone who used the app while it existed.
So a key nobody writes is still a key somebody's phone has, and an erasure that only covers the keys in the current code base is an erasure that leaves data behind on exactly the devices that have been around longest.
Why not just clear everything
AsyncStorage.clear() is one line and could not go stale. I am not using it, and the reason is that the same codebase ships as a browser build.
On web, AsyncStorage is window.localStorage with no namespacing of any kind. The library's own source says so plainly, including on clear():
/**
* Erases *all* AsyncStorage for the domain.
*/
clear: (callback) => {
return createPromise(() => window.localStorage.clear(), callback);
},
So on the phone clear() means "this app's storage", and at app.munchable.app it means "everything any code on this origin has stored". One call, two meanings, and the one I would be testing is not the one that matters most. An explicit list means the wipe does the same thing on iOS, Android and the web, and means I can read what it covers without knowing which platform the reader is on.
The cost is real and worth naming: a hand-written list fails in the dangerous direction. Forget to add a key and nothing breaks, no test goes red, and the data quietly survives a deletion. The only defences are that adding a persisted store is a rare event, and that the list lives one file away from a privacy policy that makes a promise about it.
The session is deliberately not in that list
On native the auth session is held in expo-secure-store, not AsyncStorage, through a small adapter. On web, where SecureStore does not exist, it falls back to the default storage. Either way it is cleared by supabase.auth.signOut(), in the step before the wipe, rather than by this function.
Two storages with two owners, and the credential is handled by the library that issued it. The wipe does not need to know how the session is stored, which is the only reason it can be a flat list of strings.
The server is deleted first, and the order is load bearing
const { error } = await deleteAccount();
if (error) {
showAlert('Could not delete your account', error);
return;
}
// Server erased everything and signed us out; now clear this device.
await wipeLocalData();
reset();
router.replace('/onboarding');
If the server call fails, the device is untouched and the user can try again or email us. Reverse the order and a failed server call leaves someone signed out of an account that still exists, with the local data that would have let them retry already gone.
The route behind that call fails closed in the same spirit. Without the credential it needs to delete the identity itself it returns 501 rather than deleting the rows around it, because a half-erasure that reports success is worse than a refusal. The app turns that status into a sentence that asks the person to email us, which is the one case where a human has to finish the job.
After the wipe, reset() clears the in-memory stores and the app replaces its route stack with onboarding. The point is that the app is a fresh install and not an empty signed-in shell: nothing to scroll back to, no cached name, no history screen with a "no scans yet" message implying there used to be some.
Elsewhere on the same subject: the data export carries a field explaining what is not in it, because an export of a server that holds almost nothing looks suspiciously empty otherwise, and storage written by an older build of your app is untrusted input, which is the full version of the v1 to v2 story above.
Top comments (1)
The point about dead keys is one I hadn’t thought about, and it’s exactly the data that survives longest.
On the “fails in the dangerous direction” cost: one way to turn it into a red test is to run the app through its main flows in a test build, call AsyncStorage.getAllKeys() at the end, and assert that every key is either in the wipe list or in a short, explicit “not ours to wipe” list (the session, for example). A new persisted store then breaks the build instead of quietly surviving a deletion.
The same check is worth doing outside AsyncStorage: files written to the document directory or an image cache aren’t covered by multiRemove, and a health-related app can end up with a cached photo or PDF that outlives the account.