Banning a user is two jobs: stop them signing in again, and end the sessions they already have. Most first versions do only the first. The spammer you just banned keeps posting from the tab they had open, and support asks why the ban "did not work".
The shape of a good ban
- A reason, for the next admin who looks at the account, and for the audit log. The user never sees it.
- A length: 1 day, 7 days, 30 days or permanent. Short bans cool people down; permanent ones are for fraud.
- Sessions ended at once, not at the next refresh if you can help it.
- An honest sentence in the dialog about when it bites for someone already signed in.
- Guards: no banning yourself, no banning another admin (remove the role first), no banning someone already banned.
- An audit entry with the reason and the expiry.
Better Auth
await auth.api.banUser({
body: { userId, banReason, banExpiresIn: 7 * 24 * 60 * 60 }, // seconds
headers: await headers(),
});
- Sets
banned,banReasonandbanExpireson the user and deletes every session the user has. - Sign-in is refused in the session-create hook with
BANNED_USER. - An expired ban is lifted lazily, the next time the user tries to sign in. A
row can therefore say
banned = truefor a ban that already ended. Treat a ban as in force only whenbannedis true andban_expiresis null or in the future, in your counts and filters too. - The cookie cache. With
session.cookieCacheon, the session is a signed snapshot in a cookie formaxAge(often 5 minutes). Until it expires, a request never touches the session table, so a deleted session keeps rendering pages. Either accept it and say "within 5 minutes" in the dialog, or read the session withdisableCookieCache: trueon the routes that matter.
Clerk
await clerk.users.updateUserMetadata(userId, {
privateMetadata: { ban: { reason, expiresAt, by: adminId } },
});
await clerk.users.banUser(userId);
banUserrevokes every session and blocks sign-in straight away. It is the most immediate of the three.- It has no reason and no expiry. Keep both in
privateMetadata(only the Backend API can read it). Write the metadata first: a stray note with no ban is harmless, a ban with no note has lost its reason. - Nothing lifts a timed ban. Run a job on a schedule that pages through users,
finds banned ones whose
privateMetadata.ban.expiresAthas passed, callsunbanUserand clears the key. Until it runs, show the ban as expired. - Clerk cannot filter users by ban state server-side, so a "banned" count past a few hundred users is a scan you cap, not a query.
Supabase Auth
await admin.auth.admin.updateUserById(userId, {
ban_duration: "168h", // or "876000h" for permanent, "none" to lift
app_metadata: { ban_reason: reason },
});
// then delete their sessions: a SECURITY DEFINER function on auth.sessions
await admin.rpc("admin_revoke_sessions", { target: userId });
ban_durationblocks sign-in and token refresh. Supabase lifts it itself when the time passes.- It does not revoke sessions. Delete the rows in
auth.sessionsyourself (through a function only the service role may call), which kills the refresh tokens. - Issued access tokens stay valid until they expire, 1 hour by default. A JWT is checked by signature, not against the database. Say "within the hour", or shorten the JWT expiry in the project settings if bans must bite faster.
- There is no "permanent". A very long duration is the convention; show anything decades away as permanent.
What the banned user sees
Send a banned session to a page that says the account is suspended and how to appeal, not to a sign-in form that fails with "invalid credentials". The sign-in error itself should say "suspended" too: people who think they typed the wrong password reset it and write angrier tickets.
Unban
The reverse call (unbanUser, ban_duration: "none"), plus clearing the
reason you stored, plus an audit entry that keeps the old reason in its
metadata. Signing back in is the user's job; do not restore old sessions.
Checking your work
- Sign in as a test user in one browser, ban them from another.
- Refresh their tab: signed out at once (Clerk), within the cache window (Better Auth), or within the token lifetime (Supabase). The dialog said which.
- Try to sign in as them: refused, with a message that says suspended.
- Try to ban yourself and another admin through the action directly: refused.
- Let a 1 day ban lapse (or set the expiry in the past): they can sign in, and the panel shows them as active.