Suspended and deleted accounts

An account can be suspended or deleted. The kit holds the state, refuses the account at every door, records every transition, and sends the mail. An operator suspends, reinstates, deletes, restores and exports an account through control-plane routes, below. A person deletes their own account from inside your app. A purge erases a deleted account’s data from every capability that holds it, once its window closes. Some capabilities keep a person’s rows instead, because the law or the record needs them, and say why.

Two states

StateWhat it meansEnds
SuspendedThe account cannot sign in, refresh a token, or act on any routeAt suspended_until, by itself, or when an operator reinstates it
DeletedThe same, and its data is due for erasure once the purge window closes. Restorable until thenWhen it is restored, or when the purge erases it

Both live as columns on pithy_auth_users: suspended_at and suspended_until, deleted_at and purge_after. So a session read already carries them, and an account in good standing costs no extra read. Refusing a suspended account costs one, because the operator’s reason is read from the history table.

A suspension past its suspended_until is not a suspension. Nothing has to be written to end it. A deleted account outranks a suspension it was already under.

No client can write its own state. The four columns are input: false, and only the kit’s transitions set them.

The admin reads say where each account stands. GET /auth/admin/users and GET /auth/admin/users/:userId return each user with state — active, suspended or deleted — and suspendedUntil, the end of a suspension in force, null for one with no end and in every other state, and purgeAfter, the end of a deleted account’s window to restore it, null otherwise. They are read the way enforcement reads them, so a suspension past its end is active. The listing does not leave a suspended or deleted account out, and these fields are what let a pane offer suspend or reinstate without sending a write to find out. The listing narrows on it too: GET /auth/admin/users?state=suspended pages the suspended accounts on the server, judged as enforcement judges them. It also takes verified, true or false, and a since/until signup window, ISO-8601, inclusive and exclusive; a window that does not start before it ends is refused. Neither the reason nor who acted is there: the reason is on the trail, and the trail is audit’s. Each field is optional in AdminUserView, so a client reading a Worker older than them still parses its answer.

Where an account is refused

Every request. The session middleware gives a refused account no identity, so every requireAuth() in the kit refuses it, whichever capability wrote the guard. Auth’s own guard answers with the lifecycle code. Another capability’s answers 401, and the client’s next refresh gets the code.

Every sign-in and every token refresh. Magic link, one-time code, each social provider and /token/rotate all create their session in one place, and that place refuses. A refused rotation spends nothing: the presented credential still works once the account is reinstated.

Better Auth’s own endpoints. /token, /get-session and the rest of what acts as the session’s holder refuse too. Signing out always works.

A check that cannot be made refuses. If the read behind it fails, the request gets a core/internal 500, never a session.

The two codes

Both are 403. Not 401, because signing in again cannot help.

Codemessageparams
auth/account_suspendedThe operator’s reasonsuspendedUntil, as ISO-8601, when the suspension has an end
auth/account_deletedThis account is deleted.purgeAfter, as ISO-8601: when its data may be erased, and so the end of the window to restore it

The HTTP codec strips action and detail from both, so a caller learns the state and the reason and nothing else. @pithy-sh/i18n carries each in Spanish, keyed by the code. Error codes has the rest of auth’s.

Your client branches on the code. A refresh that answers either one is not a reason to sign in again: send the reader to the screen for that state.

The screens

pithy ui add react scaffolds three when the Worker composes auth, and pithy ui add react-native scaffolds the same three in React Native primitives: account suspended, account deleted, and organization suspended, for somebody acting for an organization that is. Each says what happened, what it means, and who to ask. Each bakes its own English, and the Spanish ships in @pithy-sh/i18n. They are yours to edit after that.

None renders a reason or a date from its URL, because anybody can write a URL. The details are in the mail.

A magic link for a refused account returns to the screen it named, with the code as ?error=. So does a social sign-in, to its error callback. The callback and sign-in screens send the reader on to the screen for that state. So does the one-time-code screen, when a right code meets a refused account: a right code for a refused account is not a wrong code.

