Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Authentication & Authorization with Clerk

For .NET engineers who know: ASP.NET Identity, cookie/JWT middleware (AddAuthentication, AddJwtBearer), [Authorize] attributes, claims-based identity, and UserManager<T> You’ll learn: What Clerk is, why it replaces the auth stack you’d build yourself, and how to wire it across a Next.js frontend and a NestJS API with full role-based authorization Time: 15-20 min read

The .NET Way (What You Already Know)

In the .NET world, authentication is a first-class framework concern. ASP.NET Identity is a full membership system: user store (usually SQL Server), password hashing, email confirmation, password reset, lockout, two-factor authentication, and external OAuth providers. You register it, run migrations, and get a complete user management system.

// Program.cs — ASP.NET Identity + JWT setup
builder.Services.AddDbContext<ApplicationDbContext>();
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = config["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience = config["Jwt:Audience"],
            ValidateLifetime = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(config["Jwt:Key"]!)),
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
});
// ProtectedController.cs
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ProfileController : ControllerBase
{
    [HttpGet]
    public IActionResult GetProfile()
    {
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        return Ok(new { userId });
    }

    [Authorize(Policy = "AdminOnly")]
    [HttpDelete("{id}")]
    public IActionResult DeleteUser(string id) { /* ... */ }
}

This works, and .NET engineers know it well. But before Clerk existed, this was the minimum viable auth stack: ASP.NET Identity + JWT + email confirmation + password reset + social login integration + 2FA + token rotation. Each feature adds code, and the infrastructure has to be secured, monitored, and maintained. Clerk replaces all of it with a service.

The Clerk Way

What Clerk Provides

Clerk is a hosted authentication and user management service. It handles:

  • Sign-up and sign-in flows (email/password, magic links, social OAuth — Google, GitHub, etc.)
  • Multifactor authentication (TOTP, SMS)
  • Session management and token rotation
  • User profile management (email change, password change, connected accounts)
  • Organizations and roles (multi-tenant applications)
  • User metadata storage (arbitrary JSON attached to users)
  • Webhook delivery for user lifecycle events
  • Prebuilt UI components (sign-in/sign-up forms) or headless APIs

What you own: none of the above. You configure it in a dashboard, embed a few components, validate Clerk’s JWTs in your API, and you have a production-grade auth system in an afternoon.

The analogy in .NET terms: Clerk is roughly Azure AD B2C plus ASP.NET Identity’s user management UI, but simpler to configure, with better developer experience, and priced for startups.

What Clerk is not:

  • It is not an authorization system for your domain resources (whether User A can view Order B is still your code)
  • It is not free at scale (the free tier is generous; check pricing for your user volume)
  • It is not self-hosted (your user data lives in Clerk’s infrastructure — evaluate this for compliance requirements)

The Auth Flow

Understanding the full flow before writing code:

1. User visits your Next.js app (unauthenticated)
2. Next.js renders Clerk's <SignIn /> component (or redirects to Clerk's hosted sign-in page)
3. User signs in — Clerk authenticates, creates a session, issues a JWT
4. Clerk's session cookie + JWT are stored in the browser
5. Next.js server components read the session via Clerk's auth() helper
6. When the frontend calls your NestJS API, it sends the Clerk JWT in the Authorization header
7. NestJS verifies the JWT against Clerk's public keys
8. NestJS reads the userId and metadata from the JWT claims
9. NestJS authorizes the request based on roles/metadata

This is the same flow as ASP.NET Identity + JWT — the difference is that steps 2-4 are handled by Clerk rather than your code.

Setting Up Clerk

Step 1: Create a Clerk application

  1. Go to clerk.com and sign in
  2. Create a new application — choose the sign-in methods you want (email, Google, GitHub, etc.)
  3. Copy the API keys from the dashboard
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...

The publishable key is safe for the browser (it’s in NEXT_PUBLIC_ prefix — see Article 1.9 on Next.js environment variables). The secret key stays on the server.

