Authorization & roles
Gate access with the @repo/auth role axes, server guards, and config-driven nav filters.
@repo/auth enforces access through two independent role axes plus server-side Hono guards. Nav metadata only hides unreachable UI; the real boundary is always a guard on the route.
Two role axes
Both axes live in packages/config/src/types/roles.ts (the userRoles / orgRoles consts plus their UserRole / OrgRole types) and are re-exported from @repo/config.
| Axis | Type | Values | Scope |
|---|---|---|---|
UserRole | platform | "user" | "admin" | Global; admin unlocks the admin panel |
OrgRole | per-organization | "member" | "admin" | "owner" | Meaningful only when config.tenancy.multiTenant is true |
The two are orthogonal - a platform user can be an org owner, and a platform admin is not automatically an org admin. See Organizations.
Server guards (the boundary)
@repo/auth/guards exports Hono middleware applied per-route on the method chain. These are the only thing access actually depends on.
| Guard | Requires | On fail | Sets on context |
|---|---|---|---|
authGuard | Signed-in user | 401 Unauthorized | session |
adminGuard | session.user.role === "admin" | 401, then 403 Forbidden | session |
orgAdminGuard | Active member is owner or admin | 401 / 400 (no active member) / 403 | session, orgId |
// packages/api - protect a route on the chain
app
.get("/me", authGuard, (c) => c.json(c.get("session").user))
.post("/admin/users", adminGuard, handler)
.delete("/org/members/:id", orgAdminGuard, (c) => remove(c.get("orgId")));Purchases use a check instead of a guard: canUserPurchase(userId, billing) (packages/auth/src/authorization.ts) returns true only when the billing account is the caller's own. Money lives on users and an organization is funded by its owner, so the owner is its only member who can check out.
canUserPurchase is synchronous and takes Pick<BillingAccount, "entityId">, so you can pass anything carrying an entityId rather than a full account.
Config-driven nav filters
Sidebar items (apps/web/config/sidebar.ts) and section tabs (packages/config/src/section-tabs.ts) carry optional filters hiding entries a user can't reach. This removes a dead link; it does not protect the page behind it.
| Field | Type | Hides the entry unless… |
|---|---|---|
roles | UserRole[] | The user has one of these platform roles |
orgRoles | OrgRole[] | The active member has one of these org roles |
requires | SidebarFeatureFlag[] | All listed feature flags are enabled |
SidebarFeatureFlag (packages/config/src/types/navigation.ts) has twelve members, each mapped to a config.* check by isSidebarFeatureEnabled (@repo/config/sidebar-flags, re-exported as apps/web/lib/sidebar-flags.ts): multiTenant, projects, notifications, apiKeys, credits, agents, ai, aiByok, aiModels, runner, mcpServer, emailTracking. See Navigation for what each gate checks.
Admin panel gating
The admin route group is gated server-side, not just by the hidden sidebar link.
app/[locale]/(dashboard)/admin/layout.tsx) reads the session in a server component and redirects non-admin users to config.routes.loginRedirect.adminGuard, returning 403 to any non-admin caller.sectionTabsConfig.admin) render only the tabs whose requires flags are on.Read the role with session.user.role from a server component, or useSession() in a client component - see Data fetching.
CAPTCHA
config.captcha (default { enabled: false }) puts Cloudflare Turnstile in front of /sign-up/email, /sign-in/email, /request-password-reset, and /sign-in/magic-link - orthogonal to roles, but the same perimeter.
The Better Auth captcha plugin registers only when config.captcha.enabled is true and TURNSTILE_SECRET_KEY is set. Enable one without the other and forms render no widget and stay unprotected - see CAPTCHA and Environment variables.
Frequently asked questions
Can I rely on hidden sidebar items for security?
No. Filters (roles/orgRoles/requires) only declutter the UI. Every protected route must still carry a server guard.
How do I add a third platform role?
Extend the userRoles const in packages/config/src/types/roles.ts, then branch on it in your guards or handlers. Better Auth stores the value in user.role.
Does a platform admin automatically manage every organization?
No. adminGuard and orgAdminGuard are separate checks - org actions need an owner/admin membership the platform role does not grant.
Why is a role change not taking effect?
The session caches the role for 5 minutes (cookieCache.maxAge). Sign out and back in after changing a user's role.