Tutorials
|stacknotice.com
16 min left|
0%
|3,200 words
Tutorials

Auth in Production: What Clerk Doesn't Document (2026)

Beyond the quickstart: database sync, webhook idempotency, RBAC with custom claims, and what to do when Clerk goes down. SaaS Series #2.

May 21, 202616 min read
Share:
Auth in Production: What Clerk Doesn't Document (2026)

The Clerk quickstart takes 15 minutes. Getting auth right in production takes 15 hours — and most of those hours are spent figuring out things the documentation doesn't cover upfront.

This is the second article in the Build a SaaS from $0 to Real Product series. We're using Clerk as our auth provider (see Clerk vs Better Auth for why), and this article covers everything that comes after npm install @clerk/nextjs.

What this article assumes

You've completed the Clerk quickstart — middleware configured, ClerkProvider in layout, basic sign-in working. We're picking up from there.

The problem: Clerk stores users, your app needs users

The first thing that trips everyone up: Clerk is not your database. Clerk stores authentication data — email, password hash, OAuth tokens, MFA state. But your application has users too: plans, preferences, team memberships, usage data, billing records.

The moment you try to SELECT * FROM users WHERE id = ? and reference a Clerk user, you realize you need a sync layer.

There are two approaches:

Option A: Always fetch from Clerk — Use currentUser() everywhere, store nothing in your DB except a clerkId foreign key. Works for simple apps. Falls apart the moment you need to JOIN users with anything.

Option B: Sync Clerk → your DB via webhooks — The right approach for production. Your DB has the full user record, Clerk handles auth, webhooks keep them in sync.

Setting up the sync webhook

Clerk fires webhook events on every user lifecycle action. You need to handle them and mirror the data to your database.

Step 1 — Create the endpoint

app/api/webhooks/clerk/route.ts
import { headers } from 'next/headers'
import { Webhook } from 'svix'
import { WebhookEvent } from '@clerk/nextjs/server'
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
 
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET
 
if (!WEBHOOK_SECRET) {
  throw new Error('CLERK_WEBHOOK_SECRET environment variable is required')
}
 
export async function POST(req: Request) {
  // 1. Verify the webhook signature (Clerk uses Svix)
  const headersList = await headers()
  const svix_id = headersList.get('svix-id')
  const svix_timestamp = headersList.get('svix-timestamp')
  const svix_signature = headersList.get('svix-signature')
 
  if (!svix_id || !svix_timestamp || !svix_signature) {
    return new Response('Missing Svix headers', { status: 400 })
  }
 
  const body = await req.text()
  const wh = new Webhook(WEBHOOK_SECRET)
 
  let event: WebhookEvent
 
  try {
    event = wh.verify(body, {
      'svix-id': svix_id,
      'svix-timestamp': svix_timestamp,
      'svix-signature': svix_signature,
    }) as WebhookEvent
  } catch {
    return new Response('Invalid webhook signature', { status: 400 })
  }
 
  // 2. Handle events idempotently
  const { id: clerkId } = event.data as { id: string }
 
  switch (event.type) {
    case 'user.created': {
      const data = event.data
      await db.insert(users).values({
        clerkId: data.id,
        email: data.email_addresses[0]?.email_address ?? '',
        name: `${data.first_name ?? ''} ${data.last_name ?? ''}`.trim(),
        imageUrl: data.image_url,
        plan: 'free',
        createdAt: new Date(data.created_at),
      }).onConflictDoNothing() // idempotent — safe to replay
      break
    }
 
    case 'user.updated': {
      const data = event.data
      await db.update(users)
        .set({
          email: data.email_addresses[0]?.email_address ?? '',
          name: `${data.first_name ?? ''} ${data.last_name ?? ''}`.trim(),
          imageUrl: data.image_url,
          updatedAt: new Date(),
        })
        .where(eq(users.clerkId, data.id))
      break
    }
 
    case 'user.deleted': {
      if (clerkId) {
        // Soft delete — never hard delete user data
        await db.update(users)
          .set({ deletedAt: new Date() })
          .where(eq(users.clerkId, clerkId))
      }
      break
    }
  }
 
  return new Response('OK', { status: 200 })
}
Always use onConflictDoNothing()

