In this article
June 2, 2026
June 2, 2026

TanStack Start authorization and RBAC: A developer's guide for 2026

Your route guard does not protect your server functions. A complete guide to authorization in TanStack Start, from roles and permissions to enterprise RBAC and fine-grained access control.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Authentication answers "who is this user?" Authorization answers "what can this user do?" They are separate concerns, and in TanStack Start they require separate mechanisms. Getting one right does not mean you have the other.

This guide covers authorization in TanStack Start from the ground up: how to model roles and permissions, how to enforce them at the server function level (not just the route level), how to build typed middleware that carries authorization context through your application, and how to handle the patterns that real B2B applications require: organization-scoped roles, resource-level permissions, and enterprise RBAC.

Why authorization is harder than authentication

Once a user is logged in, many applications assume the hard part is done. It is not.

Authentication establishes identity. Authorization establishes what that identity is allowed to do. In TanStack Start, both concerns touch the same primitives (server functions, middleware, and beforeLoad) but they protect different things.

A beforeLoad guard that redirects unauthenticated users to /login does nothing to stop a logged-in user with the wrong role from calling a server function directly. Every createServerFn is an HTTP endpoint. A regular user who knows the URL of your deleteOrganization server function can POST to it regardless of what your route guards say. Authorization, like authentication, must be enforced inside the server function itself.

This is the same double-guard pattern from authentication: route-level checks for user experience, server function-level checks for actual security. Authorization adds a third layer: the permission check that happens after you know who the user is.

  
Request
  → beforeLoad (is the user logged in? do they have the right role?)
      → server function middleware (valid session?)
          → permission check (can this user perform this action?)
              → handler
  

All three layers matter. Omitting any one of them leaves a gap.

Modeling roles and permissions

Before writing any middleware or guards, you need a model. The two most common approaches are flat roles and permission-based RBAC.

Flat roles

The simplest model: each user has a single role, and roles are checked directly.

  
// src/lib/roles.ts

export const ROLES = {
  viewer: 'viewer',
  editor: 'editor',
  admin: 'admin',
} as const

export type Role = (typeof ROLES)[keyof typeof ROLES]

const ROLE_HIERARCHY: Record<Role, number> = {
  viewer: 0,
  editor: 1,
  admin: 2,
}

export function hasRole(userRole: Role, requiredRole: Role): boolean {
  return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole]
}
  

Flat roles work well for simple applications. Their weakness is that they do not scale cleanly when you need fine-grained control. An admin who can delete users but not modify billing settings cannot be expressed with a single role hierarchy.

Permission-based RBAC

The more flexible model: permissions are discrete strings representing specific actions, roles are collections of permissions, and users are assigned roles. WorkOS recommends a resource:action naming scheme using delimiters like :, -, ., or _.

  
// src/lib/permissions.ts

export const PERMISSIONS = {
  // User management
  'users:read': 'users:read',
  'users:write': 'users:write',
  'users:delete': 'users:delete',

  // Content
  'content:read': 'content:read',
  'content:write': 'content:write',
  'content:publish': 'content:publish',

  // Billing
  'billing:read': 'billing:read',
  'billing:write': 'billing:write',

  // Organization
  'org:settings:read': 'org:settings:read',
  'org:settings:write': 'org:settings:write',
  'org:members:invite': 'org:members:invite',
  'org:members:remove': 'org:members:remove',
} as const

export type Permission = keyof typeof PERMISSIONS

export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
  viewer: [
    'users:read',
    'content:read',
    'billing:read',
    'org:settings:read',
  ],
  editor: [
    'users:read',
    'content:read',
    'content:write',
    'content:publish',
    'billing:read',
    'org:settings:read',
  ],
  admin: [
    'users:read',
    'users:write',
    'users:delete',
    'content:read',
    'content:write',
    'content:publish',
    'billing:read',
    'billing:write',
    'org:settings:read',
    'org:settings:write',
    'org:members:invite',
    'org:members:remove',
  ],
}

export function hasPermission(
  userRole: string,
  permission: Permission
): boolean {
  const permissions = ROLE_PERMISSIONS[userRole] ?? []
  return permissions.includes(permission)
}

export function hasAllPermissions(
  userRole: string,
  required: Permission[]
): boolean {
  return required.every((p) => hasPermission(userRole, p))
}

