AInstein

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:

  1. 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'
  2. 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.

  3. 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 createRouteMatcher to 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/auth and @repo/auth/auth-client.
  • Server Actions: Secure with requireUser() or requireSuperAdmin(), use Zod for validation, and always return a { success, message } object.
  • Client Forms: Use useTransition for pending states and sonner for toast notifications.
  • UI Gating: Use <Protect> on the client and auth().protect() on the server.
  • Middleware: Ensure all non-public routes are protected.

On this page