GenerateSaaS

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):

AxisTypeValuesMeaning
UserRoleplatformuser, adminadmin unlocks /admin/*
OrgRoleper-membershipmember, admin, ownermeaningful when config.tenancy.multiTenant is true
  • UserRole lives on the user record; it's the platform-wide role.
  • OrgRole lives on the membership record, scoped to the user's active organization.
  • The orgRoles arrays 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.

FieldTypeHides the item unless…
rolesUserRole[]the user's platform role is in the list
orgRolesOrgRole[]the active membership role is in the list
requiresSidebarFeatureFlag[]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:

FlagEnabled when
multiTenantconfig.tenancy.multiTenant === true
notificationsconfig.notifications.enabled === true
apiKeysconfig.apiKeys.enabled === true
creditsconfig.payment.enabled and pricingConfig.credits.enabled
adminNotificationsalways 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:

GuardRequiresRejects with
authGuardany signed-in session401 Unauthorized
adminGuardsession.user.role === "admin"401 / 403 Forbidden
orgAdminGuardactive member is owner or admin401 / 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"] }). The auth middleware redirects non-admins to config.routes.loginRedirect.
  • API (boundary): every admin route is mounted behind adminGuard, so non-admins get 403 regardless of the UI.
  • The admin section tabs (sectionTabsConfig.admin) render only on those already-protected pages.
  • Promote a user by setting their role to "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:

KeyTypeDescription
enabledbooleanMaster switch for the plugin
provider"turnstile"The only supported provider
siteKeystringPublic Turnstile key, embedded client-side
  • The plugin registers only when config.captcha.enabled is true and the TURNSTILE_SECRET_KEY env var is set. The Better Auth provider string 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.vue mounts the widget; the useCaptcha() composable exposes captchaToken and resetCaptcha(). AuthForm.vue threads the token via the x-captcha-response header.

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.

On this page