GenerateSaaS

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:

AxisTypeValuesScope
UserRoleplatform"user" | "admin"Global; admin unlocks the admin panel
OrgRoleper-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.

GuardRequiresOn failSets on context
authGuardSigned-in user401 Unauthorizedsession
adminGuardsession.user.role === "admin"401, then 403 Forbiddensession
orgAdminGuardActive member is owner or admin401 / 400 (no member) / 403session, 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.

FieldTypeHides the entry unless…
rolesUserRole[]The user has one of these platform roles
orgRolesOrgRole[]The active member has one of these org roles
requiresSidebarFeatureFlag[]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.

The admin layout (app/[locale]/(dashboard)/admin/layout.tsx) reads the session in a server component and redirects non-admin users to config.routes.loginRedirect.
Admin API routes are wrapped in adminGuard, returning 403 to any non-admin caller.
Admin section tabs (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 captcha plugin registers only when config.captcha.enabled is true and env.TURNSTILE_SECRET_KEY is 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) via useCaptcha (apps/web-next/hooks/use-captcha.ts), threading the token through the x-captcha-response header.
  • 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.

On this page