# 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.

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

```ts
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

```ts
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

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
