DEV Community

NoahHayes7250
NoahHayes7250

Posted on

How to Design Account Deletion: 3 Stages for Consent, Sessions, and User Removal

Short answer: make deletion a tracked workflow with three separate stages—consent cleanup, session revocation, and user removal—and make the final state observable before you tell a customer it is done. A CAPTCHA on signup keeps bots out, but it does not clean up an account later. The hard part is recovery: a person who clicks delete still needs a safe way to prove ownership during the grace period, while every active login must stop working promptly.

I run a small B2B SaaS, so my decision rule is blunt: spend engineering time where it protects revenue per hour. Account deletion is one of those places. A half-working button creates support tickets, privacy risk, and a confusing path back to a paid workspace. Ship it weekly in small slices, outsource the undifferentiated plumbing, and keep the state machine in code you can reason about.

A choice matrix before you touch the database

Approach Recovery path Session behavior Data posture Best fit
Immediate hard delete None after confirmation Revoke now Remove user-owned rows in one transaction where possible Low-risk trials with no retention duty
Delayed delete with a grace window Signed restore request, re-authentication required Revoke now; restore creates fresh sessions Tombstone first, purge on a scheduled job Most B2B products
Account disable plus manual review Support-assisted identity check Revoke now Retain records under a documented legal hold Regulated or disputed accounts

My default is the delayed option. It gives a user a narrow recovery path without leaving an active credential alive. The trade-off is operational work: you need a purge worker, an audit record, and a clear retention policy. Choose immediate deletion when you truly have no dependent data. Choose manual review when a legal hold or billing dispute makes automation unsafe.

Do not use a CAPTCHA as proof of identity. It is a bot signal, not an account-recovery factor. OWASP recommends treating authentication events, reauthentication, and session invalidation as separate controls; that distinction is useful here too.

How should an account deletion workflow handle consent cleanup, session revocation, and user removal?

Think in state transitions rather than a single DELETE statement:

active -> pending_deletion -> deleted

The first transition records intent and blocks new work. The second revokes every session and token. The last removes or anonymizes data according to the retention policy. If one stage fails, the record remains in a visible intermediate state and can be retried; it does not silently look complete.

Consent cleanup means removing the account from product communications and recording the withdrawal event. Keep the event itself if your audit policy requires it, but do not keep a marketing profile that the user asked you to erase. Separate product consent, transactional messages, and legally required notices. They have different purposes and should not share a single boolean.

Session revocation is broader than clearing a browser cookie. Revoke refresh tokens, password-reset tokens, API keys, and organization invitations that grant access. A short-lived access token may remain cryptographically valid until expiry, so resource authorization should also check the account state for sensitive operations. This is a latency-versus-cost choice; document the maximum exposure you accept.

User removal is where teams discover hidden ownership. Files, comments, invoices, webhook deliveries, and analytics rows may point at a user ID. Decide per table whether to delete, anonymize, or retain under a legal basis. A foreign-key cascade is not a policy. It is only a mechanism.

The implementation I can test in one afternoon

The workflow below keeps policy decisions explicit. It is deliberately boring. Boring code is easy to run during an incident.

type DeletionState = "active" | "pending_deletion" | "deleted";

interface DeletionRequest {
  userId: string;
  requestedAt: string;
  purgeAfter: string;
  state: DeletionState;
  reason?: string;
}

interface DeletionStore {
  getUserState(userId: string): Promise<DeletionState>;
  saveRequest(request: DeletionRequest): Promise<void>;
  revokeSessions(userId: string): Promise<void>;
  withdrawConsents(userId: string): Promise<void>;
  purgeOrAnonymize(userId: string): Promise<void>;
}

export async function requestDeletion(
  store: DeletionStore,
  userId: string,
  now = new Date(),
): Promise<DeletionRequest> {
  const state = await store.getUserState(userId);
  if (state === "deleted") throw new Error("account_already_deleted");

  const request: DeletionRequest = {
    userId,
    requestedAt: now.toISOString(),
    purgeAfter: new Date(now.getTime() + 7 * 86400_000).toISOString(),
    state: "pending_deletion",
  };

  await store.saveRequest(request);
  await store.revokeSessions(userId);
  await store.withdrawConsents(userId);
  return request;
}

export async function purgeDue(
  store: DeletionStore,
  request: DeletionRequest,
  now = new Date(),
): Promise<void> {
  if (request.state !== "pending_deletion") return;
  if (new Date(request.purgeAfter) > now) return;
  await store.purgeOrAnonymize(request.userId);
}
Enter fullscreen mode Exit fullscreen mode

The seven-day value is an example policy, not a universal standard. Put it in configuration and expose the effective deadline in the UI. In tests, assert that a second deletion request is idempotent, a token cannot create a new session after revocation, and a retry of purgeDue does not corrupt already anonymized rows.

One trap cost me a morning: the web app showed “deleted” after the first database write, while the background purge had not touched uploaded files. The request handler had committed a row update, sent a success response, and then handed work to a queue whose retry policy was invisible to support. A browser refresh looked clean because it only read the account row. The file store, outbound webhook queue, and analytics export still carried the old identifier. The fix was to return pending_deletion, emit a job identifier, and let the status endpoint report each stage. The worker now records an attempt for every dependent store, retries idempotently, and leaves the request pending when one store is unavailable. Support can see the exact stage without opening a database console. Users handle an honest pending state better than a promise that turns out to be false.

Ship it.

Keep the recovery path narrow. Require recent authentication or a second factor to cancel deletion, bind the restore link to the original account, expire it quickly, and issue new sessions after restoration. Never resurrect old refresh tokens. Your mileage may vary if your support team must verify identity offline; write that exception into the policy instead of bypassing the state machine.

What should you measure after shipping?

Observability is part of deletion, not an optional dashboard. Log a request ID, actor, account ID, state transition, and policy version. Avoid putting email addresses or raw tokens in logs. Count pending requests by age, purge retries, and restoration attempts. Alert on a request that remains pending past its deadline.

Run a weekly synthetic account through signup, CAPTCHA challenge, deletion, and attempted reuse of the old credentials. The test should verify both the happy path and the denial path. I also keep a fixture with a workspace owner, two members, an API key, an invoice, and an uploaded file; it catches ownership assumptions that a one-row user fixture misses.

The acceptance check is simple: after completion, the old password, refresh token, API key, and invitation cannot authorize a request; consent-dependent messages stop; retained records have a documented reason; and the audit trail proves who requested each transition. If any answer is “we think so,” the workflow is not finished.

When is a different approach the better choice?

Delayed deletion is not suitable when a contract requires immediate erasure and no retention exception exists. In that case, use a transactionally coordinated hard delete and verify every dependent store before acknowledging success. It is also a poor fit for accounts under a legal hold; stick with disablement plus a reviewed retention record there.

Conversely, a seven-day grace window is useful when accidental clicks are common or a workspace owner needs to recover billing history. The catch is that recovery must be an explicit, authenticated action, and the deadline must be enforced by the server rather than a countdown in the browser.

The right design is less about a particular identity product and more about boundaries you can test. Keep consent, sessions, and user data as separate responsibilities. Give each a state and an owner. Then you can change storage providers or notification tooling without rewriting the decision logic—the kind of undifferentiated work a solo team should outsource.

References

Top comments (0)