# Account deletion

> Delete or suspend a user, end their sessions, remove their Storage objects, and keep foreign keys from blocking a delete.

Source: https://bettersupabase.com/docs/auth/account-deletion

Deleting an account touches three systems: Storage objects the user
uploaded, the Auth user, and every row that points at `auth.users`.
`deleteAccount` deletes the Auth user first, which applies the foreign keys,
then removes the objects, and returns a `Result`, like every repository
call.

```ts title="src/features/user/user-actions.ts"
"use server";

import { dbError, err } from "better-supabase";

import { avatars, documents } from "@/lib/buckets";
import { bs } from "@/lib/supabase/server";

export const deleteMyAccount = bs.action(
  { aal: "aal2" },
  async (_input, { auth }) => {
    if (auth.kind !== "user") {
      return err(dbError("unauthorized", "Sign in to delete your account"));
    }
    return bs.deleteAccount(auth.user.id, {
      buckets: [avatars, documents],
      cascades: ["notifications", "memberships"],
    });
  },
);
```

```ts
// { ok: true, data: { userId, removed: { avatars: 1, documents: 14 } } }
```

It is on every server (`createServer`, `createNext`, Hono, oRPC, edge, MCP) and
needs `SUPABASE_SECRET_KEY`. Without one it returns an `unexpected` error.

Apps on the `@supabase/server` pipeline, without a better-supabase server,
import it from `better-supabase/server` and pass their service-role client:

```ts
import { deleteAccount } from "better-supabase/server";

pipeline(
  [withSupabase({ auth: "user" }), withBetterSupabase(betterSupabase)],
  (req, ctx) =>
    deleteAccount(betterSupabase, ctx.supabaseAdmin, ctx.userClaims!.id, {
      buckets: [avatars, documents],
    }),
);
```

## Ended sessions [#ended-sessions]

better-supabase verifies access tokens locally, so a token stays valid until
it expires (an hour by default), even after the user signs out everywhere or
an admin ends the session. For actions you can't undo, also check that the
session still exists. `checkSession` reads `auth.sessions` with one query and
returns an `unauthorized` error with code `SESSION_REVOKED` when it is gone:

```ts
import { checkSession } from "better-supabase/server";

import { postgres } from "@/lib/postgres";

const ended = await checkSession(postgres.admin, auth);
if (ended) return err(ended);
```

The client needs to read `auth.sessions`, which the Data API roles can't, so
pass `createPostgres().admin` or another connection as `postgres`. Service and
anonymous callers pass through. Nothing calls it on its own: every other
request keeps verifying the token without a database or Auth round trip.

To enforce the same check in the database, add the `sessions` SQL module
(`better-supabase sql add sessions`) and a restrictive policy on the tables
that matter. `session_active()` is false once the session is gone or
expired, or the user is banned or deleted. Wrapped in `(select ...)`, it costs
one indexed lookup per statement:

```sql
create policy session_required on public.invoices as restrictive
  for all to authenticated
  using ((select better_supabase.session_active()))
  with check ((select better_supabase.session_active()));
```

Tokens without a `session_id` claim (signed by your app) and support tokens
(with an `act` claim, which the support block ends) pass.

The module also adds `better_supabase.last_sign_ins(uuid[])`, which returns
`(user_id, last_sign_in_at)` for many users in one query. `auth.users` isn't
readable through the Data API, so only the service role may call it. From
TypeScript, `lastSignIns` reads a page of users at once and returns a `Map`
keyed by user id, with `lastSignInAt` as a `Temporal.Instant` (or `null` for a
user who never signed in). Users that don't exist are missing from the map:

```ts
import { lastSignIns } from "better-supabase/server";

const signIns = await lastSignIns(
  postgres.admin,
  users.map((user) => user.id),
);
const lastSeen = signIns.orThrow().get(userId)?.lastSignInAt;
```

