Authentication
This guide details our authentication architecture, built with Clerk, providing a robust multi-tenant system with role-based access control.
Our authentication system is built on Clerk and is designed to support a multi-tenant architecture with a clear separation of roles and responsibilities. This document outlines the core concepts, patterns, and best practices for implementing authentication and authorization in the application.
Core Principles
To maintain a secure and consistent authentication system, we adhere to the following principles:
-
Centralized Auth Packages: All authentication-related imports should come from our dedicated repository packages. This ensures consistency and allows for easy updates.
- Server-side:
import { auth, clerkClient, requireUser, requireSuperAdmin } from '@repo/auth/auth' - Client-side:
import { useUser, useOrganization, Protect } from '@repo/auth/auth-client'
- Server-side:
-
Defense in Depth: We apply security at multiple layers: middleware for route protection, component-level protection for UI, server-side checks in API routes and server actions, and database-level scoping.
-
Principle of Least Privilege: Users are granted only the permissions necessary to perform their roles.
User Roles & Authorization
We have a three-tier role system managed through Clerk's organization memberships. Roles are prefixed with org: and stored in the user's membership, not in public metadata.
org:super_admin: Has complete system access, can manage all organizations, and access the super admin panel.org:reseller: An organization-level admin who can manage their assigned organizations with limited admin functionality.org:member: A standard user with basic access to application features within their organization.
Checking Roles
Client-side:
Use the membership object from the useOrganization hook.
import { useOrganization } from '@repo/auth/auth-client';
const { membership } = useOrganization();
const orgRole = membership?.role;
const isAdmin = orgRole === 'org:admin';Server-side: Role information is abstracted and available in the return value of our custom auth handlers.
import { requireUser } from '@repo/auth/auth';
const { isAdmin, isReseller } = await requireUser();Protecting Pages & Components
Client-Side: <Protect> Component
For conditionally rendering UI elements on the client, always use the <Protect> component from @repo/auth/auth-client. It's the standard for declarative, role-based UI.
import { Protect } from '@repo/auth/auth-client';
// Protect content for a specific role
<Protect condition={(has) => has({ role: 'org:admin' })}>
<AdminOnlyComponent />
</Protect>
// Protect with a fallback component
<Protect
condition={(has) => has({ role: 'org:super_admin' })}
fallback={<p>You do not have access to this section.</p>}
>
<SuperAdminDashboard />
</Protect>Server-Side: auth().protect()
In Server Components, use auth().protect() to enforce authentication and has() to check for specific roles.
import { auth } from '@repo/auth/auth';
export default async function AdminPage() {
const { has } = await auth().protect();
if (!has({ role: 'org:admin' })) {
return <p>Not authorized.</p>;
}
return <div>Welcome, Admin!</div>;
}Server-Side Authentication
Server Actions
All server actions must be secured. We provide helper functions that wrap Clerk's auth checks and enforce our application's security patterns.
Standard Action:
Use requireUser() at the beginning of any action that requires an authenticated user. It returns the user's session or throws an error if they are not authenticated.
import { requireUser } from '@repo/auth/auth';
export async function createResource(values: CreateResourceInput) {
try {
const { orgId } = await requireUser();
// Business logic here...
} catch (error) {
// Handle auth error
}
}Super Admin Action:
For actions restricted to super admins, use requireSuperAdmin().
import { requireSuperAdmin } from '@repo/auth/auth';
export async function systemWideOperation() {
try {
const { userId } = await requireSuperAdmin();
// Super admin logic here...
} catch (error) {
// Handle auth error
}
}Secure Server Action Template: Follow this template for all mutations to ensure proper security, validation, and error handling.
'use server';
import { requireUser } from '@repo/auth/auth';
import { database } from '@repo/database';
import { revalidatePath } from 'next/cache';
import { actionSchema, type ActionInput } from './validators';
export async function secureAction(values: ActionInput) {
try {
// 1. Authentication check
const { orgId } = await requireUser();
// 2. Input validation
const result = actionSchema.safeParse(values);
if (!result.success) {
return { success: false, message: 'Invalid data provided' };
}
// 3. Database operation scoped to the organization
await database.resource.create({
data: {
...result.data,
organization: { connect: { clerkOrgId: orgId } },
},
});
// 4. Revalidate cache
revalidatePath('/relevant-path');
return { success: true, message: 'Action completed successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Action failed',
};
}
}API Routes (Route Handlers)
For API endpoints, use the auth() helper from @repo/auth/auth to get the user's authentication state.
import { auth } from '@repo/auth/auth';
import { NextResponse } from 'next/server';
export async function GET() {
const { userId, orgId } = await auth();
if (!userId || !orgId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Your API logic here...
}Client-Side Authentication
Accessing User and Organization Data
Use the custom hooks from @repo/auth/auth-client to access user and organization data in your client components.
useUser(): Provides user information.useOrganization(): Provides active organization and membership details.useSuperAdmin(): A custom hook that provides boolean flags for the user's role (isAdmin,isReseller,isSuperAdmin).
'use client';
import { useUser, useOrganization } from '@repo/auth/auth-client';
import { useSuperAdmin } from '@repo/auth/auth-client';
function UserProfile() {
const { user } = useUser();
const { organization } = useOrganization();
const { isAdmin, isSuperAdmin } = useSuperAdmin();
return (
<div>
<p>User: {user?.fullName}</p>
<p>Organization: {organization?.name}</p>
{isAdmin && <p>You are an admin.</p>}
{isSuperAdmin && <p>You are a super admin.</p>}
</div>
);
}Middleware & Routing
Our middleware (middleware.ts) is responsible for protecting routes and handling redirects for authenticated users.
- It uses
createRouteMatcherto define public routes that don't require authentication. - For all other routes, it calls
auth.protect(). - It handles post-signin redirects, sending users to their organization's slug-based URL (e.g.,
/my-org/dashboard). This is done using an edge-safe slug resolver that only calls Clerk APIs, avoiding database access in middleware.
// apps/app/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@repo/auth/auth';
const isPublicRoute = createRouteMatcher([
'/sign-in(.*)',
'/sign-up(.*)',
'/api/webhooks(.*)',
]);
export default clerkMiddleware((auth, request) => {
if (!isPublicRoute(request)) {
auth.protect();
}
});Quick Checklist
- Imports: Only use
@repo/auth/authand@repo/auth/auth-client. - Server Actions: Secure with
requireUser()orrequireSuperAdmin(), use Zod for validation, and always return a{ success, message }object. - Client Forms: Use
useTransitionfor pending states andsonnerfor toast notifications. - UI Gating: Use
<Protect>on the client andauth().protect()on the server. - Middleware: Ensure all non-public routes are protected.