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 is one of multiTenant, notifications, apiKeys, credits, adminNotifications. Example: the admin Organizations tab is requires: ["multiTenant"], and org Activity is orgRoles: ["owner", "admin"].
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.