To put that policy on every table, set `options.policies`. `sql sync` reads
the `create table` and `drop table` statements in your declarative schema
files (your migrations only when the project has none) and writes
one `bs_session_active` policy per table in `schemas`, except the
`exclude` globs. Run it again after adding a table:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      sessions: {
        options: { policies: true, exclude: ["public.pricing_plans"] },
      },
    },
  },
});
```

Doctor reports every RLS table in `schemas` without a restrictive
`session_active()` policy as [BS320](/docs/cli/doctor#bs320), skipping the
`exclude` globs and the tables SQL modules own.

## Suspending an account [#suspending-an-account]

Banning a user in Supabase Auth refuses sign-in and refresh, but the user's
`auth.sessions` rows and refresh tokens stay, so lifting the ban revives
every refresh token that wasn't used during it. `suspendAccount` bans the
user, and lifting the ban with it ends every session first, so the user signs
in again. `endSessions` ends the sessions without a ban, for a forced sign-out
everywhere:

```ts title="src/features/admin/user-actions.ts"
"use server";

import { bs } from "@/lib/supabase/server";

export async function suspendUser(userId: string, suspended: boolean) {
  return bs.suspendAccount(userId, { suspended });
}

export async function signOutEverywhere(userId: string) {
  return bs.endSessions(userId);
}
```

```ts
// { ok: true, data: { userId, suspended: true, ended: 0 } }
```

`suspendAccount` sets the Auth ban (`duration` takes a Go duration such as
`"24h"`, and defaults to 100 years) and keeps the user's sessions. While a
session exists, Auth answers a refresh or `GET /auth/v1/user` with
`user_banned`, so [`sessionStatus`](/docs/auth/sessions) returns `"banned"`
and `bs.proxy({ endedSession })` redirects with `reason=account_banned`. Once
the sessions are deleted, Auth answers `session_not_found` instead and the app
can only report `"ended"`.

Lifting the ban (`suspended: false`) deletes the user's rows from
`auth.sessions` and `auth.refresh_tokens` first, then lifts it. `ended` counts
the sessions deleted: `0` on a suspension, and the sessions kept during the
ban when it is lifted. Both need `SUPABASE_SECRET_KEY` and the server's
`postgres`, since the Data API roles can't write `auth.sessions`; without
`postgres` they return an `invalid_request` error. `bs` from `createNext` also
drops the user's cached sessions.

Access tokens already issued stay valid until they expire. The
`session_active()` policy from [Ended sessions](#ended-sessions) refuses them
at once, because it treats a banned user's session as inactive. Without that
policy, pass `endSessions: true` to delete the sessions on suspension too; an
access token still works against the Data API until it expires, but it can no
longer be refreshed, and the app sees `"ended"` instead of `"banned"`:

```ts
await bs.suspendAccount(userId, { suspended: true, endSessions: true });
// { ok: true, data: { userId, suspended: true, ended: 3 } }
```

Apps without a better-supabase server import both from
`better-supabase/server`:

```ts
import { endSessions, suspendAccount } from "better-supabase/server";

await suspendAccount(supabaseAdmin, postgres.admin, userId, {
  suspended: true,
});
await endSessions(postgres.admin, userId);
```

## Recording account actions in the audit log [#recording-account-actions-in-the-audit-log]

Pass an `EventSink` as the server's `audit` option, such as the audit log's
`sink()`. The server methods `suspendAccount`, `deleteAccount` and
`endSessions` then send a CloudEvent after each success, and the server
forwards `support.denied` events to the same sink:

```ts title="src/lib/server.ts"
import { createAuditLog, rpcTransport } from "better-supabase/blocks/audit";
import { createServer } from "better-supabase/server";

const audit = createAuditLog({ transport: rpcTransport(supabaseAdmin) });