Webhooks can be delivered more than once. Clerk retries failed deliveries for up to 7 days. Every write in your webhook handler must be idempotent — safe to run multiple times with the same data. Use onConflictDoNothing() for inserts and make updates deterministic.

Step 2 — Your database schema

lib/db/schema.ts
import { pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core'
 
export const users = pgTable('users', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clerkId: varchar('clerk_id', { length: 256 }).unique().notNull(),
  email: varchar('email', { length: 512 }).notNull(),
  name: varchar('name', { length: 256 }),
  imageUrl: text('image_url'),
  plan: varchar('plan', { length: 50 }).notNull().default('free'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
  deletedAt: timestamp('deleted_at'), // soft delete
})

Step 3 — Register the webhook in Clerk dashboard

  1. Go to Clerk Dashboard → Webhooks → Add Endpoint
  2. URL: https://yourdomain.com/api/webhooks/clerk
  3. Subscribe to: user.created, user.updated, user.deleted
  4. Copy the Signing Secret → add as CLERK_WEBHOOK_SECRET in your env

For local development, use the Svix CLI:

Local webhook forwarding
npx svix-cli listen https://your-app.localhost.run/api/webhooks/clerk

The helper you'll use everywhere

Once users are in your DB, you need a way to get the current user's full record — not just the Clerk session. Build this helper once and use it everywhere:

lib/auth.ts
import { auth, currentUser } from '@clerk/nextjs/server'
import { db } from './db'
import { users } from './db/schema'
import { eq } from 'drizzle-orm'
import { redirect } from 'next/navigation'
 
export async function getAuthUser() {
  const { userId } = await auth()
 
  if (!userId) {
    redirect('/sign-in')
  }
 
  const user = await db.query.users.findFirst({
    where: eq(users.clerkId, userId),
  })
 
  if (!user) {
    // Edge case: user exists in Clerk but not in DB yet
    // (webhook hasn't fired yet — race condition on first sign-in)
    // Trigger a sync manually
    const clerkUser = await currentUser()
    if (!clerkUser) redirect('/sign-in')
 
    const [created] = await db.insert(users).values({
      clerkId: clerkUser.id,
      email: clerkUser.emailAddresses[0]?.emailAddress ?? '',
      name: `${clerkUser.firstName ?? ''} ${clerkUser.lastName ?? ''}`.trim(),
      imageUrl: clerkUser.imageUrl,
      plan: 'free',
    }).onConflictDoNothing().returning()
 
    return created
  }
 
  return user
}
The race condition you will hit

When a user signs up and is immediately redirected to the dashboard, the user.created webhook may not have fired yet. Your DB has no record of them. The getAuthUser() helper above handles this by falling back to a manual sync — it creates the user on the spot. This is the correct production pattern.

Usage in any Server Component or Route Handler:

app/dashboard/page.tsx
import { getAuthUser } from '@/lib/auth'
 
export default async function DashboardPage() {
  const user = await getAuthUser() // redirects to /sign-in if not authenticated
 
  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <p>Plan: {user.plan}</p>
    </div>
  )
}

RBAC with custom session claims

Clerk's auth() returns userId, orgId, orgRole — but what about your own roles? Maybe a user is an admin in your system, not just in an organization.

The right way to handle this is custom session claims — metadata embedded in the JWT that Clerk issues.

Step 1 — Add metadata when you change a user's role

app/api/admin/promote/route.ts
import { clerkClient } from '@clerk/nextjs/server'
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { getAuthUser } from '@/lib/auth'
 
export async function POST(req: Request) {
  const currentUser = await getAuthUser()
 
  // Only admins can promote
  if (currentUser.role !== 'admin') {
    return Response.json({ error: 'Forbidden' }, { status: 403 })
  }
 
  const { targetUserId } = await req.json()
 
  // Update in your DB
  await db.update(users)
    .set({ role: 'admin' })
    .where(eq(users.id, targetUserId))
 
  // Sync to Clerk public metadata (embedded in JWT)
  const client = await clerkClient()
  await client.users.updateUserMetadata(targetUserId, {
    publicMetadata: { role: 'admin' },
  })
 
  return Response.json({ ok: true })
}

