Skip to content

Bans that actually sign people out

Setting banned = true stops the next sign-in, not the session already open. What Better Auth, Clerk and Supabase do on a ban, where caches and tokens let a banned user linger, and how to say so.

Admin panel4 min readships at docs/solutions/admin-panel/bans-and-session-revocation.md

Tags: admin · bans · sessions · security · better-auth · clerk · supabase

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, banReason and banExpires on 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 = true for a ban that already ended. Treat a ban as in force only when banned is true and ban_expires is null or in the future, in your counts and filters too.
  • The cookie cache. With session.cookieCache on, the session is a signed snapshot in a cookie for maxAge (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 with disableCookieCache: true on the routes that matter.

Clerk

await clerk.users.updateUserMetadata(userId, {
  privateMetadata: { ban: { reason, expiresAt, by: adminId } },
});
await clerk.users.banUser(userId);
  • banUser revokes 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.expiresAt has passed, calls unbanUser and 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_duration blocks sign-in and token refresh. Supabase lifts it itself when the time passes.
  • It does not revoke sessions. Delete the rows in auth.sessions yourself (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.