Account deletion
Delete or suspend a user, end their sessions, remove their Storage objects, and keep foreign keys from blocking a delete.
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.
"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"],
});
},
);// { 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:
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
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:
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:
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:
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:
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, skipping the
exclude globs and the tables SQL modules own.
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:
"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);
}// { 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 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 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":
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:
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
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:
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
- Storage, listed. For each bucket in
bucketswith an owner placeholder, it lists the objects that placeholder fills with the user id. The owner placeholder isowner.paramforownerbuckets 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. - Auth.
auth.admin.deleteUser(userId). Postgres then applies the foreign keys toauth.users:on delete cascaderows go,set nullcolumns clear. - Storage, removed. Once the user is gone, the listed objects are removed in batches of 1,000.
- Events. A
mutationnotice forauth.users(kind: 'delete', the user id as the row), then a table-wide delete notice for each table incascades, so cache adapters drop the reads the database changed behind their back. Sinks and audit forwarders see the delete too. - Sessions.
bs.deleteAccountalso callsbs.invalidateSession(userId), sobs.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
| 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
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 reports these keys as BS406.
-- 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
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 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:
await supabase.auth.signOut({ scope: "local" });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.
Last updated on