Step 2 — Read the role from the session (no DB query needed)

lib/auth.ts — extended
import { auth } from '@clerk/nextjs/server'
 
export async function getRole() {
  const { sessionClaims } = await auth()
  // publicMetadata is included in sessionClaims automatically
  return (sessionClaims?.metadata as { role?: string })?.role ?? 'user'
}
 
export async function requireAdmin() {
  const role = await getRole()
  if (role !== 'admin') {
    throw new Error('Forbidden')
  }
}
Public vs Private metadata
  • publicMetadata: visible to the client, embedded in the JWT. Use for role, plan, features.
  • privateMetadata: server-only, not in the JWT. Use for internal flags, provider IDs.
  • unsafeMetadata: set by the client. Never trust for auth decisions.

Step 3 — Configure Clerk to include metadata in session claims

In Clerk Dashboard → Sessions → Customize session token, add:

Session token customization
{
  "metadata": "{{user.public_metadata}}"
}

Now sessionClaims.metadata.role is available in every auth() call — zero database queries for role checks.

Organization membership sync (B2B SaaS)

If you're building a workspace-based SaaS, users belong to organizations. You need to sync org membership to your DB for things like billing (charge per org, not per user) and feature access.

app/api/webhooks/clerk/route.ts — add organization events
// Add these cases to your webhook switch statement:
 
case 'organizationMembership.created': {
  const { organization, public_user_data } = event.data
  await db.insert(orgMembers).values({
    orgId: organization.id,
    clerkUserId: public_user_data.user_id,
    role: event.data.role,
  }).onConflictDoNothing()
  break
}
 
case 'organizationMembership.deleted': {
  await db.delete(orgMembers)
    .where(
      and(
        eq(orgMembers.orgId, event.data.organization.id),
        eq(orgMembers.clerkUserId, event.data.public_user_data.user_id)
      )
    )
  break
}
 
case 'organization.created': {
  await db.insert(organizations).values({
    clerkOrgId: event.data.id,
    name: event.data.name,
    plan: 'free',
    createdAt: new Date(event.data.created_at),
  }).onConflictDoNothing()
  break
}
lib/auth.ts — org-aware helper
export async function getAuthOrg() {
  const { userId, orgId } = await auth()
 
  if (!userId || !orgId) redirect('/select-org')
 
  const org = await db.query.organizations.findFirst({
    where: eq(organizations.clerkOrgId, orgId),
    with: { members: true },
  })
 
  if (!org) redirect('/select-org')
  return org
}

What happens when Clerk goes down

Clerk has a solid uptime record, but outages happen. What does your app do?

The naive approach: every page calls auth() → if Clerk is down → every page errors.

The resilient approach: cache the session validation result for the duration of a request. Clerk's SDK already does this with React cache, but for API routes you may want explicit fallback behavior:

lib/auth.ts — with timeout
import { auth } from '@clerk/nextjs/server'
 
export async function getAuthSafe(timeoutMs = 3000) {
  const timeoutPromise = new Promise<null>((resolve) =>
    setTimeout(() => resolve(null), timeoutMs)
  )
 
  const authPromise = auth().then(({ userId }) => userId)
 
  const userId = await Promise.race([authPromise, timeoutPromise])
 
  return userId
}

