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:
| Role | Description |
|---|---|
| admin | Full access to organization settings, members, invitations, API keys, audit logs, and all entities |
| member | Access 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:
- Authentication check — verifies the request has a valid user, organization, and member
- Disabled member check — blocks access for disabled members (disabled members keep their database record but lose all access)
- API key scope check — if the request uses an API key with restricted permissions, validates the key allows the requested action
- Local role check — fast, synchronous check using Better Auth's role permission system
- 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:
- User is signed in — redirects to
/auth/sign-inif not - User belongs to an organization — redirects to
/auth/organizationif not - Member is not disabled — redirects to
/auth/no-permissionsif disabled - Onboarding is complete — redirects to
/auth/profile-onboardif profile incomplete - Role has required permissions — redirects to
/auth/no-permissionsif 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:
| Connection | User | Purpose |
|---|---|---|
DATABASE_RLS_URL | Application user with NOBYPASSRLS | Normal queries — subject to RLS policies |
DATABASE_BYPASS_RLS_URL | System user with BYPASSRLS | Better Auth internals and system operations |
On startup, the app automatically:
- Identifies all tables with an
organizationIdcolumn - Creates RLS policies that restrict
SELECT,INSERT,UPDATE, andDELETEto rows matching the current organization - 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
organizationIdin 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
│
▼
ResponseCustomizing roles
To modify role permissions, edit src/features/permissions.ts. You can:
- Add new roles by calling
accessControl.newRole()and registering them in therolesobject - 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.