Step 2: Install Clerk in your Next.js app

pnpm add @clerk/nextjs

Step 3: Wrap your app with ClerkProvider

// app/layout.tsx — root layout
import { ClerkProvider } from '@clerk/nextjs';
import type { ReactNode } from 'react';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  );
}

This is analogous to enabling authentication middleware in ASP.NET Core — it makes the session available throughout the application.

Step 4: Protect routes with middleware

// middleware.ts — in the root of your Next.js app (not inside /app or /src)
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

// Define which routes require authentication
const isProtectedRoute = createRouteMatcher([
  '/dashboard(.*)',   // Matches /dashboard and all sub-paths
  '/settings(.*)',
  '/api/(.*)(?<!^/api/webhooks)',  // All API routes except webhooks
]);

// This middleware runs on every request — equivalent to app.UseAuthentication() + app.UseAuthorization()
export default clerkMiddleware((auth, req) => {
  if (isProtectedRoute(req)) {
    auth.protect(); // Redirects to sign-in if unauthenticated
  }
});

export const config = {
  matcher: [
    // Skip Next.js internals and static files
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
};

The createRouteMatcher pattern replaces the [Authorize] attribute and route-level auth configuration you’d do in ASP.NET. Routes not in the protected list are public by default.

Clerk in Next.js: Reading Auth State

// app/dashboard/page.tsx — Server Component (runs on the server)
import { auth, currentUser } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  // auth() is the server-side equivalent of HttpContext.User in ASP.NET
  const { userId, sessionClaims } = await auth();

  if (!userId) {
    redirect('/sign-in');
  }

  // currentUser() fetches the full user object from Clerk's API
  // Equivalent to: await _userManager.FindByIdAsync(userId)
  const user = await currentUser();

  return (
    <div>
      <h1>Welcome, {user?.firstName}</h1>
      <p>User ID: {userId}</p>
    </div>
  );
}
// app/components/profile-button.tsx — Client Component
'use client';
import { useUser, useAuth, SignOutButton } from '@clerk/nextjs';

export function ProfileButton() {
  // useUser() and useAuth() are React hooks for client components
  // Equivalent to injecting IHttpContextAccessor and reading User in Blazor
  const { user, isLoaded } = useUser();
  const { isSignedIn } = useAuth();

  if (!isLoaded) return <div>Loading...</div>;
  if (!isSignedIn) return null;

  return (
    <div>
      <span>{user.emailAddresses[0].emailAddress}</span>
      {/* SignOutButton handles session termination */}
      <SignOutButton>
        <button>Sign Out</button>
      </SignOutButton>
    </div>
  );
}
// app/sign-in/[[...sign-in]]/page.tsx — Sign-in page
import { SignIn } from '@clerk/nextjs';

export default function SignInPage() {
  // <SignIn /> renders Clerk's prebuilt sign-in form
  // Equivalent to an Identity-scaffolded login page
  // Handles password reset, OAuth redirects, 2FA — all built in
  return (
    <div className="flex justify-center py-12">
      <SignIn />
    </div>
  );
}

The [[...sign-in]] folder name is Next.js catch-all route syntax, needed because Clerk’s sign-in flow uses multiple URL segments for OAuth callbacks.

Calling Your NestJS API with Auth

When a Server Component or Client Component needs to call your NestJS API, it must forward the Clerk JWT:

// lib/api-client.ts — API client for Server Components
import { auth } from '@clerk/nextjs/server';

export async function fetchFromApi<T>(path: string, options?: RequestInit): Promise<T> {
  // getToken() retrieves the JWT for the current session
  // This is the equivalent of reading the bearer token from HttpContext.Request.Headers
  const { getToken } = await auth();
  const token = await getToken();

  const response = await fetch(`${process.env.API_URL}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,  // Send JWT to your NestJS API
      ...options?.headers,
    },
  });

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}

// Usage in a Server Component
const orders = await fetchFromApi<Order[]>('/api/orders');
// For Client Components using TanStack Query, include the token in the query
'use client';
import { useAuth } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';

function OrdersList() {
  const { getToken } = useAuth();

  const { data: orders } = useQuery({
    queryKey: ['orders'],
    queryFn: async () => {
      const token = await getToken();
      const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/orders`, {
        headers: { Authorization: `Bearer ${token}` },
      });
      return response.json();
    },
  });

  return <ul>{orders?.map((o) => <li key={o.id}>{o.id}</li>)}</ul>;
}