export function hasAnyPermission(
  userRole: string,
  required: Permission[]
): boolean {
  return required.some((p) => hasPermission(userRole, p))
}
  

Store the user's role in your database and include it in the session. When you look up the user on each request, you get their role, and from the role you derive their permissions at runtime.

  
// src/lib/session.ts
import { useSession } from 'vinxi/http'

type SessionData = {
  userId: string
  role: string
}

export function useAppSession() {
  return useSession<SessionData>({
    password: process.env.SESSION_SECRET!,
  })
}
  

Authorization middleware

The cleanest way to enforce permissions across server functions is middleware. You compose it: an auth middleware verifies the session, and a permission middleware layers on top of it.

  
// src/middleware/auth.ts
import { createMiddleware } from '@tanstack/react-start'
import { useAppSession } from '../lib/session'
import { db } from '../lib/db'

export const authMiddleware = createMiddleware().server(async ({ next }) => {
  const session = await useAppSession()

  if (!session.data.userId) {
    throw new Error('Unauthorized')
  }

  const user = await db.users.findById(session.data.userId)

  if (!user) {
    throw new Error('Unauthorized')
  }

  return next({ context: { user } })
})
  
  
// src/middleware/rbac.ts
import { createMiddleware } from '@tanstack/react-start'
import { authMiddleware } from './auth'
import { hasPermission, hasAllPermissions, type Permission } from '../lib/permissions'

export function requirePermission(permission: Permission) {
  return createMiddleware()
    .middleware([authMiddleware])
    .server(async ({ next, context }) => {
      if (!hasPermission(context.user.role, permission)) {
        throw new Error('Forbidden')
      }
      return next({ context })
    })
}

export function requirePermissions(permissions: Permission[]) {
  return createMiddleware()
    .middleware([authMiddleware])
    .server(async ({ next, context }) => {
      if (!hasAllPermissions(context.user.role, permissions)) {
        throw new Error('Forbidden')
      }
      return next({ context })
    })
}
  

Now protect server functions by composing the right middleware:

  
// src/server/users.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { requirePermission } from '../middleware/rbac'
import { db } from '../lib/db'

export const listUsers = createServerFn({ method: 'GET' })
  .middleware([requirePermission('users:read')])
  .handler(async ({ context }) => {
    return await db.users.findAll()
  })

export const deleteUser = createServerFn({ method: 'POST' })
  .middleware([requirePermission('users:delete')])
  .inputValidator(z.object({ userId: z.string() }))
  .handler(async ({ data, context }) => {
    if (data.userId === context.user.id) {
      return { error: 'You cannot delete your own account' }
    }
    await db.users.delete(data.userId)
    return { success: true }
  })

export const updateBillingSettings = createServerFn({ method: 'POST' })
  .middleware([requirePermission('billing:write')])
  .inputValidator(z.object({ plan: z.string() }))
  .handler(async ({ data, context }) => {
    await db.organizations.updatePlan(context.user.orgId, data.plan)
    return { success: true }
  })
  

The middleware composes through TanStack Start's inference system. When requirePermission layers on top of authMiddleware, the context.user type flows through. Your handler knows the exact shape of the user without manual type annotations.

Route-level authorization

Route guards protect the user experience. An editor who navigates to /admin/billing should be redirected before they see anything, not after a server function rejects their request.

One important note on where to put these checks: use loader, not beforeLoad, for session reads. beforeLoad runs on both the server and the client during hydration, which means a call like getUser() would execute client-side too. loader is server-only during SSR and runs before the component renders, making it the correct place for auth redirects.

Use layout routes for role-gated sections:

  
src/routes/
  _authed.tsx                    # requires login
  _authed/
    dashboard.tsx
    _admin.tsx                   # requires admin role
    _admin/
      users.tsx                  # /admin/users
      billing.tsx                # /admin/billing
      settings.tsx               # /admin/settings
  
  
// src/routes/_admin.tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
import { getUser } from '../server/auth'
import { hasRole } from '../lib/permissions'

export const Route = createFileRoute('/_admin')({
  loader: async () => {
    const user = await getUser()

    if (!user) {
      throw redirect({ to: '/login' })
    }

    if (!hasRole(user.role, 'admin')) {
      throw redirect({ to: '/unauthorized' })
    }

    return { user }
  },
  component: () => <Outlet />,
})
  

