Part 24 shipped account deletion as a request — a form, a queue, a runbook I'd follow by hand. It was honest and it was insufficient: the release runbook ranked it the single most likely rejection, because Apple's 5.1.1(v) and Google Play both require deletion to be completed, not filed.
So deletion became real — in three lanes, each with a different threat model, sharing one Lambda's order of operations. This is the post where security decisions stop being abstract.
TL;DR — In-app: an argument-free authenticated mutation — the caller is whoever the API verified, so it can only ever delete itself; the order S3 → DynamoDB → Cognito last is the design, so any earlier failure leaves an account that can still sign in and retry; the S3 identity is derived from the caller's own object keys, never a client-supplied value. Guest web: a single-use confirm token, email dedupe, a confirm page that acts only on button click (inbox scanners prefetch links), and a mutation that always answers
{ok: true}so the form can't probe which emails have accounts. Admin: the same Lambda's twin with group rules (super-admins are un-deletable, never yourself), plus a repair path for half-deleted accounts — and anonymous activity rollups, so erasing a user doesn't rewrite usage history.
(Part 32 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
Lane 1: in-app, immediate — and the order is the design
The deleteAccount mutation takes no arguments. That's the first security decision: the caller is whoever AppSync verified the token for, so the Lambda can only ever delete the account making the call. There is no "which user?" parameter to tamper with, because there is no parameter.
The second decision is the sequence. Deleting an account touches three stores, and the Lambda walks them in a specific order:
- S3 objects — avatar, receipt photos.
- DynamoDB rows — every table the user owns (names arriving as SSM paths, per the house convention; the list of tables to sweep is one explicit constant).
- The Cognito user — last.
The reasoning: every earlier failure leaves an account that can still sign in and retry. If S3 fails, nothing's gone. If DynamoDB fails halfway, the user still has credentials and can press the button again. Delete the Cognito user first and any subsequent failure orphans data that can no longer be attributed to anyone — un-deletable exactly when you're obliged to delete it. Part 24 made this point about the manual runbook; the Lambda inherited it as a hard invariant.
A third decision hides in step 1. Storage paths are keyed by the identity-pool ID, which is not the Cognito sub. Rather than accepting that ID from the client (a value a hostile caller could point at someone else's prefix), the Lambda derives it from the caller's own object keys — the avatar key and receipt image keys already stored on their rows. If you own the row, the row tells us your prefix.
When it's done, one thing is deliberately left behind: a COMPLETED deletion-request row — proof of fulfilment, with no user data on it, for the ninety days the privacy policy says records are kept.
On the client: a type-DELETE confirmation, immediate execution, sign-out after. And one honesty detail — the Lambda deliberately outlives the API gateway's timeout (a full sweep can take longer than the request window), so a "failed" call is reported as "outcome unknown — check by signing in", never "nothing happened." The one thing worse than a slow deletion is telling someone it didn't happen when it did.
Lane 2: the guest round-trip that can't leak
Google Play requires a public web page where anyone — including someone who can't sign in — can request deletion. A guest form is an attack surface by definition, and the original version (a guest-writable model, verify-by-reply) was tightened into a proper flow:
- The form calls a mutation; direct guest writes to the model are revoked. One writer, so every request passes through the same guards.
- One email per address per 24 hours. The form can't be used to flood an inbox.
- A single-use confirm token lands in a branded email with a confirm link (with a reply-to-confirm fallback when no site origin is configured).
- The confirm page acts only on button click. This one matters more than it looks: corporate inboxes and security scanners prefetch every link in an email. A link that confirms on GET would confirm deletions that nobody clicked. The page loads inert and deletes only on an explicit press.
-
The mutation always answers
{ok: true}. Whether the address has an account or not, the response is identical — otherwise the public form becomes an email oracle: a way for anyone to test whether an address is registered. The truth goes to the support inbox, which is notified separately.
Confirmed web requests take the documented 30-day path in the privacy policy; in-app deletion is immediate. Two doors, two timelines, both written down where users can read them.
Lane 3: admin-initiated, without rewriting history
Support sometimes needs to delete an account on someone's behalf. adminDeleteUser is the deletion Lambda's twin — same S3 → DynamoDB → Cognito-last order — with the target named by sub and a set of rules the handler enforces against the caller's verified groups (not anything the client claims):
- A super-admin can never be a target — refused outright.
- Deleting an admin requires a super-admin caller.
- Never yourself — use the app's own door.
- A sub with rows but no Cognito user still erases — the repair path for half-deleted accounts from the manual era.
And the subtle design that makes admin deletion safe for analytics: an ActivityRollup model of anonymous per-day, per-action counters. As the Lambda erases a user's activity rows, it increments the rollups — so deleting an account doesn't rewrite usage history. The rollups carry no userId by design, which is precisely what lets them survive the erasure they record. They replicate to dev as structured passthrough and are admin-read-only. A deleted user disappears; the fact that someone scanned a receipt on that Tuesday does not.
The legal copy had to get honest too
Making deletion real forced a re-read of the privacy policy, and one paragraph turned out to be false. The barcode feature's product lookups run server-side — but the review sheet loads product photos straight from the third-party database's servers, which means they do see the device's IP at that moment. The drafted "they never see your IP" wording was wrong and was rewritten to disclose all four databases, the server-side lookup, and the photo exception. Deletion got its own rewritten section (immediate in-app; email-verified 30-day web path; records kept 90 days), and each legal document got its own updated-date so amending one no longer restamps the other.
Compliance work has a way of auditing your own claims. It's worth doing before someone else does.
What I took away
- Identity comes from the token, never a parameter. An argument-free mutation can only delete its caller.
- Order destructive operations so every failure is retryable — the step that destroys the ability to retry goes last.
- Derive cross-store identifiers from data the caller already owns, never from the request.
- Confirm on click, never on load. Link scanners prefetch; a GET must be inert.
- Public forms must not be oracles. Identical responses whether or not the account exists.
- Erase the person, keep the count. Anonymous rollups let deletion and analytics coexist.
- Audit the policy while you build the feature — mine contained a sentence that wasn't true.
Next up
Part 33: the app learned to speak nineteen languages — a two-tier translation model (human-reviewed launch tier, machine-translated extended tier), a diff-only translation pipeline, and the day the shell flipped to right-to-left.
What does your delete-account flow do if the Cognito (or auth-provider) call succeeds but the data delete fails? If the answer is "orphans," swap the order.
Top comments (0)