Skip to content

Admin

Platform administrators are users whose role is admin. This role is independent of organization roles: an organization owner is not an admin, and an admin has no implicit membership anywhere.

The same role list drives Better Auth’s admin({ adminRoles }), the API’s requireAdmin middleware and the /admin route guard (a universal load that redirects non-admins to /app).

Terminal window
ADMIN_PASSWORD='…' bun run admin:create -- --email you@example.com --name 'You'
# add --yes to promote an account that already exists

With ADMIN_PASSWORD unset the script prompts for a password. It is never read from the command line, because arguments stay in shell history and are visible in ps. The script uses the kit’s own auth config and downloads no CLI, so it also runs inside the api container.

Promote further admins from /admin/users.

/admin/users: people (Better Auth admin plugin)

Section titled “/admin/users: people (Better Auth admin plugin)”

Search, ban and unban with a reason and an optional expiry, set role, revoke sessions, impersonate. These come from the Better Auth admin plugin at /api/auth/admin/*. Starterdough adds no procedures for them.

Impersonation sessions last one hour and cannot target other admins. While one is active the app shell shows a banner with Stop impersonating. Starting an impersonation and revoking a user’s sessions both ask for confirmation first, because neither can be undone.

Impersonate from the web app. The desktop and mobile shells hide the button: the admin’s own session waits in a cookie that a shell never gets back, so it could start an impersonation but never stop it (see Authentication).

Every organization with member and workspace counts, effective plan and current subscription (admin.organizations.list). A subscription whose seats disagrees with the member count is flagged here. Seat sync logs its failures instead of throwing, so this page is where drift is visible before the daily reconcile repairs it.

Force-cancel a subscription at period end or immediately (admin.organizations.cancelSubscription). The API asks Stripe, mirrors the answer into the local row and audits it with the admin as actor. It answers 412 when billing is not configured, 404 without a live subscription and 502 with Stripe’s message on a Stripe error.

Revoke API keys and webhooks (admin.organizations.revokeMachineAccess, POST /api/v1/admin/organizations/{organizationId}/revoke-machine-access) stops an organization’s own systems at once: it revokes every key the organization holds that is not revoked yet, and switches off every webhook endpoint that is on, as an owner’s own switch would, so a pending delivery ends without an attempt. Each one is audited in the organization’s log as api_key.revoke or webhook.disable, with the admin as the actor and by: 'platform-admin' in its metadata, and the answer counts them (apiKeysRevoked, webhooksDisabled). The page asks once more first, since a revoked key stays revoked. This is what a ban cannot do: banning a person ends their sessions, and stops the keys they created while it lasts, because they saw those keys’ secrets, but not the keys other members created or the organization’s webhooks, and an admin cannot impersonate a banned owner to revoke them. It suspends nothing: an owner or admin who is not banned can create keys and switch endpoints on again, so ban them too, or delete the organization, to keep it out.

Delete an organization (admin.organizations.delete, DELETE /api/v1/admin/organizations/{organizationId}) is for a tenant nobody owns any more. Better Auth needs an owner to promote one, and only an owner may delete an organization. It takes the organization’s slug as confirmSlug, refuses with PRECONDITION_FAILED while a live subscription exists, and reports how many stored objects went with it (objectsDeleted, objectsFailed). The page keeps the button disabled until the slug is typed out. The organization’s audit log goes with it, so the record of who deleted it is one info line in the API’s log, platform admin deleted an organization, with the organization’s id, name and slug and the admin’s user id. It lasts only as long as those logs: the default stack rotates each container’s at 10 MB × 5, and a LOG_LEVEL of warn or error drops the line. If you need that answer later, ship the API’s logs somewhere that keeps them.

Flags live in feature_flag (a global default) and feature_flag_override (per organization). Admins manage both (admin.flags.list | upsert | delete | setOverride). Clients read the resolved map for an organization from system.flags (GET /api/v1/flags?organizationId=…).

In the app, the (app) layout load fetches the flags for the active organization once per session. Components read flag('key') from #lib/flags.js. An unreachable API means “nothing is on”.

Keys are stable slugs used literally in code: lowercase letters, numbers, dots, dashes and underscores.

admin.system.status reports your product’s version and the kit release (version and kitVersion in the root package.json), the environment and runtime, uptime, counts (users, organizations, workspaces, entitling subscriptions), a database probe with the state of both migration histories (the journals shipped in the build vs. drizzle.__drizzle_migrations for the kit’s and drizzle.__product_migrations for yours), the AI service’s /health, and which optional subsystems are configured: billing (with any unpriced plans), the email provider and sender, social sign-in and email verification. Never secrets.

The AI service answers /health with HTTP 200 and ok: false when its configuration cannot serve a request (no token check, or an OCR backend that cannot run there). It lists one sentence per fault in problems, which this page shows under a Degraded label. It is not a 503: the container health check asserts liveness only, and a 503 would mark the container unhealthy and fail every deploy, which waits for healthy, over its environment.

  • Admin actions on users are not in the per-organization audit log (they have no tenant). Rely on Better Auth’s request logging or add a global log when needed.
  • An action an admin takes while impersonating is audited in that organization’s log with the impersonated user as the actor and the admin’s id in impersonated_by.
  • Flag reads are one indexed query per page load with no caching. Add a short cache when flags are read on hot paths.
  • Deleting users is not exposed in the UI.