The record

pithy_auth_user_lifecycle holds every transition: the action, the actor, the reason, the time. The columns say what is true now; the table says what happened and who did it. The two are written in one transaction, so neither is ever there without the other.

It has no foreign key to the user, because the purge erases the user row and this record must outlive it.

A transition that finds the account already where it was asked to put it does nothing: no row, no event, no mail, no hook. The control-plane routes still record the call on the trail. A deleted account cannot be suspended or reinstated. Restore it first, and its suspension is still there to lift.

Suspending an account that is already suspended is the one exception. It restates the suspension: the new reason and end replace the old, a row records them, and the mail and the audit event go out. The start stays, and no hook fires, because the account was suspended and still is.

Each transition also writes an audit event: auth/admin_user_suspended · auth/admin_user_reinstated · auth/admin_user_soft_deleted · auth/admin_user_hard_deleted · auth/admin_user_restored · auth/admin_user_purged · auth/user_self_deleted. A purge adds auth/admin_user_purge_step per step and auth/admin_user_purge_failed when it gives up. An export writes auth/admin_user_exported.

Three scopes

Reading a user, ending their access, and erasing them are three grants, beside auth:users:read. None implies another.

ScopeWhat it grants
auth:users:suspendSuspend an account, and reinstate it
auth:users:deleteDelete an account, restore it inside its window, and start its purge
auth:users:exportEverything the kit holds about one account, for an access request

auth:users:suspend gates suspend and reinstate. auth:users:delete gates delete and restore, and auth:users:export the export. Control-plane scopes is how a connection is granted one.

Suspend and reinstate

Two routes, each gated by auth:users:suspend. A connection holding only auth:users:read is refused 403, and the scope is checked before the body is read.

POST /auth/admin/users/:userId/suspend     { reason, until?, notify? }
POST /auth/admin/users/:userId/reinstate   { reason, notify? }

The paths are under auth’s basePath, /auth by default, not under /control-plane. reason is required on both: one sentence, at most 500 characters, written for the person, because a suspension’s reason is what every refused sign-in shows them. A request without one is validation/invalid_input.

Suspend sets the state, records it, and then revokes every session the account holds, refresh tokens with them. The state is written first, so a session still alive in between meets auth/account_suspended. Signing in again answers auth/account_suspended with the reason. until is an ISO-8601 time in the future, and one in the past is refused. The suspension ends by itself when it passes, with nothing written. Suspending a suspended account restates it, and the response says outcome: "updated". The response carries the suspension in force, sessionsRevoked and mailed.

Reinstate lifts the suspension. It restores no session: the person signs in again. An account with no suspension in force answers 200 with outcome: "unchanged", and nothing is written.

Neither touches a deleted account. Both answer core/conflict, naming the restore route, because a deleted account is restored, not reinstated. An unknown :userId is a 404.

notify is true when absent, and sends the account-suspended or account-reinstated mail. notify: false sends none. lifecycleEmails turns a mail off for every call; notify turns it off for one.

Every call is audited, as auth/admin_user_suspended or auth/admin_user_reinstated, with the operator, the connection, the reason and the user id. A refusal is recorded as denied with its code, a body the validator turns away included, and a reinstatement that changed nothing as a success with changed: false. The sessions a suspension ends are recorded as auth/admin_user_sessions_revoked, with cause: "suspended".

Delete, restore and export

Three routes. Deleting and restoring are gated by auth:users:delete, exporting by auth:users:export. Neither grants the other, and auth:users:read grants neither. The scope is checked before the body is read.

DELETE /auth/admin/users/:userId           { mode?, reason, notify? }
POST   /auth/admin/users/:userId/restore   { reason }
GET    /auth/admin/users/:userId/export?reason=…

reason is required on all three, on the same terms as a suspension’s. A request without one is validation/invalid_input.