NestJS Integration: Verifying Clerk JWTs

Your NestJS API receives the Clerk JWT and must verify it. Clerk signs JWTs with RS256 using a JWKS endpoint — the same mechanism as Azure AD tokens.

pnpm add @clerk/backend jwks-rsa jsonwebtoken
pnpm add -D @types/jsonwebtoken
// auth/clerk.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { createClerkClient } from '@clerk/backend';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

@Injectable()
export class ClerkGuard implements CanActivate {
  private clerk: ReturnType<typeof createClerkClient>;

  constructor(
    private config: ConfigService,
    private reflector: Reflector,
  ) {
    this.clerk = createClerkClient({
      secretKey: this.config.get<string>('CLERK_SECRET_KEY'),
    });
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // Check for @Public() decorator — skip auth for public routes
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    const request = context.switchToHttp().getRequest<Request>();
    const token = this.extractToken(request);

    if (!token) {
      throw new UnauthorizedException('No authentication token provided');
    }

    try {
      // Clerk verifies the JWT against their public keys
      // This is equivalent to .AddJwtBearer() validation in ASP.NET Core
      const payload = await this.clerk.verifyToken(token, {
        authorizedParties: [this.config.get<string>('FRONTEND_URL')!],
      });

      // Attach Clerk's session claims to the request
      // Equivalent to setting HttpContext.User with a ClaimsPrincipal
      request['auth'] = {
        userId: payload.sub,          // Clerk user ID — equivalent to ClaimTypes.NameIdentifier
        sessionId: payload.sid,
        metadata: payload.metadata,   // Custom metadata from Clerk
        orgId: payload.org_id,        // Organization ID (if using Clerk Organizations)
        orgRole: payload.org_role,    // 'org:admin' | 'org:member'
      };

      return true;
    } catch {
      throw new UnauthorizedException('Invalid or expired token');
    }
  }

  private extractToken(request: Request): string | null {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : null;
  }
}
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ClerkGuard } from './clerk.guard';
import { APP_GUARD } from '@nestjs/core';

@Module({
  imports: [ConfigModule],
  providers: [
    ClerkGuard,
    {
      // Register globally so all routes require auth by default
      // Equivalent to requiring [Authorize] on all controllers
      provide: APP_GUARD,
      useClass: ClerkGuard,
    },
  ],
  exports: [ClerkGuard],
})
export class AuthModule {}

Reading Auth Context in Controllers and Services

Define a type for the auth context and a custom decorator to extract it cleanly:

// auth/auth.types.ts
export interface AuthContext {
  userId: string;       // Clerk user ID (e.g., 'user_2abc...')
  sessionId: string;
  metadata: Record<string, unknown>;
  orgId?: string;       // Set if the user is acting within an organization
  orgRole?: string;     // 'org:admin' | 'org:member'
}

// auth/current-user.decorator.ts
// Custom parameter decorator — equivalent to a custom IActionResult parameter in ASP.NET
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): AuthContext => {
    const request = ctx.switchToHttp().getRequest();
    return request['auth']; // Set by ClerkGuard
  },
);
// orders/orders.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthContext } from '../auth/auth.types';

@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}

  @Get()
  findMyOrders(
    // @CurrentUser() is equivalent to reading User.FindFirst(ClaimTypes.NameIdentifier) in ASP.NET
    @CurrentUser() auth: AuthContext,
  ) {
    return this.ordersService.findByUser(auth.userId);
  }

  @Post()
  create(
    @CurrentUser() auth: AuthContext,
    @Body() dto: CreateOrderDto,
  ) {
    return this.ordersService.create(auth.userId, dto);
  }
}

