Authorization
Gate navigation, settings tabs, and API routes by application and organization role.
Authorization runs on two role axes from @repo/config plus server guards in @repo/auth/guards. UI hiding is convenience; the real boundary is the per-route guard.
Role axes
Two independent role types, exported from @repo/config (packages/config/src/types/index.ts):
| Axis | Type | Values | Meaning |
|---|---|---|---|
UserRole | platform | user, admin | admin unlocks /admin/* |
OrgRole | per-membership | member, admin, owner | meaningful when config.tenancy.multiTenant is true |
UserRolelives on the user record; it's the platform-wide role.OrgRolelives on the membership record, scoped to the user's active organization.- The
orgRolesarrays are explicit lists, not "at least" thresholds - name every role that should pass.
Gating fields
Both SidebarNavItem (your apps/web-nuxt/app/config/sidebar.ts) and SectionTabItem (packages/config/src/section-tabs.ts) expose the same three optional gates. An item renders only when every present gate passes.
| Field | Type | Hides the item unless… |
|---|---|---|
roles | UserRole[] | the user's platform role is in the list |
orgRoles | OrgRole[] | the active membership role is in the list |
requires | SidebarFeatureFlag[] | every named feature flag resolves to enabled |
The SidebarFeatureFlag type lists five names, but the Nuxt resolver (isFeatureEnabled in AppSidebar.vue / SectionTabs.vue) only evaluates four:
| Flag | Enabled when |
|---|---|
multiTenant | config.tenancy.multiTenant === true |
notifications | config.notifications.enabled === true |
apiKeys | config.apiKeys.enabled === true |
credits | config.payment.enabled and pricingConfig.credits.enabled |
adminNotifications | always passes - no case in the resolver, so it is effectively a no-op gate |
The built-in sectionTabsConfig shows the pattern:
// packages/config/src/section-tabs.ts
admin: [
{ title: "sidebar.organizations", url: "/admin/organizations", requires: ["multiTenant"] },
{ title: "sidebar.api_keys", url: "/admin/api-keys", requires: ["apiKeys"] },
],
organization: [
{ title: "sidebar.activity", url: "/settings/organization-activity", orgRoles: ["owner", "admin"] },
],These gates control visibility only. Hiding a nav item is not access control - always enforce the role inside the API route that serves the data.
Server guards
@repo/auth/guards exports Hono middleware applied per-route on the method chain. Each reads the session and short-circuits with a JSON error:
| Guard | Requires | Rejects with |
|---|---|---|
authGuard | any signed-in session | 401 Unauthorized |
adminGuard | session.user.role === "admin" | 401 / 403 Forbidden |
orgAdminGuard | active member is owner or admin | 401 / 400 (no active member) / 403 |
authGuard and adminGuard set session on context; orgAdminGuard also sets orgId (the active organization). Read the session client-side with await authClient.useSession(ssrFetch) and gate UI inside <ClientOnly>.
For billing-specific checks, @repo/auth/authorization exports canUserPurchase(userId, billing) and canUserPurchaseForOrganization(userId, organizationId). canUserPurchase applies getBillingScope(): in "user" scope it matches the billing entity to the user; in "organization" scope it requires owner or admin membership.
Admin panel
The /admin/* area is gated entirely by the platform role on two layers:
- Page (UI): each admin page declares
definePageMeta({ middleware: "auth", roles: ["admin"] }). Theauthmiddleware redirects non-admins toconfig.routes.loginRedirect. - API (boundary): every admin route is mounted behind
adminGuard, so non-admins get403regardless of the UI. - The admin section tabs (
sectionTabsConfig.admin) render only on those already-protected pages. - Promote a user by setting their
roleto"admin"on the user record.
CAPTCHA on auth forms
When config.captcha is enabled (default off - generated projects ship { enabled: false }), the Better Auth captcha plugin protects auth forms with Cloudflare Turnstile:
| Key | Type | Description |
|---|---|---|
enabled | boolean | Master switch for the plugin |
provider | "turnstile" | The only supported provider |
siteKey | string | Public Turnstile key, embedded client-side |
- The plugin registers only when
config.captcha.enabledistrueand theTURNSTILE_SECRET_KEYenv var is set. The Better Authproviderstring is"cloudflare-turnstile". - It scopes to
/sign-up/email,/sign-in/email,/request-password-reset, and/sign-in/magic-link. apps/web-nuxt/app/components/shared/CaptchaTurnstile.vuemounts the widget; theuseCaptcha()composable exposescaptchaTokenandresetCaptcha().AuthForm.vuethreads the token via thex-captcha-responseheader.
For non-Better-Auth surfaces (the contact form), @repo/auth/captcha exports verifyCaptcha(token, ip?) - it returns true when CAPTCHA is disabled.
Frequently asked questions
Why is my admin link hidden even though I'm an admin?
The session caches the role for ~5 minutes. Sign out and back in after changing a user's role so the new session reflects it.
Do orgRoles work without organizations enabled?
Org roles are only meaningful when config.tenancy.multiTenant is true. Org-scoped tabs typically also carry requires: ["multiTenant"], so they stay hidden otherwise.
Is hiding a nav item enough to secure a page?
No - roles/orgRoles/requires only affect what renders. The matching API route must use authGuard, adminGuard, or orgAdminGuard; UI visibility is not a boundary.