Two modes, and soft is the default. mode: "soft", or no mode at all, stamps deleted_at and purge_after (now plus the purge window) and revokes every session the account holds. Signing in again answers auth/account_deleted. The deletion-scheduled mail goes out with the purge date, and onUserDeleted fires. The account can be restored until purge_after. mode: "hard" closes the window now: nothing can restore it, and the purge starts in the same call. You name it on purpose: a typo such as "Hard" is a 400, never either mode. A hard deletion of an account already deleted softly brings its purge forward to now.

The purge window is retention.purgeWindows.user from the Worker’s retention block in pithy.config.ts when it names one, and otherwise auth’s own retention.purgeAfterDays, 30 unless you set it. The block, and everything else it governs, is retention and legal holds.

The response says what happened. outcome is deleted, brought_forward, or unchanged for an account already deleted. purge is scheduled for a soft deletion, started when this call started the purge, and pending when the account is due and the Worker binds no purge Workflow to start. notify: false silences the mail this call sends: the deletion-scheduled notice for soft, the deletion-complete notice for hard.

Restore clears both columns inside the window and records it. It restores no session: the person signs in again. A suspension the account was under before the deletion is still there, and the response says state: "suspended". Once purge_after has passed, a restore is a 409 naming the date, and a restore that races the purge is checked against the same database clock, so the two always agree on which one won. After the purge there is no account: a 404.

Export answers an access request with one JSON document: the subject, when it was read, and one entry per composed capability that declares a purge contribution, in the order the purge runs them. Each entry says what a purge does with its rows: disposition is erase or retain, and a retain entry carries the basis it is kept on, in the sentence written for the person. Then it says what it answered: exported with that capability’s data, not_exported when it declares no export, or unavailable when its rows would not read. Every kit capability that holds rows about a person exports them, so not_exported is only ever one of your own capabilities. Auth’s own part is the account, its sessions, linked providers, devices, history, the sign-in links and codes the verification ledger records for it, and its rows in the tables its Better Auth plugins created, and no credential: no token, no two-factor secret, no key. The ledger’s part is verifications, absent from a Worker older than the ledger. No other kit capability’s part carries a credential either: no opt-in token, no share link, no invitation digest, no provider payload, no Google Play purchase token, no message variables. It is audited as a disclosure, auth/admin_user_exported, with the operator, the reason and which parts answered, never the data. An unknown or purged account is a 404.

Every call is audited, as for suspend and reinstate: auth/admin_user_soft_deleted or auth/admin_user_hard_deleted, and auth/admin_user_restored. A refusal is denied with its code, and a call that changed nothing is a success with changed: false.

Deleting your own account

DELETE /auth/account   { confirm: true }

The signed-in person deletes their own account. This is the route the App Store and Google Play require: an app that lets people create an account must let them delete it from inside the app. Put a button behind it in your settings screen, and send confirm: true once the person has confirmed. Without it the call is a 400 and nothing moves.

It is always soft, on the configured window, and the subject is always the session’s own: no field names an account. Every session ends, this one included. It is audited as auth/user_self_deleted, with the person as the actor. A suspended account can still delete itself, because a suspension is no reason to keep somebody who wants to leave. A deleted one is refused with auth/account_deleted.

Signing in to leave. A suspended person holds no session to call this route with. A sign-in that sends x-pithy-intent: delete-account gives them one that opens nothing else: every other route, a token and a refresh included, still answers auth/account_suspended. When a sign-in answers auth/account_suspended, offer a delete button that signs in again with the header and then calls this route. The header only works on a sign-in request your app sends itself, such as the one-time code or an ID-token sign-in; a browser redirect such as a magic link cannot carry it.

The way back. Somebody who changes their mind inside the window cannot sign in to undo it, so the deletion-scheduled mail tells them how: its restore button links to retention.restoreUrl when you set one, and the mail always says to reply before the purge date. An operator restores the account through the route above.

The purge