Role-Based Authorization

Clerk supports two mechanisms for roles:

  1. User metadata — arbitrary JSON you store on the user (e.g., { role: 'admin' })
  2. Clerk Organizations — a first-class multi-tenant feature with built-in roles (org:admin, org:member)

Approach 1: Metadata-based roles (simpler, for single-tenant apps)

Set user metadata via the Clerk dashboard or API:

// In a webhook handler or admin endpoint:
await clerk.users.updateUserMetadata(userId, {
  publicMetadata: { role: 'admin' },
});

Metadata on the JWT is available in the metadata field of your AuthContext. Build a guard:

// auth/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles?.length) return true;

    const request = context.switchToHttp().getRequest();
    const auth: AuthContext = request['auth'];

    const userRole = (auth.metadata?.role as string) ?? 'user';
    if (!requiredRoles.includes(userRole)) {
      throw new ForbiddenException('Insufficient permissions');
    }

    return true;
  }
}

// Usage — equivalent to [Authorize(Roles = "Admin")] in ASP.NET
@Delete(':id')
@UseGuards(RolesGuard)
@Roles('admin')
deleteOrder(@Param('id', ParseIntPipe) id: number) {
  return this.ordersService.remove(id);
}

Approach 2: Clerk Organizations (for multi-tenant apps)

Clerk Organizations are the equivalent of Azure AD tenants + roles. Each organization has members with roles (org:admin or org:member). The active organization and role are included in the JWT automatically.

// auth/org-roles.guard.ts — require specific organization role
export const OrgRoles = (...roles: string[]) => SetMetadata('orgRoles', roles);

@Injectable()
export class OrgRolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<string[]>('orgRoles', [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles?.length) return true;

    const request = context.switchToHttp().getRequest();
    const auth: AuthContext = request['auth'];

    if (!auth.orgId) {
      throw new ForbiddenException('Must be acting within an organization');
    }

    // org_role is 'org:admin' or 'org:member' in Clerk's format
    const userOrgRole = auth.orgRole ?? '';
    const hasRole = requiredRoles.some((r) => userOrgRole === `org:${r}`);

    if (!hasRole) {
      throw new ForbiddenException('Insufficient organization permissions');
    }

    return true;
  }
}

// Usage
@Delete(':id')
@UseGuards(OrgRolesGuard)
@OrgRoles('admin')  // Only org:admin can delete
deleteOrder(@Param('id') id: string) { /* ... */ }

Webhook Integration

Clerk sends webhooks for user lifecycle events: user.created, user.updated, user.deleted, session.created, etc. This is equivalent to handling events from an identity provider in .NET (though ASP.NET Identity doesn’t have a built-in webhook system — you’d typically handle these in IdentityOptions callbacks or custom IUserStore implementations).

Use webhooks to:

  • Sync Clerk users to your own database
  • Trigger onboarding flows on user.created
  • Clean up user data on user.deleted
  • Log security events on session.created from a new device
pnpm add svix  # Clerk uses Svix for webhook delivery and signature verification
// webhooks/webhooks.controller.ts
import { Controller, Post, Headers, Body, HttpCode, RawBodyRequest } from '@nestjs/common';
import { Request } from 'express';
import { Webhook } from 'svix';
import { ConfigService } from '@nestjs/config';
import { Public } from '../auth/current-user.decorator';

// The webhook endpoint must be public — Clerk can't authenticate with your JWT
@Controller('webhooks')
export class WebhooksController {
  constructor(
    private readonly config: ConfigService,
    private readonly usersService: UsersService,
  ) {}

