Roles and permissions

How ShipKit decides who can do what in the admin console — permissions, roles, the built-in admin, and how to guard a new page or server function.

Access to the admin console is role-based. A permission is a dotted name such as users.read or billing.write. A role is a named set of permissions. Every user holds at most one role, stored as a name in the user.role column that better-auth's admin plugin already maintains.

Code never asks "is this person an admin?". It asks "does this person hold billing.read?", and the same question is asked in every place that matters:

WhereHowWhat it decides
Sidebarthe row's permission in adminNavwhether the tab is shown
RouterequirePermission(queryClient, '<perm>') in beforeLoadwhether the page renders
Server functionawait assertPermission(context.session, '<perm>')whether the call runs
Admin APIadminApi(handler, '<perm>')whether the key gets data

The route guard runs in the browser too, so it is a convenience that keeps a page from half-rendering. The server function and the API check are the real boundary, and they re-check on every call.

Built-in roles

Two roles live in code rather than in the database:

  • admin holds *, which matches every permission. It appears on the roles page but cannot be edited or deleted.
  • No role (a null column, or the plain user value better-auth writes) holds nothing. That person uses the app normally and cannot open the console.

They are built in so that no edit on the roles page can leave the database with nobody able to get back in. Everything between those two ends is a row in the role table (src/core/authz/schema.ts). A role name that no longer exists grants nothing, so a deleted role fails closed.

To make yourself the first admin, set your own row by hand after signing in — see Getting started.

Creating a role

Open Admin → Settings → Roles (/admin/roles). The editor shows every declared permission as a checkbox, grouped by feature. Saving goes through saveRoleAdminFn in src/core/authz/fns.ts, which enforces a few rules:

  • The name is an identifier: lowercase, starting with a letter, then letters, digits, _ or -, at most 40 characters. admin is reserved.
  • Every grant must name a permission some feature declared. A typo is refused instead of sitting in the table looking like access.
  • * is refused. Full access is the built-in admin role only.
  • A role that users still hold cannot be deleted.

Saves and deletes are written to the audit log as roles.saved and roles.deleted, with the grants in the entry.

Every role that can open the console needs admin.access, the floor that every console page and admin server function requires before anything more specific. A read-only support role might hold admin.access, users.read and billing.read.

Treat roles.write as equivalent to full access: anyone who can edit roles can give themselves any permission.

Wildcards

A grant can end in .* to cover a whole area: billing.* covers billing.read and billing.write. The wildcard is only ever a whole trailing segment, so use* matches nothing (src/core/authz/match.ts). The matcher and the save validation accept wildcards, but the editor itself works with individual checkboxes. The public demo's demo role is an example of a role built in code: admin.access plus every *.read permission, derived from the registry (src/features/demo/config.ts).

Assigning a role

Open a user from any console table (it opens the user drawer) and pick a role from the select next to Role. The list is the built-in admin plus every row on the roles page. The change is written to the audit log as admin.role_set.

One limitation: changing a role and banning a user go through better-auth's admin plugin endpoints, and that plugin, in its default configuration, only lets the built-in admin role call them. A custom role holding users.write sees the controls but the change is refused. Give role changes and bans to admins only, or configure the plugin's own access control in src/features/auth/server/auth.ts if you need to delegate them.

Checking a permission

A console route:

export const Route = createFileRoute('/_app/admin/reports')({
  beforeLoad: async ({ context }) => {
    await requirePermission(context.queryClient, 'reports.read')
  },
  component: ReportsPage,
})

A server function starts from adminFn (which already requires admin.access) and narrows inside the handler:

import { assertPermission } from '@/core/authz/assert'
import { adminFn } from '@/core/server/fn'

export const listReportsAdminFn = adminFn.handler(async ({ context }) => {
  await assertPermission(context.session, 'reports.read')
  // …query and return
})

Both redirect to /dashboard rather than showing a 403, so someone whose role changed lands on a page they can use. The admin API returns a real 403 instead — see API keys.

Inside a component, useCan('<perm>') from src/core/authz/queries.ts decides whether to show a control. It returns false while loading. Use it to hide buttons, never as the only check.

Two things not to do. Do not put a helper that touches the database in src/core/server/fn.ts: that file is bundled for the client too, and the build only strips what sits inside a .server() callback. That is why assertPermission lives in src/core/authz/assert.ts. And do not wrap createServerFn in a factory such as permissionFn(perm): the build cannot see through the call and ships the whole module to the browser.

Adding a permission

Permissions are declared by the feature that enforces them, in a client-safe src/features/<name>/permissions.ts:

import { registerPermission } from '@/core/authz/events'
import { m } from '@/paraglide/messages'

registerPermission({
  key: 'reports.read',
  group: 'reports',
  groupLabel: () => m.admin_reports_title(),
  label: () => m.perm_read(),
})

Then add one import line to src/core/authz/handlers.ts. The role editor picks it up with no other change, and deleting the feature removes its permissions from the editor along with that line.

Rules for a declaration: the key is <area>.<action> in lowercase, it may not contain *, and the file imports nothing server-only (no ./server/, no @/core/db), because the role editor renders the registry in the browser. Reads conventionally use <area>.read and changes <area>.write.

The core permissions, which exist whatever features you keep, are declared in src/core/authz/permissions.ts: admin.access, users.read, users.write, roles.write, settings.read, settings.write, api_keys.manage and email.read.

Changes take up to a minute

Role grants are read on every guarded request, so they are cached per Worker isolate for 60 seconds (src/core/authz/store.ts). A role edit applies at once on the isolate that made it and within a minute everywhere else. The user's role name itself is read fresh with the session on each call, so moving someone to no role, or banning them, takes effect at once. It is editing what a role grants that can lag.

The tests that enforce it

src/core/authz/registry.test.ts runs with bun run test and fails when:

  • a route file under src/routes/_app/admin/ does not call requirePermission(, or still contains a role !== 'admin' check;
  • an export const … = adminFn anywhere in src/ does not call await assertPermission(context.session, …);
  • a features/*/permissions.ts is missing from src/core/authz/handlers.ts, or imports something server-only;
  • a key is malformed, duplicated or unlocalized;
  • src/core/server/fn.ts exports a plain function or touches the database outside a .server() callback.

These catch the usual mistakes, from a person or an agent, before they ship. To add a console page end to end, see Admin console.