A purge erases one deleted account from every capability that holds rows about it. It runs as a Workflow, one step per composed purge contribution, named for its capability, so a capability that fails retries alone and the steps that finished do not run again. A contribution that retains its rows is a step too, and erases nothing: it records purge_retained, with its basis as the reason, so the history never reads a kept step as an erased one. In order:

  1. Is the account due: deleted, with its window closed? A restored account, one inside its window, and one already erased end the run here, having erased nothing.
  2. Every composed contribution but auth’s, in composition order, your app last. Each reads the account’s address, when it needs one, inside its own step.
  3. The deletion-complete mail, before the address is erased.
  4. Auth’s own rows: the user, its sessions, linked providers, devices, pending sign-in codes and its rows in the verification ledger, and its rows in every table a composed Better Auth plugin created with a userId column. Its history stays, the one pithy_auth_* row left.
  5. The purged record, auth/admin_user_purged, and onUserPurged, once.

Every purge can be proved. Every step from 2 on also records an audit event, auth/admin_user_purge_step, with the step’s name and whether it erased or retained in its metadata, and never the summary. A step that runs out of retries fails the run, and before it does the purge records where: a purge_failed row naming the step, and auth/admin_user_purge_failed. The failure’s message is never written. The deleted row keeps the deletion’s mode and when its purge comes due, because the purge erases the account’s own columns. Together they are what the erasure attestation reads: GET /control-plane/erasure/user/:userId, under erasure:read, says when the account was deleted, by which kind of actor and why, each step erased or kept with its reason, the purge’s state, and the audit event behind each step.

A daily cron at 02:00 UTC starts the purge for every account past its purge_after. A hard deletion starts its own. Either way the run is named for the deletion and the UTC day, so a repeated hard deletion is one purge, and a purge that failed is started again by the next day’s pass.

A legal hold stops it. Before the first step, and again before every step after it, the purge asks whether a hold stands on the account, each ask its own step. A hold found ends the run as held: the steps that finished stay finished, and nothing more is erased. Released, the account is still deleted and still due, so the next daily pass starts a fresh purge, whose steps find nothing to erase where the first one did and carry on from there. A hold that cannot be read fails its step, which retries, so a purge never erases past a hold it could not see. The daily pass does not start a purge for a held account at all. Holds are placed over the control plane: retention and legal holds.

Inactivity deletes an account, through this same lifecycle. When the Worker’s retention block declares class ages (classAges: { pii: "3y" }), the same daily pass soft-deletes every account untouched for longer than the strictest class age it holds: no sign-in, no token refresh, and no session refreshed since. Auth marks an account’s name and email as pii, so a pii age reaches every account. The clock is lastActiveAt on the account, stamped each time it opens a session or refreshes one, and stamped on every existing account when the column arrived. The deletion is an ordinary soft one by a system actor, with the reason Inactive for more than N days, the retention policy’s limit for the personal data it holds: the deletion-scheduled mail, the purge window, then the purge. Which classes an account holds is read off the composed schemas that hold rows about a user. A held account is skipped. A pass deletes at most 200 accounts, so a backlog drains over several nights. With no class age that applies, the pass does nothing.

The Workflows are yours to export. Only the Worker that composes auth has every contribution, your own among them, so the two classes go in that Worker’s entry, beside createEntrypoint, and their bodies are the kit’s:

// apps/api/src/index.ts
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";
import { runAuthPurge, runAuthPurgeDue } from "@pithy-sh/auth/src/purge/workflow";

export class AuthPurgeWorkflow extends WorkflowEntrypoint<Record<string, unknown>> {
  override run(event: WorkflowEvent<unknown>, step: WorkflowStep) {
    return runAuthPurge(this.env, event, step, NonRetryableError);
  }
}

export class AuthPurgeDueWorkflow extends WorkflowEntrypoint<Record<string, unknown>> {
  override run(event: WorkflowEvent<unknown>, step: WorkflowStep) {
    return runAuthPurgeDue(this.env, event, step, NonRetryableError);
  }
}
// apps/api/wrangler.jsonc, in each environment's stanza
"workflows": [
  { "binding": "AUTH_PURGE", "name": "acme-prod-auth-purge", "class_name": "AuthPurgeWorkflow" },
  { "binding": "AUTH_PURGE_DUE", "name": "acme-prod-auth-purge-due", "class_name": "AuthPurgeDueWorkflow" }
],
"triggers": { "crons": ["0 2 * * *"] }