  @Post('clerk')
  @Public()  // Skip JWT auth — Clerk webhook verification handles security
  @HttpCode(200)
  async handleClerkWebhook(
    @Headers('svix-id') svixId: string,
    @Headers('svix-timestamp') svixTimestamp: string,
    @Headers('svix-signature') svixSignature: string,
    @Body() body: Buffer,  // Raw body required for signature verification
  ) {
    const webhookSecret = this.config.get<string>('CLERK_WEBHOOK_SECRET');
    const wh = new Webhook(webhookSecret);

    let event: WebhookEvent;
    try {
      // Verify the webhook signature — equivalent to validating HMAC in ASP.NET webhook handlers
      event = wh.verify(body, {
        'svix-id': svixId,
        'svix-timestamp': svixTimestamp,
        'svix-signature': svixSignature,
      }) as WebhookEvent;
    } catch {
      throw new BadRequestException('Invalid webhook signature');
    }

    switch (event.type) {
      case 'user.created':
        await this.usersService.createFromClerk(event.data);
        break;
      case 'user.updated':
        await this.usersService.updateFromClerk(event.data);
        break;
      case 'user.deleted':
        await this.usersService.deleteByClerkId(event.data.id);
        break;
    }

    return { received: true };
  }
}

To receive the raw request body (required for signature verification), configure NestJS to preserve it:

// main.ts — enable raw body parsing
const app = await NestFactory.create(AppModule, { rawBody: true });

Key Differences

ConcernASP.NET Identity / Azure ADClerkNotes
User storageYour SQL Server databaseClerk’s infrastructureClerk stores user records; you store a reference
Sign-up/sign-in UIScaffolded Razor pages or customPrebuilt React/Next.js componentsClerk’s UI components are customizable via the dashboard
Password hashingASP.NET Identity handles itClerk handles itYou never see passwords
JWT issuanceYour code (or Azure AD)Clerk issues JWTsYour API only verifies, never issues
JWT verificationAddJwtBearer() with your signing keyClerk’s SDK via JWKS endpointRS256; public keys fetched automatically
Claims / PrincipalClaimsPrincipal on HttpContext.Userauth object on Request (you set this)Same concept; different object model
Roles[Authorize(Roles = "Admin")] + DB rolesUser metadata or Clerk OrganizationsOrganization roles are built-in; custom roles use metadata
[AllowAnonymous]Attribute on controller/action@Public() custom decoratorSame concept; requires custom implementation
Email verificationASP.NET Identity token flowClerk handles automaticallyNo code required on your end
Social login (OAuth)Configure in Identity + callback handlersToggle in Clerk dashboardOne-click setup in Clerk
MFASeparate configuration + TOTP libraryToggle in Clerk dashboardNo code required
Session managementCookie/JWT, your responsibilityClerk manages sessionsClerk handles rotation and revocation
User management UIAdmin pages you buildClerk Dashboard (hosted)No code required for basic user management
Webhook eventsCustom implementationBuilt-in via Svixuser.created, user.deleted, etc.
PricePart of .NET / Azure AD costsFree tier; paid above 10k MAUEvaluate for your scale

Gotchas for .NET Engineers

Gotcha 1: Clerk’s User ID Is a String, Not an Integer

ASP.NET Identity uses GUID-based user IDs by default, but many .NET projects use integer IDs. Clerk user IDs are opaque strings in the format user_2abc123.... If you’re storing references to users in your database, your schema needs a String type (or varchar) for clerkId, not an integer.

// schema.prisma — correct approach
model Order {
  id        Int    @id @default(autoincrement())
  clerkId   String                        // Clerk user ID — String, not Int
  // ...
}

// WRONG: don't try to parse the Clerk ID as a number
const userId = parseInt(auth.userId); // NaN — will silently fail

If your existing database uses integer user IDs and you’re migrating to Clerk, you need an intermediate table:

model UserProfile {
  id        Int    @id @default(autoincrement())
  clerkId   String @unique   // Clerk's ID
  // Your existing integer-keyed data can reference this table
}

Gotcha 2: JWT Metadata Is Cached — Updates Are Not Instant

