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 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.

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