pithy worker sync leaves these entries and the cron where you wrote them, and pithy doctor and pithy deploy do not count them against your app’s own Workflows. Until they are bound, a deletion still shuts the account, a hard deletion reports its purge as pending, and nothing is erased.

The purge audits through the composed audit capability. With no audit capability composed, nothing is recorded, as for every other event.

Config, mail and hooks

auth({
  baseURL: "https://api.example.com",
  // How long a deleted account waits before its data is erased. 30 when nothing says otherwise. `restoreUrl`
  // is where the deletion-scheduled mail sends somebody who wants their account back: an http(s) page of yours.
  retention: { purgeAfterDays: 30, restoreUrl: "https://example.com/account/restore" },
  // All four send by default. Set one to false to turn it off.
  lifecycleEmails: { accountSuspended: true, accountReinstated: true, deletionScheduled: true, deletionComplete: true },
  onUserSuspended: async (event, d1) => {},
  onUserReinstated: async (event, d1) => {},
  onUserDeleted: async (event, d1) => {},
  onUserPurged: async (event, d1) => {},
});

The four emails are transactional, so an unsubscribe never silences them. Account suspended and deletion scheduled say who to answer: a reply, which reaches your sending address. Deletion scheduled carries the date the purge is due, and the restore button when retention.restoreUrl is set. Deletion complete carries the date it ran, and is sent before the address is erased. Each is written in the account’s own language, dates included.

Each hook fires once per transition, with the user id, the actor, the reason and the time. Restating a suspension fires none. A hook that throws is logged and changes nothing, because the transition is already written.

What each capability does with a person’s rows

Every kit capability that holds rows about a person declares a user contribution, and every one exports what it holds. Most erase. Five keep their rows, because the law or the record needs them, and say why.

CapabilityDispositionWhat
autheraseThe account, sessions, providers, devices, refresh-token ledger, pending codes, verification ledger and plugin rows. Its lifecycle history stays
mcperaseThe consents the person gave applications to act as them, and the access and refresh tokens minted under them. A client is the application’s, whether it registered dynamically or is known by its metadata document, and stays
ledgereraseBalances in every currency, the transaction log, holds
ratingeraseThe rating in every pool, and the control-plane acts on it. The person’s place in every logged outcome is anonymized instead, one stand-in per outcome, so each opponent’s rating still replays exactly
leaderboarderaseEntries on every board, in every window, what each session result put on them, and their moderation history
matchmakingeraseInvites sent or received, friendships and requests on either side, and the person’s place, undelivered match and drop records in every game’s queue. A room code expires on its own TTL
multiplayeranonymizeSession results: the person’s id in the players, scores and winner is replaced, so the other players’ results stay. A session in flight they play in is closed with no result, and leaves the session index
supporteraseThreads linked to the account, or sent from its address and linked to no other account, with messages, attachments, raw mail, classifications, read state, assignment history, and each message’s vector in the semantic message index with its model stamp. On other people’s threads, the handovers they made lose their id; who held a thread is an opaque reference and stays with it
testerseraseCohort memberships under the account’s address, and their events. Cohort counts stay
artifactseraseRepositories the person owns, with their contents and grants, and the grants they hold. The record of every token minted for them is anonymized instead, one erased- stand-in per record, so a token still listed reads as the kit’s. The export hands those records back as mints
mediaeraseRecords the person holds as themselves, with their bytes and hashes. A record made for an organization is the organization’s
storageeraseFiles the person holds as themselves, with their bytes and share links. A file made for an organization is the organization’s
organizationeraseMemberships, the organization each session acted for, ownership offers made to or by them, invitations addressed to them. Invitations they sent and an organization’s history keep their id; an organization they were the last owner of is left without one
paymentsretainPurchases, entitlements, provider accounts, overrides, meter counters and usage reports of a user subject. Kept for tax and accounting records, which the law requires a business to hold after an account is closed.
emailretainThe address on the suppression list, and the log of mail sent to it. Kept so your unsubscribe holds: the address stays on the list of addresses not to mail, with the record of what was sent to it.
auditretainEvery audit event where the person is the actor. Kept as the record of who did what, which a security review or a dispute is read against.
controlplaneretainEvery legal hold placed on the person, standing or released. Kept as the record that a legal hold stood, when it was placed and why it ended. The export says when each was placed and released, never who placed it or why.
consentretainEvery grant and withdrawal, with the documents and evidence behind them and the visitors merged into the account. Kept as the proof of what you agreed to and when you withdrew it, which the law asks us to be able to show. The export opens the typed and representative names. The drawn image is kept with the record, never purged with the account.

