Supabase User Management: 5 Server Actions and the JWT That Outlives a Deleted User
5 real Server Actions, an admin-client rule, and the one line that stops a deleted user's still-valid JWT from staying logged in.
Supabase user management is more than supabase.auth calls — it's what happens around them, and it is emphatically not what CRM means, which is the other thing "managing users" gets taken for. This storefront's account settings run 5 Server Actions (profile, email, password, delete, resend-verification), a Postgres CHECK constraint as a second line of defense, and one explicit line that closes a real gap: a deleted user's JWT keeps verifying locally, unless you sign them out yourself.
The five actions
| Action | What it changes | Client used | Why |
|---|---|---|---|
updateProfile | profiles.display_name | Admin client | profiles has no write policy for authenticated — every write goes through the service role, scoped to the caller's own verified id |
updateEmail | auth.users.email | Session client | supabase.auth.updateUser() operates on "the currently authenticated user" — there's no user-id parameter to spoof |
updatePassword | auth.users password | Session client | Same "no id parameter" guarantee as email |
deleteAccount | Deletes the auth user | Admin client | Requires auth.admin.deleteUser(), an admin-only operation |
resendVerificationEmail | Triggers a signup-confirmation resend | Session client | Reuses Supabase Auth's own per-project rate limit rather than a bespoke one |
Two different clients, on purpose. src/lib/supabase/server.ts's session client can only act as the signed-in user — asking it to update someone else's row simply fails RLS. The admin client bypasses RLS entirely, so every admin-client call in account.ts first re-derives the caller's own id from a verified session and only then acts — never from a client-supplied value:
// src/lib/actions/account.ts
export async function updateProfile(_prev: AccountState, formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, message: "Please sign in." };
const raw = String(formData.get("display_name") ?? "").trim();
const displayName = raw.length === 0 ? null : raw;
if (displayName !== null && !isValidDisplayName(displayName)) {
return { ok: false, message: "Names can only use letters, numbers, spaces, and - ' . (max 40 characters)." };
}
const admin = createAdminClient();
const { error } = await admin
.from("profiles")
.upsert({ id: user.id, display_name: displayName }, { onConflict: "id" });
// …
}
user.id comes from getUser(), resolved server-side from the verified session cookie — never from formData. That's the whole trick to using an RLS-bypassing client safely: the row it's allowed to touch is decided by code, not by policy, so the code has to get it right every single time. updateEmail and updatePassword sidestep the question entirely by using the session client and Supabase's updateUser(), which only ever targets "yourself."
Validation is enforced twice, once in SQL
The display_name field is checked in the Server Action (isValidDisplayName) and again by a Postgres constraint that doesn't trust the app layer to be the only writer:
-- supabase/migrations/0002_display_name_allowlist.sql
alter table public.profiles drop constraint if exists profiles_display_name_check;
alter table public.profiles add constraint profiles_display_name_check
check (display_name is null or display_name ~ '^[[:alnum:] _''.-]{1,40}$');
POSIX [[:alnum:]] is Unicode-aware in a UTF-8 database, so this allowlist accepts José, 山田太郎, and Mary-Jane O'Brien, while rejecting <, &, and ". The comment in the migration is explicit about why this exists even though the Server Action already validates: CHECK constraints apply to service-role writes too — only Row Level Security is bypassed by the admin client, not table constraints. If a future code path writes to profiles through the admin client without calling isValidDisplayName first, the database itself still refuses the bad value. That's the actual purpose of defense-in-depth: not redundancy for its own sake, but a backstop that survives a mistake in the layer above it.
The defect: a JWT that outlives the account it belonged to
deleteAccount is the one action with a line that isn't obvious until you know the bug it prevents:
export async function deleteAccount(_prev: AccountState, formData: FormData) {
const confirm = String(formData.get("confirm") ?? "");
if (confirm !== "DELETE") {
return { ok: false, message: 'Type "DELETE" to confirm.' };
}
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false, message: "Please sign in." };
const admin = createAdminClient();
const { error } = await admin.auth.admin.deleteUser(user.id);
if (error) { /* … */ }
// The deleted user's JWT can otherwise keep verifying locally until it
// expires (JWKS verification doesn't re-check the user still exists) —
// clear the session cookie explicitly so this browser is signed out now.
await supabase.auth.signOut();
redirect("/");
}
Supabase's getClaims() (this codebase's standard auth check — see dashboard/layout.tsx) verifies a JWT's signature against Supabase's published JWKS. That check answers "was this token issued by our project and is it unexpired," not "does this user still exist." Delete the auth.users row without also clearing the browser's session cookie, and the just-deleted account's existing JWT keeps passing every getClaims() check on the dashboard for the rest of its lifetime — the app would keep treating a deleted account as logged in until the token's own expiry, independent of the deletion. signOut() closes that gap by clearing the cookie at the moment of deletion, rather than waiting on token expiry to do it. It's a one-line fix, but only once you know verification is against the token's signature, not a live row.
What happens to the data that isn't deleted
Deleting the auth user doesn't erase the account's history — it anonymizes it, per a separate migration:
| Table | On user delete |
|---|---|
profiles | Cascades away entirely |
orders.user_id | Set null — the order row survives for accounting/tax records |
entitlement_slots.user_id | Set null — anonymized |
download_events.user_id | Set null — the download audit trail survives for abuse detection |
That split — cascade the identity, null the foreign key on everything with a compliance or abuse-detection reason to persist — is the actual "user management" decision behind the delete button, and it's why deleteAccount doesn't just run one DELETE FROM auth.users and call it done: the database's own foreign-key actions (set in supabase/migrations/0005_retain_anonymized_download_audit.sql) do the rest. That migration is one of the four times this database schema had to change, each for a reason the current structure alone cannot tell you.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Deleted user still appears "logged in" in the same browser tab | getClaims() verifies the JWT signature, not whether the user row still exists | Call supabase.auth.signOut() explicitly right after admin.auth.admin.deleteUser() |
| A profile write silently fails for every user | profiles has no authenticated write policy by design (RLS deny-by-default) | Write through the admin/service-role client, scoped to the session's own verified user.id |
| A user can update someone else's profile | The row id came from client-supplied input instead of the verified session | Always resolve the id from getUser()/getClaims() server-side; never trust a form field or query param for it |
| A display name with an emoji or accented character gets rejected | The CHECK constraint's regex isn't Unicode-aware in that column's encoding, or the allowlist is too strict | Confirm the database is UTF-8 and the class is POSIX [[:alnum:]], which is Unicode-aware under UTF-8 |
| Orders disappear entirely after a customer deletes their account | Cascading orders on user delete instead of nulling the foreign key | Use ON DELETE SET NULL for compliance-relevant tables, not ON DELETE CASCADE |
Frequently asked questions
Why does updateProfile use the admin client instead of just adding an RLS write policy?
Because profiles has zero INSERT/UPDATE policies for authenticated by design — every write on this schema goes through a narrow, auditable path (a Server Action or a SECURITY DEFINER RPC) rather than a general "users can write their own row" policy, which is easy to get subtly wrong (a malformed USING/WITH CHECK clause). The admin client bypasses RLS but the Server Action itself enforces "only your own row" by sourcing the id from the verified session, never from input.
Does deleting a Supabase Auth user also delete their data everywhere?
Not necessarily, and by design here it doesn't: profiles cascades away, but orders, entitlement_slots, and download_events keep their rows with user_id set to null — the account's identity is gone, the transactional history it left behind persists for accounting and abuse-detection reasons.
Is checking display_name in the app enough, or do I need the database constraint too?
If every write to that column goes through code you control and always will, app-layer validation alone works. The CHECK constraint exists because "always will" is a promise about the future, and a service-role client — used precisely because it bypasses RLS — also bypasses any assumption that only the validated code path ever writes there.
Why is updateEmail on the session client but updateProfile is on the admin client?
supabase.auth.updateUser({ email }) is inherently scoped to "the currently authenticated user" by Supabase Auth itself — there's no id parameter to pass, so the session client is already exactly as safe as it needs to be. profiles is an application table with no such built-in scoping, so writing to it needs either an RLS policy or a service-role client with the scoping done in code; this schema chose the latter.
Templates in this post
ASoc Folio (a developer portfolio site), ASoc Forge (an AI resume-builder landing page) and ASoc Frame (an AI image-generator product site) are all built on the same Next.js + Supabase foundation as this storefront's own account settings.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the auth check these actions build on, see Supabase Auth in Next.js 16; for what happens when a write reaches the database without going through one of these actions, the RLS policy this schema ships zero INSERT rules for.