When you update user metadata via the Clerk API, the change is not reflected in the JWT immediately. JWTs have a lifespan (default: 60 seconds for Clerk’s short-lived tokens). Until the token expires and is refreshed, old metadata will appear in claims.

For frequently-changing authorization data (e.g., subscription status, feature flags), do not rely solely on JWT metadata. Instead, verify against your own database in the guard or service:

// For frequently changing state: verify against your DB, not just the JWT
@Injectable()
export class SubscriptionGuard implements CanActivate {
  constructor(private readonly subscriptionService: SubscriptionService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const auth: AuthContext = request['auth'];

    // Don't trust JWT metadata for subscription status — check the source of truth
    const isSubscribed = await this.subscriptionService.isActive(auth.userId);
    if (!isSubscribed) {
      throw new ForbiddenException('Active subscription required');
    }

    return true;
  }
}

For slow-changing data (e.g., user role in a multi-tenant app), JWT metadata is fine.

Gotcha 3: Webhook Endpoints Must Not Require JWT Authentication

This trips up .NET engineers who reflexively apply [Authorize] to all POST endpoints. Clerk’s webhook delivery has no way to obtain your JWT — it authenticates using its own HMAC signature scheme (via Svix). If your webhook endpoint requires JWT auth, Clerk’s requests will return 401 and webhooks will fail silently.

The pattern is: mark the webhook endpoint with @Public() (your JWT guard skips it), and rely on Svix signature verification for security. Never skip signature verification — an unprotected webhook endpoint is an unauthenticated POST endpoint that can execute code in your system.

@Post('clerk')
@Public()  // Required: skip JWT auth
async handleWebhook(/* ... */) {
  // Must verify Svix signature before processing
  try {
    event = wh.verify(body, headers);
  } catch {
    throw new BadRequestException('Invalid signature'); // Always reject unsigned requests
  }
  // ... safe to process now
}

Gotcha 4: The Frontend Needs the Publishable Key; the Backend Needs the Secret Key

Clerk has two keys with different security properties:

  • NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY — safe to expose to the browser (it’s designed to be public). This is how the Clerk SDK initializes in the browser.
  • CLERK_SECRET_KEY — must stay server-side only. Used by your NestJS API to verify tokens. Never expose it to the frontend or commit it to version control.
# .env (Next.js)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...   # Goes to the browser — safe
CLERK_SECRET_KEY=sk_test_...                    # Server-only — never expose

# .env (NestJS)
CLERK_SECRET_KEY=sk_test_...                    # Same key; NestJS needs it to verify tokens
CLERK_WEBHOOK_SECRET=whsec_...                  # For webhook signature verification

# .env.example (committed to git — shows required variables, no values)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
CLERK_WEBHOOK_SECRET=

In ASP.NET Core, the equivalent mistake is committing the JWT signing key in appsettings.json. Clerk’s separation of publishable vs. secret key makes this harder to get wrong, but you still need discipline with the secret key.

Gotcha 5: Organization Context Must Be Explicitly Set by the Frontend

Clerk supports multiple organizations per user (like Azure AD where a user can be a member of multiple tenants). When a user is a member of multiple organizations, your frontend must explicitly specify which organization the user is “acting as” — Clerk calls this the “active organization.”

If your backend checks auth.orgId and it’s undefined, the user may have organizations but hasn’t set one as active. Handle this explicitly:

// Frontend — set the active organization on sign-in or when switching orgs
'use client';
import { useOrganization, useOrganizationList } from '@clerk/nextjs';

function OrgSwitcher() {
  const { organization } = useOrganization();
  const { setActive, userMemberships } = useOrganizationList();

  return (
    <select
      value={organization?.id}
      onChange={(e) => setActive({ organization: e.target.value })}
    >
      {userMemberships.data?.map((membership) => (
        <option key={membership.organization.id} value={membership.organization.id}>
          {membership.organization.name}
        </option>
      ))}
    </select>
  );
}
// Backend — handle missing org context gracefully
canActivate(context: ExecutionContext): boolean {
  const auth: AuthContext = context.switchToHttp().getRequest()['auth'];

  if (!auth.orgId) {
    // User is authenticated but has no active organization
    // Either they haven't selected one, or they're a personal account user
    throw new ForbiddenException(
      'Please select an organization to continue',
    );
  }
  // ...
}