The kit’s own build holds every capability to it: a table that names a person — userId, ownerId, subjectId, actorId, any …UserId, either side of an invite or a friendship, or an address — in a capability with no user contribution, or with one that has no export, fails it. isPersonColumn and personRowGaps, from @pithy-sh/core/src/capability/personColumns, are the rule, if you want to hold your own capabilities to it.

The purge contribution

A purge has to reach rows other capabilities hold, and no foreign key crosses a capability. So each capability declares, on the Capability contract, how one subject’s rows are erased or anonymized — or that they are kept, and why — and what an export hands back:

defineCapability({
  name: "app",
  requiredBindings: [],
  purge: [
    {
      subject: "user",
      summary: "Deletes the user's drafts.",
      erase: async ({ subject, env }) => {
        // Remove or anonymize this user's rows. Succeed when there is nothing left to remove.
      },
      export: async ({ subject, env }) => {
        // What you hold about this user, as one JSON object. No credentials.
        return { drafts: [] };
      },
    },
  ],
});

The purge runs every composed contribution as its own step, named for its capability, so a failure retries that capability alone. That is why there is at most one per subject per capability, and why erase must succeed when the rows are already gone. export is optional: without one, the export lists the capability as not_exported, and a throw from it costs its own entry, never the document. The kit knows two subjects, user and organization — the second for rows scoped to a tenant, which the organization purge runs — and defineCapability refuses a contribution for any other. Declare one on your own app capability for your own tables.

Or keep the rows, and say why. Not every row about a person should go: a payment record is commonly kept for tax law, a suppression has to outlive the person or the unsubscribe stops holding, and an audit trail is the record of who acted. retain is the third disposition beside erase and anonymize. It takes a basis, one sentence written for the person, and an export, which it may not leave out, because rows that are kept are rows an access request is answered with. It takes no erase. defineCapability refuses one without a basis, without an export, or with an erase as well. The purge records the step as purge_retained and erases nothing; the export carries the rows, disposition: "retain" and the basis.

const kept: PurgeContribution = {
  subject: "user",
  summary: "Keeps the user's invoices.",
  retain: { basis: "Kept for tax records." },
  export: async ({ subject, env }) => ({ invoices: [] }),
};

Rows found by address. A step is handed address, a function that reads the account’s address when it is called, and answers null once the address is gone. Call it inside erase or export; the address is never carried in the Workflow’s journal. Compare it in normalizeAddress’s form, from @pithy-sh/core/src/address/address, which is the form the kit stores addresses in.

Rows keyed by a column. For tables whose rows name the person in a column and need nothing but reading out and deleting, personRowsPurge, from @pithy-sh/core/src/capability/personRows, writes the contribution for you: it exports every matching row decoded through its schema, and deletes them all in one batch. personRowsExport is its export alone, for a contribution that retains. A table’s omit leaves columns out of the export, and its redact rewrites a row before that, for a credential only some rows hold. credentialFields, from @pithy-sh/core/src/capability/purge, names every field in an export that holds a credential, so a test can assert there are none.

ESC