For permission-based checks, the loader can be more granular:

  
// src/routes/_authed/billing.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getUser } from '../server/auth'
import { hasPermission } from '../lib/permissions'

export const Route = createFileRoute('/_authed/billing')({
  loader: async () => {
    const user = await getUser()

    if (!user) {
      throw redirect({ to: '/login' })
    }

    if (!hasPermission(user.role, 'billing:read')) {
      throw redirect({ to: '/unauthorized' })
    }

    return { user }
  },
  component: BillingPage,
})
  

The /unauthorized route should give a clear message. Redirecting to /login for a permission failure confuses users and makes it look like their session expired.

Conditional UI rendering

Beyond route guards, you will often want to show or hide UI elements based on the user's role. A viewer should not see the "Delete user" button even if clicking it would be blocked server-side.

Pull the permission check into a hook so it can be reused across components:

  
// src/hooks/usePermission.ts
import { useRouteContext } from '@tanstack/react-router'
import { hasPermission, hasAnyPermission, type Permission } from '../lib/permissions'

export function usePermission(permission: Permission): boolean {
  const { user } = useRouteContext({ strict: false })
  if (!user) return false
  return hasPermission(user.role, permission)
}

export function useAnyPermission(permissions: Permission[]): boolean {
  const { user } = useRouteContext({ strict: false })
  if (!user) return false
  return hasAnyPermission(user.role, permissions)
}
  

Use it in components to conditionally render controls:

  
// src/components/UserActions.tsx
import { usePermission } from '../hooks/usePermission'
import { deleteUser } from '../server/users'

export function UserActions({ userId }: { userId: string }) {
  const canDelete = usePermission('users:delete')
  const canEdit = usePermission('users:write')

  return (
    <div>
      {canEdit && (
        <button onClick={() => editUser({ data: { userId } })}>
          Edit
        </button>
      )}
      {canDelete && (
        <button onClick={() => deleteUser({ data: { userId } })}>
          Delete
        </button>
      )}
    </div>
  )
}
  

If you are using WorkOS, you can skip the custom hook entirely. The useAuth() client hook returns role, roles, and permissions directly from the session, and it stays reactive. Permission checks update automatically if the session changes.

  
import { useAuth } from '@workos/authkit-tanstack-react-start/client'
import { deleteUser } from '../server/users'

export function UserActions({ userId }: { userId: string }) {
  const { permissions } = useAuth()

  const canDelete = permissions?.includes('users:delete') ?? false
  const canEdit = permissions?.includes('users:write') ?? false

  return (
    <div>
      {canEdit && (
        <button onClick={() => editUser({ data: { userId } })}>
          Edit
        </button>
      )}
      {canDelete && (
        <button onClick={() => deleteUser({ data: { userId } })}>
          Delete
        </button>
      )}
    </div>
  )
}
  

One thing to be clear about: UI permission checks are a user experience concern, not a security control. Hiding buttons does not prevent a malicious user from calling the server functions directly. The server function middleware is the security boundary. The UI check is so legitimate users do not see controls they cannot use.

Organization-scoped roles

Most B2B applications do not have a single global role per user. A user might be an admin in one organization and a viewer in another. This pattern, where roles are scoped to an organization, is the standard for SaaS applications.

The data model changes: instead of users.role, you have a memberships table.

  
CREATE TABLE memberships (
  id          TEXT PRIMARY KEY,
  user_id     TEXT NOT NULL REFERENCES users(id),
  org_id      TEXT NOT NULL REFERENCES organizations(id),
  role        TEXT NOT NULL DEFAULT 'member',
  created_at  TIMESTAMP DEFAULT NOW(),
  UNIQUE(user_id, org_id)
);
  

Your session now tracks which organization is active:

  
type SessionData = {
  userId: string
  orgId: string
}
  

And your auth middleware resolves the membership for the active organization:

  
// src/middleware/auth.ts
export const authMiddleware = createMiddleware().server(async ({ next }) => {
  const session = await useAppSession()

  if (!session.data.userId || !session.data.orgId) {
    throw new Error('Unauthorized')
  }

  const membership = await db.memberships.findOne({
    userId: session.data.userId,
    orgId: session.data.orgId,
  })

  if (!membership) {
    throw new Error('Unauthorized')
  }

  const user = await db.users.findById(session.data.userId)

  return next({
    context: {
      user,
      membership,
      orgId: session.data.orgId,
      role: membership.role,
    },
  })
})
  

Your permission middleware then checks against context.role (the org-scoped role) rather than a global user role:

  
export function requirePermission(permission: Permission) {
  return createMiddleware()
    .middleware([authMiddleware])
    .server(async ({ next, context }) => {
      if (!hasPermission(context.role, permission)) {
        throw new Error('Forbidden')
      }
      return next({ context })
    })
}
  

Server functions that modify organization data should also validate that the target resource belongs to the active organization. Without this check, an admin in org A could modify data belonging to org B:

  
export const updateProject = createServerFn({ method: 'POST' })
  .middleware([requirePermission('content:write')])
  .inputValidator(z.object({ projectId: z.string(), name: z.string() }))
  .handler(async ({ data, context }) => {
    const project = await db.projects.findById(data.projectId)

    if (!project || project.orgId !== context.orgId) {
      throw new Error('Not found')
    }

    await db.projects.update(data.projectId, { name: data.name })
    return { success: true }
  })
  

Return Not found rather than Forbidden for cross-tenant access attempts. Forbidden confirms the resource exists; Not found does not.

Resource-level permissions

Sometimes role-based permissions are not granular enough. You need to check ownership: can this user access this specific record?

  
export const getDocument = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .inputValidator(z.object({ documentId: z.string() }))
  .handler(async ({ data, context }) => {
    const document = await db.documents.findById(data.documentId)

    if (!document) {
      throw new Error('Not found')
    }

    const canRead =
      document.ownerId === context.user.id ||
      (document.orgId === context.orgId &&
        hasPermission(context.role, 'content:read'))

    if (!canRead) {
      throw new Error('Not found')
    }

    return document
  })
  

A helper function keeps this readable when ownership checks get complex:

  
// src/lib/access.ts
import { hasPermission, type Permission } from './permissions'

type AccessContext = {
  userId: string
  orgId: string
  role: string
}

type Resource = {
  ownerId?: string
  orgId?: string
}

export function canAccess(
  ctx: AccessContext,
  resource: Resource,
  fallbackPermission: Permission
): boolean {
  if (resource.ownerId === ctx.userId) return true
  if (
    resource.orgId === ctx.orgId &&
    hasPermission(ctx.role, fallbackPermission)
  )
    return true
  return false
}
  

Testing authorization

Authorization logic is high-stakes and should be unit tested. The permission functions are pure and easy to test:

  
// src/lib/permissions.test.ts
import { describe, it, expect } from 'vitest'
import { hasPermission, hasAllPermissions } from './permissions'

describe('hasPermission', () => {
  it('grants viewers read access', () => {
    expect(hasPermission('viewer', 'users:read')).toBe(true)
  })

  it('denies viewers write access', () => {
    expect(hasPermission('viewer', 'users:write')).toBe(false)
  })

  it('grants admins delete access', () => {
    expect(hasPermission('admin', 'users:delete')).toBe(true)
  })

  it('denies unknown roles all permissions', () => {
    expect(hasPermission('unknown', 'users:read')).toBe(false)
  })
})
  

For testing server functions under different roles, mock the session and membership and test both the allowed and denied cases:

  
// src/server/users.test.ts
import { describe, it, expect, vi } from 'vitest'
import { deleteUser } from './users'

vi.mock('../lib/session', () => ({
  useAppSession: vi.fn(),
}))

vi.mock('../lib/db', () => ({
  db: {
    users: { findById: vi.fn(), delete: vi.fn() },
    memberships: { findOne: vi.fn() },
  },
}))

import { useAppSession } from '../lib/session'
import { db } from '../lib/db'

function mockSession(userId: string, role: string) {
  ;(useAppSession as ReturnType<typeof vi.fn>).mockResolvedValue({
    data: { userId, orgId: 'org-1' },
  })
  ;(db.memberships.findOne as ReturnType<typeof vi.fn>).mockResolvedValue({
    userId,
    orgId: 'org-1',
    role,
  })
  ;(db.users.findById as ReturnType<typeof vi.fn>).mockResolvedValue({
    id: userId,
    email: 'user@example.com',
  })
}

describe('deleteUser', () => {
  it('allows admins to delete users', async () => {
    mockSession('user-1', 'admin')
    ;(db.users.delete as ReturnType<typeof vi.fn>).mockResolvedValue(undefined)

    const result = await deleteUser({ data: { userId: 'user-2' } })
    expect(result).toEqual({ success: true })
  })

  it('rejects editors attempting to delete users', async () => {
    mockSession('user-1', 'editor')

    await expect(
      deleteUser({ data: { userId: 'user-2' } })
    ).rejects.toThrow('Forbidden')
  })
})
  

Test that direct HTTP calls to server functions are rejected by the middleware, not just when triggered from your UI. A raw POST to the server function URL with a valid session but insufficient role should return a rejection. This is the most important test, because it verifies the boundary that actually matters.

Managed RBAC with WorkOS

Building the permission model above covers some basic applications. But enterprise B2B products often need capabilities that are expensive to build yourself: roles that sync automatically from a customer's identity provider, permission assignments managed outside your codebase, or audit logs of every access control change.

WorkOS RBAC handles this. You configure roles and permissions in the WorkOS dashboard or via the API; useful if you want to manage role definitions as code or provision environments from CI. Users are assigned roles through their organization memberships or identity providers. Those roles and permissions are embedded directly in the session access token as JWT claims, so your server code reads them without an extra database query.

The access token includes a role claim with the user's role for the active organization, and a permissions claim with the array of permission slugs assigned to that role. For example:

  
{
  "sub": "user_01JXYZ...",
  "org_id": "org_01JXYZ...",
  "role": "admin",
  "roles": ["admin"],
  "permissions": ["users:delete", "billing:write", "org:settings:write"]
}
  

The WorkOS TanStack Start SDK exposes these through getAuth(). Your middleware reads them directly from the session without touching the database:

  
// src/middleware/rbac.ts
import { createMiddleware } from '@tanstack/react-start'
import { getAuth } from '@workos/authkit-tanstack-react-start'

export function requirePermission(permission: string) {
  return createMiddleware().server(async ({ next }) => {
    const auth = await getAuth()

    if (!auth.user) {
      throw new Error('Unauthorized')
    }

    if (!auth.permissions?.includes(permission)) {
      throw new Error('Forbidden')
    }

    return next({
      context: { user: auth.user, permissions: auth.permissions, role: auth.role },
    })
  })
}
  
  
// src/server/users.ts
export const deleteUser = createServerFn({ method: 'POST' })
  .middleware([requirePermission('users:delete')])
  .inputValidator(z.object({ userId: z.string() }))
  .handler(async ({ data, context }) => {
    await db.users.delete(data.userId)
    return { success: true }
  })
  

The pattern in your application code stays the same: middleware on server functions, loader for route-level redirects. What changes is where roles and permissions are managed, and how they get into the session.

Syncing WorkOS users to your database

Most applications need a record of each user in their own database, even when WorkOS manages auth. The handleCallbackRoute handler accepts an onSuccess hook that runs after a successful login. It is the right place to create or update your user record.

  
// src/routes/api/auth/callback.tsx
import { createFileRoute } from '@tanstack/react-router'
import { handleCallbackRoute } from '@workos/authkit-tanstack-react-start'
import { db } from '../../lib/db'

export const Route = createFileRoute('/api/auth/callback')({
  server: {
    handlers: {
      GET: handleCallbackRoute({
        onSuccess: async ({ user }) => {
          await db.users.upsert({
            id: user.id,
            email: user.email,
            firstName: user.firstName,
            lastName: user.lastName,
          })
        },
      }),
    },
  },
})
  

This keeps your local user table in sync without polling or webhooks. The upsert runs once per login, which is also where you could seed a default role for new users before WorkOS RBAC takes over.

Organization switching

Multi-org users need a way to change their active organization. The SDK provides switchToOrganization on both the server and the useAuth() client hook. When the organization changes, the session is refreshed and the role and permissions claims update to reflect the user's membership in the new org.

  
import { useAuth } from '@workos/authkit-tanstack-react-start/client'

function OrgSwitcher({ orgs }: { orgs: Array<{ id: string; name: string }> }) {
  const { organizationId, switchToOrganization } = useAuth()

  return (
    <select
      value={organizationId ?? ''}
      onChange={(e) => switchToOrganization(e.target.value)}
    >
      {orgs.map((org) => (
        <option key={org.id} value={org.id}>
          {org.name}
        </option>
      ))}
    </select>
  )
}
  

The permission checks in your server function middleware automatically reflect the new organization's role after the switch, with no additional code required.

IdP role assignment

The feature that saves the most engineering time in practice: WorkOS can map a customer's identity provider groups directly to your roles. When someone joins the "Engineering" group in Okta or Azure AD, they are automatically assigned the corresponding role in your application. The mapping is configured by the customer's IT admin in the WorkOS Admin Portal, not by your team.

This means a customer's IT department can manage your application's access control through their existing identity provider, without filing a support ticket every time a new employee joins or someone changes teams. For SSO users, roles update on each authentication. For Directory Sync users, roles update in real time when directory events arrive.

Multiple roles

WorkOS supports assigning multiple roles to a single organization membership. This is useful when users span functions and need additive access rather than a single catch-all role. Instead of creating a designer-engineer role to cover both permission sets, you assign both designer and engineer roles to the user. The permissions claim in the JWT will contain the union of all permissions from all assigned roles.

Multiple roles is an environment-level setting and is disabled by default. For most applications, start with single-role assignments for simplicity and predictability, and enable multiple roles when overlapping permission sets become common.

When RBAC is not enough: Fine-grained authorization

RBAC works well when access decisions can be made from a user's role alone. It gets awkward when you need to ask more specific questions: can this user access this particular project? Can they edit this document but not that one?

These are resource-scoped questions, and they require a different model. Instead of "does the user have the content:write permission," you need to ask "does the user have the content:write permission on resource project-abc."

The naive solution is to keep adding ownership checks in your server function handlers. That works up to a point, but it scales poorly. As your product adds workspaces, projects, shared documents, and nested resources, the ownership logic in each handler gets more complex and harder to audit.

WorkOS FGA (Fine-Grained Authorization) addresses this directly. It extends the RBAC model with resource types and a hierarchy. A workspace admin gets access to all projects and apps within that workspace automatically, without needing explicit assignments at each level. An access check answers "can this user perform this action on this resource?" and the system traverses the hierarchy to resolve it.

The integration path is incremental: your existing organization-level roles keep working, and you add resource-scoped roles only where you need them. For a TanStack Start application, FGA access checks slot into the same server function middleware pattern:

FGA is the right tool when your product has grown beyond tenant-wide roles and you find yourself writing complex ownership logic in multiple server functions. It is not necessary for most early-stage applications. Start with RBAC and add resource-scoped authorization when the ownership checks in your handlers start to feel like a maintenance burden.

Production checklist

Authorization architecture

  • Enforce permissions inside every server function that handles sensitive data or operations. Route-level beforeLoad checks are for user experience only.
  • Use middleware to avoid duplicating permission checks. A check that lives in one place cannot be accidentally removed from another.
  • Return Not found, not Forbidden, when denying access to resources that the user should not know exist.
  • Validate that resources belong to the active organization before operating on them. A user who is admin in org A should not be able to modify org B's data.
  • Check permissions on every mutation, not just reads.

Role design

  • Start with the minimum roles your product actually needs. Adding roles is easy; removing them once users are assigned is painful.
  • Name permissions as resource:action pairs (billing:write, users:delete). This scales better than boolean flags like canDeleteUsers.
  • Log all permission denials. A spike in Forbidden errors from a single user is an anomaly worth investigating.

Testing

  • Unit test every permission function. These are pure functions with no dependencies; they should have full branch coverage.
  • Write integration tests that call server functions with different role contexts. Test both the allowed and denied cases.
  • Test that direct HTTP calls to server functions are rejected by the middleware, not just when called from your UI.

Conclusion

Authorization in TanStack Start follows the same pattern as authentication: the server function is the security boundary, not the route. Once you accept that, the rest follows naturally. Middleware composes permission checks cleanly. beforeLoad handles the user experience. The type system carries authorization context through your route tree.

Build the minimum permission system your product needs today. Name permissions clearly so you can add granularity later without a rewrite. Test that the server function rejections actually work, not just that the UI hides the right buttons.

If your B2B customers will eventually ask for configurable roles, directory sync, or audit logs, consider whether building that infrastructure is the best use of your team's time. WorkOS RBAC handles role management, IdP group syncing, and the session integration. If you later need resource-scoped access control as your product grows, WorkOS FGA extends the same model without a conceptual rewrite.

Sign up for WorkOS and add RBAC to your TanStack Start application.