← All posts
·12 min read

Claude Code with Clerk: Authentication Done Right

Claude CodeClerkAuthenticationWorkflow
Claude Code with Clerk: Authentication Done Right

Why Clerk needs more guardrails than most integrations

Authentication is the part of an application where AI-generated code goes wrong most quietly. A broken UI fails loudly, a broken query fails on the first request, but a broken auth check sits there in production protecting nothing, and you only find out when somebody else does.

Clerk handles sign-up, sign-in, MFA, social providers, organisations, and billing through a few well-designed primitives. The hard part is wiring those primitives into your application correctly, especially in a Next.js App Router project where the boundary between server and client components is where most auth bugs hide.

Claude Code understands Clerk well. It knows the SDK, the helpers, the webhook payloads, and the middleware patterns. What it does not know is your project: which routes are public, which require organisation membership, which require a specific plan, and where your webhooks are mounted. Without that context, Claude generates code that looks like Clerk code but quietly leaves route groups unprotected, mixes server-only helpers into client components, and skips webhook signature verification.

This guide covers the CLAUDE.md configuration and patterns that prevent those failures. If you are new to Claude Code, the Claude Code setup guide covers installation first.

The Clerk CLAUDE.md template

The CLAUDE.md at your project root is read before every Claude Code session. For a Clerk-protected application, it needs to answer: which Clerk SDK and Next.js version are in use, where is middleware mounted, which routes are public, how is server-side auth read, where do webhooks live, and what are the hard rules that prevent insecure auth code.

# Clerk authentication rules

## Stack
- Next.js 15.x (App Router only)
- @clerk/nextjs 6.x, svix 1.x, TypeScript 5.x strict

## Project structure
- middleware.ts: clerkMiddleware at root, matcher excludes static
- app/api/webhooks/clerk/route.ts: receiver with svix verification
- lib/auth.ts: server helpers (currentUser, requireUser, requireOrg)
- components/auth/: client components using Clerk hooks
- app/(public)/, app/(app)/, app/(org)/: route groups by auth posture

## Environment (read from .env.local, never hardcode)
- NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, CLERK_WEBHOOK_SECRET
- NEXT_PUBLIC_CLERK_SIGN_IN_URL, _SIGN_UP_URL, _AFTER_SIGN_IN_URL, _AFTER_SIGN_UP_URL