For public-facing pages, consider caching the rendered HTML at the edge (Vercel's full-route cache) so authenticated state is not required for the initial render. Protected routes should always verify — there's no safe way to cache auth decisions.

Don't cache auth results in memory

Never store session validation results in a module-level variable or Redis with a long TTL to avoid Clerk calls. If a user's session is revoked (logout, suspicious activity, password change), your cache will keep serving them as authenticated. Always validate per-request.

Admin impersonation

You'll need to debug user issues. Being able to log in as a specific user (without knowing their password) is essential for support.

Clerk has a built-in impersonation feature in the Enterprise plan. For the Pro plan, build it manually:

app/api/admin/impersonate/route.ts
import { clerkClient, auth } from '@clerk/nextjs/server'
import { getRole } from '@/lib/auth'
 
export async function POST(req: Request) {
  const role = await getRole()
  if (role !== 'admin') {
    return Response.json({ error: 'Forbidden' }, { status: 403 })
  }
 
  const { targetUserId } = await req.json()
 
  // Create a sign-in token for the target user
  const client = await clerkClient()
  const { token } = await client.signInTokens.createSignInToken({
    userId: targetUserId,
    expiresInSeconds: 300, // 5 minutes — short-lived
  })
 
  // Log the impersonation attempt for audit
  await db.insert(auditLog).values({
    action: 'admin.impersonate',
    actorId: (await auth()).userId!,
    targetId: targetUserId,
    createdAt: new Date(),
  })
 
  // Return the token — frontend uses it to sign in as that user
  return Response.json({ token })
}
Always log impersonation

Every impersonation must be written to an audit log. This is both a security requirement and essential for debugging — you need to know who was logged in as whom when a support issue occurred.

The middleware you actually want

Most examples show a simple clerkMiddleware() call. Production apps need more:

middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
 
const isPublicRoute = createRouteMatcher([
  '/',
  '/blog(.*)',
  '/pricing',
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/api/webhooks(.*)', // webhooks must be public — Clerk can't auth itself
])
 
const isAdminRoute = createRouteMatcher(['/admin(.*)'])
 
export default clerkMiddleware(async (auth, req) => {
  // Public routes — no auth required
  if (isPublicRoute(req)) return NextResponse.next()
 
  // Everything else — require auth
  const { userId, orgId, sessionClaims } = await auth.protect()
 
  // Admin routes — require admin role in session claims
  if (isAdminRoute(req)) {
    const role = (sessionClaims?.metadata as any)?.role
    if (role !== 'admin') {
      return NextResponse.redirect(new URL('/dashboard', req.url))
    }
  }
 
  return NextResponse.next()
})
 
export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
}

Remember: middleware redirects and checks are UX. Always verify auth again in Server Components and Route Handlers. See the CVE-2025-29927 section in the Clerk vs Better Auth guide for why.

Environment variables checklist

.env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_SECRET_KEY=sk_live_...
CLERK_WEBHOOK_SECRET=whsec_...
 
# Optional — customize redirect URLs
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
Never expose CLERK_SECRET_KEY to the client

Any env variable without NEXT_PUBLIC_ prefix is server-only in Next.js. The CLERK_SECRET_KEY must stay server-side — it can create sessions, delete users, and access any Clerk data.

Testing your auth setup

Before going to production, verify these scenarios manually:

1
New user sign-up

Sign up with a fresh email. Check that user.created webhook fires, record appears in your DB within 2-3 seconds. Check the race condition path by hitting /dashboard immediately after sign-up.

2
Webhook replay

In Clerk Dashboard → Webhooks → your endpoint → Attempts, manually replay a user.created event. Confirm your DB has no duplicate records.

3
Role escalation

Create a user, promote them to admin via your API, sign out and sign back in (new session token needed for claims to update), verify they can access /admin routes.

4
Session revocation

In Clerk Dashboard, revoke a user's active sessions. Confirm they're redirected to sign-in on the next request (within one polling interval, default 5 minutes with JWT templates).

5
Webhook failure recovery

Temporarily return a 500 from your webhook endpoint. Send a user event from Clerk. Restore the endpoint. Verify Clerk retried and your DB is eventually consistent.

What's next in the series

The auth layer is solid. Next up: database migrations without downtime — how to add columns, change types, and rename tables in a live production database without taking your app offline.

If you haven't read it yet, the SaaS stack overview covers why we chose Clerk + Drizzle + Neon and what the full architecture looks like.

#nextjs#clerk#authentication#saas#production
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.