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, andminRoleon nav items. Enforcement still happens server-side — client-side checks are for UX only.
The Four Roles
| Role | Level | Typical capabilities |
|---|---|---|
owner | 100 | Full access, can delete the organization |
admin | 80 | Manage org settings, members, dictionaries |
member | 50 | Regular day-to-day work |
viewer | 10 | Read-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:
- oRPC handler — authoritative. Throws
FORBIDDENif the role check fails. proxy.ts— redirects the user away from pages they can't use, so they don't see a broken empty state.- Client UI —
useSessionRole()/minRolehide 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 routerAlways 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()Related
- Customize Navigation — using
minRoleon nav items - Authentication Setup — how the session is built