Straktur
Authentication

Roles & Permissions (RBAC)

Level-based role system with helpers for nav visibility, permission checks, and client-side access

Roles & Permissions (RBAC)

TL;DR: Four built-in roles ranked by level. Use hasMinRole() on the server, useSessionRole() in the client, and minRole on nav items. Enforcement still happens server-side — client-side checks are for UX only.

The Four Roles

RoleLevelTypical capabilities
owner100Full access, can delete the organization
admin80Manage org settings, members, dictionaries
member50Regular day-to-day work
viewer10Read-only access

Roles are ordered by level. Any check is "at least this role" — admin passes checks for member and viewer, but not for owner.

Defined in src/lib/auth/roles.ts. The set is intentionally small — add a fifth role only if you have a real use case that doesn't fit.


Server-Side Checks (hasMinRole)

Every oRPC router that mutates state should enforce a minimum role. Pull the current role from context.membership and compare:

import { hasMinRole } from "@/lib/auth/roles"

export const deleteOrganization = orpc
  .use(authMiddleware)
  .handler(async ({ context }) => {
    if (!hasMinRole(context.membership.role, "owner")) {
      throw new ORPCError("FORBIDDEN", { message: "Only owners can delete the organization" })
    }
    // ... perform the delete
  })

This is the only layer that actually protects data. Treat everything below as UX sugar.


Client-Side Role (useSessionRole)

For conditional UI — hiding buttons, disabling actions, or routing decisions — use the useSessionRole() hook. It reads from the SessionProvider that wraps the dashboard layout, so it's available in every dashboard page without prop drilling.

"use client"

import { useSessionRole } from "@/components/providers/session-provider"
import { hasMinRole } from "@/lib/auth/roles"

export function DangerZone() {
  const role = useSessionRole()
  const canDelete = role && hasMinRole(role, "admin")

  if (!canDelete) return null
  return <Button variant="destructive">Delete</Button>
}

Hiding a button is not a security control. Always enforce the same rule in the oRPC handler. A motivated user can call the endpoint directly from the browser console.

Why a Provider Instead of Prop Drilling

Before SessionProvider, any component that needed the role had to receive it as a prop — often from a server component five levels up. With the provider:

  • Set once in the dashboard layout
  • Available anywhere in the dashboard tree
  • No re-renders when unrelated session fields change

The provider exposes only the role ({ role }). If you need the full session elsewhere, use better-auth's useSession() hook — don't widen the provider unnecessarily.


Hiding Nav Items (minRole)

Add minRole to any NavItem to hide it from users below that level:

// src/lib/config/navigation.ts
{
  label: "settings",
  href: "/settings",
  icon: Settings,
  minRole: "admin",
}

getMainNavItems(role) applies the filter when called with a role. The sidebar and the command palette both already pass the session role, so adding minRole to a nav item hides it from both — no extra wiring needed.

See Customize Navigation for the full setup.


Redirects in proxy.ts

For route-level guards (e.g., /settings/billing should be admin-only), check the session in src/proxy.ts and redirect. This is Next.js 16's replacement for middleware.ts.

// src/proxy.ts
import { hasMinRole } from "@/lib/auth/roles"

export async function proxy(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/settings/billing")) {
    const session = await getSession(request)
    if (!session || !hasMinRole(session.membership.role, "admin")) {
      return NextResponse.redirect(new URL("/", request.url))
    }
  }
}

Like useSessionRole, this is UX — the oRPC handlers behind the page still need their own checks.


Defense in Depth

Three layers, in order of authority:

  1. oRPC handler — authoritative. Throws FORBIDDEN if the role check fails.
  2. proxy.ts — redirects the user away from pages they can't use, so they don't see a broken empty state.
  3. Client UIuseSessionRole() / minRole hide controls the user couldn't use anyway, reducing confusion.

A request that bypasses all three still fails at layer 1. Drop any of 2 or 3 and the app gets uglier, not insecure.


Common Mistakes

Don't use role === "admin" for checks:

// ❌ Wrong — breaks when you add a higher role (e.g. "superadmin")
if (context.membership.role === "admin") { ... }

// ✅ Correct — matches "admin" and every role above
if (hasMinRole(context.membership.role, "admin")) { ... }

Don't rely on minRole for security:

// ❌ Wrong — hides the nav link, but anyone with the URL can still call the handler
{ label: "settings", href: "/settings", icon: Settings, minRole: "admin" }
// ...with no guard in the router

Always pair minRole with an equivalent hasMinRole() guard server-side.

Don't read the role from the session object in components:

// ❌ Wrong — forces every consumer to handle the whole session
const { session } = useSession()
const role = session?.membership?.role

// ✅ Correct — one narrow hook, no prop drilling
const role = useSessionRole()

On this page