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:
| Middleware | Purpose | Enforces |
|---|---|---|
isAuthed | Verifies a valid Clerk userId exists | UNAUTHORIZED if no session |
hasTenant | Resolves orgId to an internal tenantId | FORBIDDEN if no org selected |
isAdmin | Checks the Clerk org role is org:admin | FORBIDDEN 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:
| Role | Clerk Value | Permissions |
|---|---|---|
| Admin | org:admin | Full access -- config changes, scoring runs, connector management, writeback |
| Manager | org:manager | Read access to all data, limited write access |
| Viewer | org:viewer | Read-only access to scoring data and dashboards |
tenantProcedure-- Available to all authenticated org members (admin, manager, viewer)adminProcedure-- Restricted to users with theorg:adminrole
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 },
});
});
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");
| Helper | Signature | Description |
|---|---|---|
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 |
For tRPC procedures, use adminProcedure or tenantProcedure rather than calling requireRole directly. The middleware chain handles auth checks declaratively.
Related Pages
- tRPC Overview -- Client setup and router structure
- Error Handling --
UNAUTHORIZEDandFORBIDDENerror codes - Tenant API -- Tenant settings management