## Public routes (allowlist)
- /, /sign-in, /sign-up, /api/webhooks/*, /pricing, /blog/*

## Hard rules
- NEVER call auth() or currentUser() in client components
- NEVER read userId from headers or cookies, always use auth()
- NEVER skip webhook signature verification, even in dev
- NEVER trust org membership or plan state from client, verify server-side
- ALL mutations on user data must verify userId match (IDOR check)
- Public routes are an explicit allowlist in middleware, not a denylist

Three rules here prevent the most common Clerk failures Claude produces without context.

The public routes allowlist matters because Claude defaults to denylist patterns when conventions are unclear. A denylist fails open when you forget a new route; an allowlist fails closed. Every route Claude scaffolds is protected by default.

The server-only auth helpers rule prevents Claude importing auth() from @clerk/nextjs/server into a 'use client' component. This compiles, sometimes runs in dev, and breaks in production. The rule pushes Claude towards useUser() and useAuth() for client components.

The mutation IDOR rule is the single most useful auth rule. Every mutation must verify the authenticated userId matches the record's owner. Without it, Claude generates handlers that take a userId from the request body and trust it. With it, Claude reads userId from auth() and rejects mismatched requests.

Middleware and route protection

Middleware is where your auth posture is set. In a Clerk-protected Next.js App Router app, clerkMiddleware runs on every request, attaches the auth context, and lets you protect or expose routes. Get this file right and the rest of the application inherits the correct defaults.

Add a middleware section to your CLAUDE.md:

## Middleware patterns (middleware.ts)

### Standard middleware with public route allowlist
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isPublicRoute = createRouteMatcher([
  "/", "/sign-in(.*)", "/sign-up(.*)", "/api/webhooks/(.*)", "/pricing", "/blog(.*)",
]);
const isOrgRoute = createRouteMatcher(["/team(.*)", "/billing(.*)"]);

export default clerkMiddleware(async (auth, req) => {
  if (isPublicRoute(req)) return;
  await auth.protect();

  if (isOrgRoute(req)) {
    const { orgId } = await auth();
    if (!orgId) return Response.redirect(new URL("/select-organisation", req.url));
  }
});

export const config = {
  matcher: [
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico)).*)",
    "/(api|trpc)(.*)",
  ],
};

The createRouteMatcher helper compiles route patterns once and reuses them across requests. Claude tends to inline string comparisons or ad-hoc regex when the pattern is not in CLAUDE.md. With the helper established as convention, every new public or org-scoped route gets added to the matcher rather than triggering bespoke logic.

The auth.protect() call is the modern replacement for the manual "redirect if not signed in" pattern. It throws a redirect that Next.js handles automatically. Claude will still occasionally write the older manual redirect from outdated Clerk docs, so having auth.protect() in CLAUDE.md anchors it as the default.

The matcher regex excludes static file extensions explicitly while still running on all dynamic routes and the API. Most Clerk tutorials show a simpler matcher that runs middleware on every request including static assets, which adds latency to every image and font. Claude copies this matcher exactly when it is in CLAUDE.md.

Server vs client auth patterns

This is the boundary where most Clerk bugs are introduced. Server Components, Server Actions, and Route Handlers use one set of helpers. Client Components use a different set. Mixing them produces code that builds locally and fails in production.

Add a server vs client section to CLAUDE.md:

## Server-side auth (Server Components, Server Actions, Route Handlers)

### Reading the current user
import { auth, currentUser } from "@clerk/nextjs/server";

// Lightweight check, returns userId and orgId
const { userId, orgId } = await auth();

// Full user object with email, image, metadata
const user = await currentUser();

### Server Action with IDOR check
"use server";
import { auth } from "@clerk/nextjs/server";

export async function updateProfile(input: ProfileInput) {
  const { userId } = await auth();
  if (!userId) throw new Error("Unauthorised");

  const existing = await db.profile.findUnique({ where: { id: input.id } });
  if (!existing || existing.userId !== userId) throw new Error("Forbidden");

  return db.profile.update({ where: { id: input.id }, data: input.data });
}

### lib/auth.ts: centralised guards
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";

export async function requireUser() {
  const { userId } = await auth();
  if (!userId) redirect("/sign-in");
  return userId;
}

export async function requireOrg() {
  const { userId, orgId } = await auth();
  if (!userId) redirect("/sign-in");
  if (!orgId) redirect("/select-organisation");
  return { userId, orgId };
}
// Client-side auth (Client Components only)
"use client";
import { useUser, useAuth } from "@clerk/nextjs";

export function ProfileBadge() {
  const { isLoaded, isSignedIn, user } = useUser();
  if (!isLoaded) return <Skeleton />;
  if (!isSignedIn) return null;
  return <span>{user.firstName}</span>;
}

export function ProtectedAction() {
  const { isSignedIn, getToken } = useAuth();
  if (!isSignedIn) return null;
  const callApi = async () => {
    const token = await getToken();
    await fetch("/api/secure", { headers: { Authorization: `Bearer ${token}` } });
  };
  return <button onClick={callApi}>Run</button>;
}

The requireUser and requireOrg helpers in lib/auth.ts are the single most useful pattern in a Clerk codebase. Once Claude knows they exist, every new Server Action and Route Handler gets the auth check at the top. Without them, Claude inlines the same if (!userId) throw check repeatedly, which is fine until somebody forgets it.

The IDOR check pattern in updateProfile is what the hard rule in CLAUDE.md produces. Claude generates this check on every user-data mutation when the rule is in place. Without it, Claude trusts whatever id arrives in the request body. See Claude Code with TypeScript for the broader input-validation conventions that compose with this.

The client hook pattern with useUser returning isLoaded matters because Clerk hydrates user data asynchronously. Claude will sometimes render based on isSignedIn alone, producing a flash of incorrect UI on first load. The isLoaded guard prevents that flash.

Webhooks and user lifecycle

Webhooks are how you keep your own database in sync with Clerk. When a user signs up, updates their profile, or is deleted, Clerk fires a webhook that your application receives, verifies, and uses to update local records. Skipping signature verification is the single most common Clerk security failure.

Add a webhooks section to CLAUDE.md:

## Webhook receiver pattern (app/api/webhooks/clerk/route.ts)

import { Webhook } from "svix";
import { headers } from "next/headers";
import type { WebhookEvent } from "@clerk/nextjs/server";

export async function POST(req: Request) {
  const secret = process.env.CLERK_WEBHOOK_SECRET;
  if (!secret) throw new Error("CLERK_WEBHOOK_SECRET not set");

  const headerPayload = await headers();
  const svixId = headerPayload.get("svix-id");
  const svixTimestamp = headerPayload.get("svix-timestamp");
  const svixSignature = headerPayload.get("svix-signature");

  if (!svixId || !svixTimestamp || !svixSignature) {
    return new Response("Missing svix headers", { status: 400 });
  }

  const body = await req.text();
  const wh = new Webhook(secret);

  let event: WebhookEvent;
  try {
    event = wh.verify(body, {
      "svix-id": svixId,
      "svix-timestamp": svixTimestamp,
      "svix-signature": svixSignature,
    }) as WebhookEvent;
  } catch (err) {
    return new Response("Invalid signature", { status: 401 });
  }

  switch (event.type) {
    case "user.created":
    case "user.updated":
      await db.user.upsert({
        where: { clerkId: event.data.id },
        create: { clerkId: event.data.id, email: event.data.email_addresses[0]?.email_address ?? "" },
        update: { email: event.data.email_addresses[0]?.email_address ?? "" },
      });
      break;
    case "user.deleted":
      if (event.data.id) await db.user.delete({ where: { clerkId: event.data.id } });
      break;
    case "organizationMembership.created":
      // sync membership records
      break;
  }

  return new Response("ok", { status: 200 });
}

### Hard rules
- ALWAYS verify svix signature before processing the payload
- ALWAYS read the secret from env, never inline
- ALWAYS use req.text() for the body, not req.json() (svix needs raw bytes)
- Webhook handlers must be idempotent: receiving the same event twice must not corrupt state

The req.text() rule is subtle and catches Claude every time without it. Svix verifies the signature against the exact bytes of the request body, so parsing with req.json() and re-serialising changes the bytes and fails verification. Claude defaults to req.json() because most Next.js route handlers parse JSON. The CLAUDE.md rule anchors req.text() followed by manual parsing after verification succeeds.

The idempotency rule prevents data corruption when Clerk retries a webhook, which it will. Without it Claude generates an INSERT for user.created that fails on duplicate key the second time, leaving the queue stuck. With the rule, Claude writes upsert patterns so retried webhooks are no-ops.

Env var handling here connects to the broader pattern in Claude Code with environment variables: keys read from process.env, never inlined, never logged.

Organisations and billing integration

Clerk's organisations feature is what makes it useful for B2B SaaS. Users can belong to multiple organisations, each organisation has roles and permissions, and you can scope most of your application data by orgId rather than userId. The Clerk billing integration ties subscription state to either the user or the organisation, depending on your model.

Add an organisations section to CLAUDE.md:

## Organisations (B2B multi-tenant pattern)

### Reading and scoping by org
const { userId, orgId, orgRole, orgSlug } = await auth();
// orgId null = no orgs or none selected; orgRole = "org:admin" | "org:member" | custom
const projects = await db.project.findMany({ where: { orgId } });

### Role-gated mutation
export async function deleteProject(projectId: string) {
  const { userId, orgId, orgRole } = await auth();
  if (!userId || !orgId) throw new Error("Unauthorised");
  if (orgRole !== "org:admin") throw new Error("Forbidden: admin required");

  const project = await db.project.findUnique({ where: { id: projectId } });
  if (!project || project.orgId !== orgId) throw new Error("Not found");

  return db.project.delete({ where: { id: projectId } });
}

### Switching organisations (client-side)
"use client";
import { OrganizationSwitcher } from "@clerk/nextjs";

export function OrgSwitcher() {
  return <OrganizationSwitcher hidePersonal afterSelectOrganizationUrl="/dashboard" />;
}

## Billing (Clerk Billing)

### Plan and feature gating on the server
const { has } = await auth();
if (!has({ plan: "pro" })) throw new Error("Upgrade required");
const canExport = has({ feature: "export_data" });

### Hard rules
- NEVER trust plan state from client, always verify with auth().has() server-side
- ALL paid features gate at the Server Action / Route Handler boundary
- subscription.updated webhooks must invalidate any cached plan state

The has() helper is the single API for checking both plans and features. Without the pattern in CLAUDE.md, Claude tends to write custom plan-checking logic by querying user metadata or hitting the Clerk API. With has() as convention, Claude generates the correct server-evaluated check every time.

The role check orgRole !== "org:admin" is verbose by design. A concise if (!isAdmin()) would hide the actual comparison and make security-sensitive code harder to audit. For projects that integrate Stripe directly rather than through Clerk Billing, Claude Code with Stripe covers the lower-level subscription handling.

The org-scoping pattern in findMany looks trivial but is where most multi-tenant data leaks happen. Claude will sometimes drop the orgId filter when generating a new query if the existing pattern is not visible. Putting the canonical query shape in CLAUDE.md anchors Claude to filter by orgId on every read.

Permission hooks for auth-touching scripts

Authentication projects often include scripts that interact with the Clerk API directly: backfilling local users, deleting test accounts, syncing subscription state. These range from safe to destructive. The permission hook system gates them explicitly; the full mechanism is documented in Claude Code permissions.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(pnpm dev*)",
      "Bash(pnpm build*)",
      "Bash(pnpm test*)",
      "Bash(pnpm typecheck*)",
      "Bash(pnpm tsx scripts/list-users.ts*)"
    ],
    "deny": [
      "Bash(pnpm tsx scripts/delete-user.ts*)",
      "Bash(pnpm tsx scripts/bulk-delete-test-users.ts*)",
      "Bash(pnpm tsx scripts/reset-org-memberships.ts*)"
    ]
  }
}

This lets Claude run the dev server, build, tests, type-checking, and the read-only user listing script. Destructive scripts are gated behind explicit user confirmation. The general workflow patterns are covered in Claude Code best practices.

Hard rules and what to review manually

Claude Code generates excellent Clerk code in several areas. Middleware with createRouteMatcher, server-side auth() checks, webhook receivers with svix verification, and orgId-scoped queries are all consistently correct when the patterns are in CLAUDE.md.

Three areas warrant manual review.

The first is the public route allowlist. Every new public route is a deliberate decision, and Claude cannot infer which routes you intend to expose. Treat each addition to the public matcher as a security review: confirm the route handles its own auth where needed and no sensitive data leaks.

The second is webhook event handlers. Clerk fires events for many lifecycle moments and the right action depends on your data model. Implicit constraints (a user must always have a default org, a billing event must trigger a feature reset) need to be encoded in CLAUDE.md or reviewed manually.

The third is role and permission logic. has() and orgRole checks are mechanical, but which roles can do what is policy. Write it out in CLAUDE.md or a lib/permissions.ts file Claude can read. The patterns for surfacing these across components are covered in Claude Code with React. For deployment, Claude Code with Vercel covers env var promotion that composes with Clerk's preview-environment keys. For Supabase as the local database, Claude Code with Supabase covers row-level security that complements Clerk's user-level checks.

Building on a secure foundation

The Clerk CLAUDE.md in this guide produces an auth implementation where middleware protects routes by default, server and client helpers stay on their correct sides, webhooks verify before processing, org and billing scopes are honoured on every query, and destructive scripts are gated behind permission hooks.

The principle is the same as any framework integration: Claude Code performs at the level of context you give it. A project without CLAUDE.md produces Claude that uses denylist patterns, mixes server and client helpers, skips signature verification, and trusts client-side plan state. A project with the configuration above fails closed by default.

For the mechanics of how CLAUDE.md is read across a session, see CLAUDE.md explained. For the App Router patterns this Clerk setup depends on, Claude Code with Next.js covers the conventions. Claudify includes a Clerk-specific CLAUDE.md template, pre-configured for App Router middleware, organisation scopes, webhook signature verification, and IDOR prevention.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir