Security and permissions

Your project ships with a multi-layer authorization system built on top of Better Auth and its access control plugin. It combines role-based access control (RBAC), PostgreSQL row-level security (RLS), and frontend route guards to enforce permissions at every level of the stack.

Roles

There are two built-in roles defined in src/features/permissions.ts:

RoleDescription
adminFull access to organization settings, members, invitations, API keys, audit logs, and all entities
memberAccess to own subscription, entities, chatbot, and MCP — but no access to organization settings, member management, or audit logs

Both roles have full CRUD access to all entities by default. The difference is in administrative capabilities — only admins can manage members, invitations, API keys, and view audit logs.

How permissions work

Permissions follow a resource → actions structure. Each role declares which actions it can perform on which resources:

// Example: admin role permissions
const admin = accessControl.newRole({
  organization: ['read', 'update', 'delete'],
  member: [
    'create',
    'update',
    'delete',
    'read',
    'autocomplete',
    'import',
    'export',
  ],
  invitation: ['create', 'read', 'resend', 'cancel', 'export'],
  apiKey: ['create', 'read', 'update', 'delete'],
  auditLog: ['read', 'export'],
  // ... plus all entity permissions
});
 
// Example: member role permissions
const member = accessControl.newRole({
  member: ['autocomplete'],
  // ... limited set of permissions
});

Every entity in your project is automatically registered as a resource with the following actions: import, create, update, read, autocomplete, delete, archive, restore, and export.

Backend authorization guard

Every API endpoint is protected by authGuardBackend(), which performs five security checks in sequence:

  1. Authentication check — verifies the request has a valid user, organization, and member
  2. Disabled member check — blocks access for disabled members (disabled members keep their database record but lose all access)
  3. API key scope check — if the request uses an API key with restricted permissions, validates the key allows the requested action
  4. Local role check — fast, synchronous check using Better Auth's role permission system
  5. Remote session validation — server-side verification against the session store (skipped for MCP requests which use JWT)
// Example: protecting an entity create endpoint
export async function customerCreateController(
  body: unknown,
  context: AppContext,
) {
  const { currentOrganization } = await authGuardBackend(
    { customer: ['create'] },
    context,
  );
 
  // ... create logic
}

If any layer fails, the request receives a 403 Forbidden response.

Frontend route guard

The frontend mirrors backend authorization using authGuardFrontend() in TanStack Router's beforeLoad hooks. It checks:

  1. User is signed in — redirects to /auth/sign-in if not
  2. User belongs to an organization — redirects to /auth/organization if not
  3. Member is not disabled — redirects to /auth/no-permissions if disabled
  4. Onboarding is complete — redirects to /auth/profile-onboard if profile incomplete
  5. Role has required permissions — redirects to /auth/no-permissions if lacking
// Example: protecting a route
export const Route = createFileRoute('/customers')({
  beforeLoad: ({ context }) => {
    authGuardFrontend(context, { customer: ['read'] });
  },
});

The hasPermission() function in the auth Zustand store lets you conditionally show or hide UI elements based on the current member's role:

const { hasPermission } = useAuthStore();
 
{
  hasPermission({ customer: ['create'] }) && <Button>New customer</Button>;
}

Row-level security (RLS)

Beyond application-level checks, your project enforces tenant isolation at the database level using PostgreSQL row-level security.

How it works

The project uses two database connections:

ConnectionUserPurpose
DATABASE_RLS_URLApplication user with NOBYPASSRLSNormal queries — subject to RLS policies
DATABASE_BYPASS_RLS_URLSystem user with BYPASSRLSBetter Auth internals and system operations

On startup, the app automatically:

  1. Identifies all tables with an organizationId column
  2. Creates RLS policies that restrict SELECT, INSERT, UPDATE, and DELETE to rows matching the current organization
  3. Sets the organization context via a PostgreSQL session variable before each transaction

Usage in controllers

All entity queries use the $withRLS helper, which wraps queries in a transaction with the organization context set:

return await prisma.$withRLS(
  { organization: currentOrganization },
  async (tx) => {
    // All queries here are automatically filtered by organizationId
    const customers = await tx.customer.findMany();
    return customers;
  },
);

This means even if a bug in application code omits a where clause, the database itself prevents cross-organization data access.

API key authorization

API keys support optional permission scoping. When creating an API key, an admin can restrict it to specific actions:

  • A key created without permissions inherits the creator's full role permissions
  • A key created with specific permissions (e.g., read-only for customer) is limited to those actions
  • Keys store the organizationId in metadata, preventing cross-organization use

The authorization guard validates API key permissions as an additional layer on top of role checks.

MCP tool authorization

Each MCP tool declares its required permissions. The MCP handler validates these permissions before executing the tool:

export const customerCreateMcpTool = {
  name: 'customer_create',
  requiredPermissions: { customer: ['create'] },
  handler: async (params, context) => {
    return await customerCreateController(params, context);
  },
};

MCP requests authenticate via JWT (not sessions), so the remote session validation step is skipped — but all other authorization layers apply.

Storage permissions

File and image uploads are also role-gated. Each storage configuration declares which roles can upload:

export const storage = {
  memberAvatars: {
    roles: ['admin', 'member'], // Both roles can upload
  },
  organizationLogos: {
    roles: ['admin'], // Admin only
  },
};

Authorization flow summary

Request arrives

    ├─ Session cookie? → Validate session
    ├─ X-API-Key header? → Validate API key
    └─ MCP transport? → Validate JWT


Populate context (user, member, organization)


authGuardBackend() — 5-layer check


prisma.$withRLS() — Database-level isolation


Response

Customizing roles

To modify role permissions, edit src/features/permissions.ts. You can:

  • Add new roles by calling accessControl.newRole() and registering them in the roles object
  • Restrict entity access per role by removing actions from a role's entity permissions
  • Add new resources for custom permission checks beyond entities

After changing permissions, the authorization guard and frontend permission checks will automatically enforce the new rules.

For more details on the access control plugin, see the Better Auth access control documentation.