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:
| Where | How | What it decides |
|---|---|---|
| Sidebar | the row's permission in adminNav | whether the tab is shown |
| Route | requirePermission(queryClient, '<perm>') in beforeLoad | whether the page renders |
| Server function | await assertPermission(context.session, '<perm>') | whether the call runs |
| Admin API | adminApi(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:
adminholds*, which matches every permission. It appears on the roles page but cannot be edited or deleted.- No role (a null column, or the plain
uservalue 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.adminis 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-inadminrole 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 callrequirePermission(, or still contains arole !== 'admin'check; - an
export const … = adminFnanywhere insrc/does not callawait assertPermission(context.session, …); - a
features/*/permissions.tsis missing fromsrc/core/authz/handlers.ts, or imports something server-only; - a key is malformed, duplicated or unlocalized;
src/core/server/fn.tsexports 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.