export const bs = createServer(betterSupabase, {
  postgres,
  audit: audit.sink(),
});
```

| Action                    | Event type                                   |
| ------------------------- | -------------------------------------------- |
| `suspendAccount` (ban)    | `dev.better-supabase.account.suspended`      |
| `suspendAccount` (lift)   | `dev.better-supabase.account.unsuspended`    |
| `deleteAccount`           | `dev.better-supabase.account.deleted`        |
| `endSessions`             | `dev.better-supabase.account.sessions_ended` |
| a refused support session | `dev.better-supabase.support.denied`         |

Each event's subject is `users/<id>` and its source is `SERVER_EVENT_SOURCE`
(`/better-supabase/server`). A failed send goes to the logger and never fails
the action. The standalone functions from `better-supabase/server` send
nothing; only the server's methods do.

## What it does [#what-it-does]

1. **Storage, listed.** For each bucket in `buckets` with an owner
   placeholder, it lists the objects that placeholder fills with the user
   id. The owner placeholder is `owner.param` for `owner`
   buckets and `{userId}` for any other template that has one. Buckets
   without one (tenant logos, say) are skipped. Put `{userId}` first in the
   path when you can: a template like `{organizationId}/{userId}/{file}` has to list the
   whole bucket to find the user's objects.
2. **Auth.** `auth.admin.deleteUser(userId)`. Postgres then applies the
   foreign keys to `auth.users`: `on delete cascade` rows go, `set null`
   columns clear.
3. **Storage, removed.** Once the user is gone, the listed objects are
   removed in batches of 1,000.
4. **Events.** A `mutation` notice for `auth.users` (`kind: 'delete'`, the
   user id as the row), then a table-wide delete notice for each table in
   `cascades`, so [cache adapters](/docs/concepts/caching) drop the reads the database
   changed behind their back. Sinks and audit forwarders see the delete too.
5. **Sessions.** `bs.deleteAccount` also calls
   `bs.invalidateSession(userId)`, so `bs.cached()` entries for the user
   are gone.

A delete the database refuses (an organization's only owner under the
organizations module's `ownerInvariant`, say) leaves the objects in place.
Auth reports such a refusal only as a generic failure, so with
`sql: postgres.admin` (which `bs.deleteAccount` passes when the server has
`postgres`) the delete is replayed in a statement that is rolled back, and
the database's own error comes back instead, with its SQLSTATE and hint
(`ORGANIZATION_OWNER_REQUIRED`). If removing objects fails after the user is
deleted, the error names the bucket; remove the rest with the bucket's
`remove`.

## Errors [#errors]

| kind           | When                                                                              |
| -------------- | --------------------------------------------------------------------------------- |
| `conflict`     | A foreign key to `auth.users` blocks the delete (`hint` points at BS406)          |
| database kinds | With `sql`, the database's own refusal, with its `hint` and `table: "auth.users"` |
| `not_found`    | No user with that id                                                              |
| `network`      | Auth or Storage is unreachable                                                    |
| Storage kinds  | A list or remove failed, with `table` set to the bucket id                        |

## Foreign keys [#foreign-keys]

A foreign key to `auth.users` without `on delete` defaults to `no action`,
and Auth answers every delete with "Database error deleting user" while such
rows exist. [`doctor`](/docs/cli/doctor#bs406) reports these keys as BS406.

```sql
-- Data that belongs to the user goes with them.
user_id uuid not null references auth.users (id) on delete cascade,
-- Shared records keep their row and lose the author.
created_by uuid references auth.users (id) on delete set null,
```

## Tokens stay valid until they expire [#tokens-stay-valid-until-they-expire]

Deleting the Auth user revokes their refresh tokens, but access tokens are
verified locally and stay valid until `exp`, one hour by default. Requests in
that window still carry the old `sub`. RLS policies on rows that were
cascaded find nothing, and a policy that joins `auth.users` or a profile
table denies them. If that window matters, shorten `jwt_expiry` or add the
`session_active()` policy from [Ended sessions](#ended-sessions) to the tables
that guard sensitive writes.

In the browser, sign out locally after the action succeeds. A normal
`signOut()` would call Auth for a user that no longer exists:

```ts
await supabase.auth.signOut({ scope: "local" });
```

## Exports and retention [#exports-and-retention]

Deletion is often the last step of a GDPR request that starts with an export.
Run the export first with `bs.admin()` (it bypasses RLS), store it where
the user can download it, then call `deleteAccount`. Rows kept for legal
reasons (invoices, audit logs) should reference the user with
`on delete set null` and keep a copy of what they need, such as the email at
the time of purchase.