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; @repo/config nav metadata only hides unreachable UI, never enforces it. The real boundary is always a guard on the route.
Two role axes
Both axes are defined in packages/config/src/types/index.ts (the userRoles / orgRoles consts plus their UserRole / OrgRole types) and 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 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 dedicated check instead of a guard: canUserPurchase(userId, billing) (packages/auth/src/authorization.ts) returns true when getBillingScope() is "user" and the account is the user's own, otherwise when the user is an owner/admin member of the billing org.
Config-driven nav filters
Sidebar items (apps/web-next/config/sidebar.ts) and section tabs (packages/config/src/section-tabs.ts) carry optional filters that hide entries a user can't reach. This is convenience only - it 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 has eleven members, resolved by the app's isSidebarFeatureEnabled helper (apps/web-next/lib/sidebar-flags.ts): nine map to a config.* check (multiTenant, notifications, apiKeys, credits, ai, aiByok, companion, mcpServer, emailTracking), while adminNotifications and agents have no resolver case and always pass. 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 (a discriminated union, default { enabled: false }) protects auth forms from bots via Cloudflare Turnstile - orthogonal to roles but part of the same auth perimeter. Turn it on by setting the enabled shape { enabled: true, provider: "turnstile", siteKey: "…" }.
- The Better Auth
captchaplugin registers only whenconfig.captcha.enabledistrueandenv.TURNSTILE_SECRET_KEYis set. - Enforcement scopes to
/sign-up/email,/sign-in/email,/request-password-reset, and/sign-in/magic-link. - Forms mount the
<Turnstile>widget (next-turnstile) viauseCaptcha(apps/web-next/hooks/use-captcha.ts), threading the token through thex-captcha-responseheader. verifyCaptcha(token, ip)is exported for non-Better-Auth surfaces (e.g. the contact form).
Setting config.captcha.enabled: true without TURNSTILE_SECRET_KEY silently leaves the plugin unregistered - forms render no widget and stay unprotected. Set both. See 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/index.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 require an owner/admin membership, which the platform role does not grant.