Keel

Docs / Admin panel / Admin access

Admin access

The admin panel under /admin/* is a separate surface from the user dashboard, gated by one global role: superadmin. This is a completely different axis from the per-team owner / admin / member roles, a team owner has no admin-panel access, and a superadmin is not automatically privileged inside any given team. The role column on user (set via Better Auth's admin plugin in lib/auth.ts) holds it; SUPERADMIN_ROLE in lib/dal/admin.ts is the constant.

The gate

Every /api/admin/* route calls requireSuperadmin() first, the admin-panel counterpart to authorizeTeamAccess:

ts
const access = await requireSuperadmin();
if (!access.ok) return adminAccessErrorResponse(access);
// access.userId is the acting superadmin from here on

It returns 401 for no session and 403 for a signed-in non-superadmin. The split matters: a 401 tells the client to redirect to sign-in, a 403 is a real "you're logged in but not allowed", the page-level admin routes behave the same way (unauthenticated redirects, authenticated-but-unauthorized hits a 403 page).

Every action is audited

Admin actions that touch a user's account write one row to audit_log through logAdminAction:

ts
await logAdminAction({
  actorUserId: access.userId,
  targetUserId: id,
  action: "user.impersonated",
});

The user-detail Activity tab reads these back scoped to targetUserId, so each account carries its own moderation history. Write the audit row only after the underlying action succeeds, logging it before (or unconditionally) records a false trail when the action was actually rejected.

Superadmins can't act on each other

Better Auth blocks impersonating another superadmin, but banUser and delete have no equivalent check. isSuperadminUser(id) in lib/dal/admin-users.ts is the rank guard that closes that gap, without it a rogue or compromised superadmin could ban or delete every other superadmin. Any destructive admin action against an account must call it first.

Guards on Better Auth's admin endpoints

The admin plugin's own endpoints (/api/auth/admin/*) are guarded too, in authGuardHook in lib/auth.ts, so the rules hold even for code that calls auth.api.* directly:

  • Browser calls are refused. authClient.admin.* requests from the client get a 403. Those endpoints don't write audit_log rows, so every admin action goes through a wrapped /api/admin/* route instead. If you need a new one, add a route that calls auth.api.* server-side and logs it.
  • No acting on another superadmin. Any write (set-role, update-user, ban-user, set-user-password, revoke-user-session(s), remove-user, impersonate-user) aimed at a different superadmin is rejected.
  • There's always one superadmin. Demoting yourself (set-role, or update-user with a role) and deleting your own account both fail if you're the last one.
  • Impersonation is read-mostly. While impersonating, the target's sign-in details are locked: changing email or password, revoking sessions, unlinking logins, and deleting the account all return 403.

lib/admin-nav.ts is the single source for the admin sidebar, command palette, and breadcrumbs (same role lib/app-nav.ts plays for the dashboard), listing the three admin routes: Users, Subscriptions, and Settings. See Users & moderation and Feature flags & settings for what each does.