Skip to main content

Authentication

GTM Clarity uses Clerk for session-based authentication with multi-tenant organization scoping. Every API call requires an active Clerk session, and all data access is isolated to the authenticated user's organization (tenant).

Authentication Flow

Browser (Clerk session cookie)
-> Next.js API route (/api/trpc)
-> tRPC context (extracts auth + resolves tenant)
-> Procedure middleware chain (isAuthed -> hasTenant -> isAdmin)
-> Router handler

Clerk manages session tokens automatically via its Next.js middleware. The tRPC context extracts the session on every request:

interface Context {
auth: Awaited<ReturnType<typeof auth>>;
db: typeof db;
tenantId?: string;
}

export async function createContext(): Promise<Context> {
const session = await auth();
return { auth: session, db };
}

Middleware Chain

tRPC procedures are protected by a layered middleware stack defined in src/server/trpc/init.ts:

MiddlewarePurposeEnforces
isAuthedVerifies a valid Clerk userId existsUNAUTHORIZED if no session
hasTenantResolves orgId to an internal tenantIdFORBIDDEN if no org selected
isAdminChecks the Clerk org role is org:adminFORBIDDEN if not admin

These compose into three procedure types:

export const authedProcedure  = publicProcedure.use(isAuthed);
export const tenantProcedure = authedProcedure.use(hasTenant);
export const adminProcedure = tenantProcedure.use(isAdmin);

RBAC Roles

GTM Clarity uses Clerk's organization role system with three tiers:

RoleClerk ValuePermissions
Adminorg:adminFull access -- config changes, scoring runs, connector management, writeback
Managerorg:managerRead access to all data, limited write access
Viewerorg:viewerRead-only access to scoring data and dashboards
Procedure Access Levels
  • tenantProcedure -- Available to all authenticated org members (admin, manager, viewer)
  • adminProcedure -- Restricted to users with the org:admin role

Tenant Resolution

When a user selects a Clerk organization, the hasTenant middleware maps the Clerk orgId to an internal tenant record. If no tenant exists for the org, one is auto-provisioned:

const hasTenant = t.middleware(async ({ next, ctx }) => {
if (!ctx.auth.orgId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "No organization selected",
});
}

let tenant = await db.query.tenants.findFirst({
where: eq(tenants.clerkOrgId, ctx.auth.orgId),
});

if (!tenant) {
const [newTenant] = await db
.insert(tenants)
.values({
clerkOrgId: ctx.auth.orgId,
name: ctx.auth.orgSlug ?? ctx.auth.orgId,
slug: ctx.auth.orgSlug ?? ctx.auth.orgId,
})
.returning();
tenant = newTenant;
}

return next({
ctx: { ...ctx, tenantId: tenant!.id },
});
});
Multi-Tenant Isolation

Every database query in GTM Clarity filters by tenantId. Cross-tenant data access is structurally impossible when using the provided procedure types. Never bypass the tenant middleware.

Making Authenticated Calls (React)

The tRPC client is configured with automatic session handling through the TRPCReactProvider:

// src/lib/trpc.tsx
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "@/server/trpc/router";

export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();

Wrap your app with the provider (already configured in the root layout):

import { TRPCReactProvider } from "@/lib/trpc";

export default function RootLayout({ children }) {
return <TRPCReactProvider>{children}</TRPCReactProvider>;
}

Then call procedures using the useTRPC hook and TanStack React Query:

import { useTRPC } from "@/lib/trpc";
import { useQuery } from "@tanstack/react-query";

function ScoringDashboard() {
const trpc = useTRPC();
const { data, isLoading } = useQuery(
trpc.scoring.getMatrixData.queryOptions({ limit: 500 })
);

if (isLoading) return <div>Loading...</div>;
return <ScoreMatrix data={data} />;
}

Clerk injects the session cookie automatically -- no manual token management is required.

Server-Side Auth Helpers

For server components or API routes outside tRPC, use the helpers in src/lib/auth.ts:

import { requireRole } from "@/lib/auth";

// Throws if user doesn't have the required role
await requireRole("org:admin");
HelperSignatureDescription
requireRole(role: OrgRole) => Promise<void>Throws if the active session lacks the specified role
hasRole(role: OrgRole) => Promise<boolean>Returns true if the session has the role
tip

For tRPC procedures, use adminProcedure or tenantProcedure rather than calling requireRole directly. The middleware chain handles auth checks declaratively.