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. Nav metadata only hides unreachable UI; the real boundary is always a guard on the route.

Two role axes

Both axes live in packages/config/src/types/roles.ts (the userRoles / orgRoles consts plus their UserRole / OrgRole types) and are 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 active 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 check instead of a guard: canUserPurchase(userId, billing) (packages/auth/src/authorization.ts) returns true only when the billing account is the caller's own. Money lives on users and an organization is funded by its owner, so the owner is its only member who can check out.

canUserPurchase is synchronous and takes Pick<BillingAccount, "entityId">, so you can pass anything carrying an entityId rather than a full account.

Config-driven nav filters

Sidebar items (apps/web/config/sidebar.ts) and section tabs (packages/config/src/section-tabs.ts) carry optional filters hiding entries a user can't reach. This 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 (packages/config/src/types/navigation.ts) has twelve members, each mapped to a config.* check by isSidebarFeatureEnabled (@repo/config/sidebar-flags, re-exported as apps/web/lib/sidebar-flags.ts): multiTenant, projects, notifications, apiKeys, credits, agents, ai, aiByok, aiModels, runner, mcpServer, emailTracking. 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 (default { enabled: false }) puts Cloudflare Turnstile in front of /sign-up/email, /sign-in/email, /request-password-reset, and /sign-in/magic-link - orthogonal to roles, but the same perimeter.

The Better Auth captcha plugin registers only when config.captcha.enabled is true and TURNSTILE_SECRET_KEY is set. Enable one without the other and forms render no widget and stay unprotected - see CAPTCHA and 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/roles.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 need an owner/admin membership the platform role does not grant.

Why is a role change not taking effect? The session caches the role for 5 minutes (cookieCache.maxAge). Sign out and back in after changing a user's role.

On this page