Hands-On Exercise

Build the complete auth flow for a multi-tenant task management API.

Setup:

  1. Create a Clerk application with email/password and Google sign-in enabled
  2. Create a Next.js app with the Clerk middleware configured
  3. Create a NestJS API with the Clerk guard registered globally

Implement:

  1. Next.js pages:

    • /sign-in — Clerk <SignIn /> component
    • /sign-up — Clerk <SignUp /> component
    • /dashboard — Protected page showing the current user’s name and email
    • / — Public landing page with a sign-in link
  2. NestJS endpoints:

    • GET /api/me — Returns { userId, email } from the JWT claims. Requires auth.
    • GET /api/tasks — Returns tasks for the authenticated user. Requires auth.
    • POST /api/tasks — Creates a task for the authenticated user. Requires auth.
    • DELETE /api/tasks/:id — Deletes a task. Requires auth + ownership check (only the task owner can delete).
    • GET /api/health — Public health check. No auth required.
  3. Webhook handler:

    • POST /api/webhooks/clerk — Public endpoint with Svix verification
    • On user.created: log "New user: {userId}" to the console
    • On user.deleted: log "Deleted user: {userId}" to the console
  4. Test the full flow:

    • Sign up as a new user
    • Verify /api/health returns 200 without auth
    • Verify /api/tasks returns 401 without a token
    • From the Next.js dashboard, call /api/tasks with the Clerk token — verify it returns 200
    • Create a task, then attempt to delete another user’s task — verify it returns 403

Quick Reference

Clerk SDK Packages

PackageUsed InPurpose
@clerk/nextjsNext.js frontendClerkProvider, auth(), useUser(), useAuth(), components
@clerk/backendNestJS APIcreateClerkClient(), verifyToken(), user management API
svixNestJS webhook handlerWebhook signature verification

Auth State — Where to Read It

ContextHow to Get the UserEquivalent in .NET
Next.js Server Componentconst { userId } = await auth()HttpContext.User via IHttpContextAccessor
Next.js Client Componentconst { user } = useUser()Injected via Blazor or JS interop
Next.js middlewareauth.protect()UseAuthorization()
NestJS controller/service@CurrentUser() auth: AuthContextUser.FindFirst(...) from HttpContext.User

Common Clerk Patterns

// Protect all routes, allow specific ones to be public (Next.js middleware)
clerkMiddleware((auth, req) => {
  if (!isPublicRoute(req)) auth.protect();
});

// Get current user ID in a Server Component
const { userId } = await auth();

// Get full user object (makes an API call — use sparingly)
const user = await currentUser();

// Custom @Public() decorator (NestJS)
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

// Read in ClerkGuard
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
  context.getHandler(),
  context.getClass(),
]);
if (isPublic) return true;

// Get token for API calls (Client Component)
const { getToken } = useAuth();
const token = await getToken();
headers: { Authorization: `Bearer ${token}` }

// Get token for API calls (Server Component)
const { getToken } = await auth();
const token = await getToken();
headers: { Authorization: `Bearer ${token}` }

Environment Variable Checklist

# Next.js (.env.local)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...   # Required — Clerk frontend SDK
CLERK_SECRET_KEY=sk_test_...                    # Required — Clerk backend verification
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in          # Optional — customize sign-in URL
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up          # Optional — customize sign-up URL

# NestJS (.env)
CLERK_SECRET_KEY=sk_test_...                    # Same key as Next.js backend
CLERK_WEBHOOK_SECRET=whsec_...                  # From Clerk Dashboard > Webhooks
FRONTEND_URL=http://localhost:3000              # For JWT authorizedParties validation

Further Reading