Auth & Sicherheit Next.js TypeScript

Clerk Auth mit Claude Code: Next.js Authentication 2026

📅 6. Mai 2026 ⏱ 11 min Lesezeit ✍️ Agentic Movers Team

Authentication ist eine der komplexesten und fehleranfälligsten Komponenten moderner Webanwendungen. Clerk hat sich als führende Lösung für Next.js-Projekte etabliert — und Claude Code verändert grundlegend, wie schnell du eine vollständige Auth-Integration aufbaust. In diesem Artikel zeigen wir dir die gesamte Clerk-Integration: von der Middleware-Konfiguration bis zu Custom Claims, Organizations und Webhooks.

Was du in diesem Artikel lernst:

1. Clerk Setup & Next.js Middleware

Der Einstieg in Clerk beginnt mit der Installation und der Middleware-Konfiguration. Claude Code kann dir dabei helfen, die passende Middleware-Strategie für dein Projekt zu generieren — du beschreibst deine Route-Struktur, Claude Code liefert den fertigen Code.

Installation & Environment Variables

# Installation npm install @clerk/nextjs # .env.local — niemals in Git committen! NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... CLERK_SECRET_KEY=sk_test_... # Optional: Custom Sign-In / Sign-Up 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
💡 Claude Code Prompt: "Erstelle eine Clerk-Middleware für Next.js 15 mit App Router. Öffentliche Routen: /, /blog/*, /pricing, /api/webhooks/*. Alle anderen Routen sollen authentifiziert sein."

clerkMiddleware mit createRouteMatcher

// middleware.ts — App Router (Next.js 14+) import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' const isPublicRoute = createRouteMatcher([ '/', '/blog(.*)', '/pricing', '/sign-in(.*)', '/sign-up(.*)', '/api/webhooks(.*)', ]) export default clerkMiddleware(async (auth, request) => { if (!isPublicRoute(request)) { await auth.protect() } }) 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)(.*)', ], }

Erweiterte Middleware: Rollen-basiertes Redirect

// middleware.ts — mit Role-Check import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' import { NextResponse } from 'next/server' const isAdminRoute = createRouteMatcher(['/admin(.*)']) const isPublicRoute = createRouteMatcher(['/', '/blog(.*)', '/api/webhooks(.*)']) export default clerkMiddleware(async (auth, request) => { const { userId, sessionClaims } = await auth() // Admin-Route: nur für Admins if (isAdminRoute(request)) { if (!userId) { return auth.redirectToSignIn() } const role = (sessionClaims?.metadata as { role?: string })?.role if (role !== 'admin') { return NextResponse.redirect(new URL('/unauthorized', request.url)) } } // Alle anderen geschützten Routen if (!isPublicRoute(request)) { await auth.protect() } })
💡 Claude Code Tipp: Beschreibe Claude Code deine komplette Route-Architektur in einem Satz. Claude Code erkennt automatisch, welche Routen öffentlich sein müssen und generiert den passenden createRouteMatcher-Aufruf.
⚠️ Wichtig: Die alte authMiddleware-API ist seit Clerk v5 deprecated. Nutze ausschließlich clerkMiddleware für neue Projekte.

ClerkProvider im Root Layout

// app/layout.tsx import { ClerkProvider } from '@clerk/nextjs' import { Inter } from 'next/font/google' const inter = Inter({ subsets: ['latin'] }) export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <ClerkProvider appearance={{ variables: { colorPrimary: '#6366f1' }, elements: { formButtonPrimary: 'bg-indigo-600 hover:bg-indigo-700', card: 'shadow-none border border-gray-200', }, }} > <html lang="de"> <body className={inter.className}>{children}</body> </html> </ClerkProvider> ) }

2. Client-Side Auth: useAuth, useUser & UI-Komponenten

Clerk bietet eine Reihe von React-Hooks und vorgefertigten UI-Komponenten, die du sofort in deine Client Components integrieren kannst. Claude Code kann aus einem Prompt vollständige Navigation-Komponenten mit Auth-State generieren.

useAuth useUser UserButton SignInButton

useAuth Hook — Grundlegende Auth-Informationen

// components/AuthStatus.tsx 'use client' import { useAuth } from '@clerk/nextjs' export function AuthStatus() { const { isLoaded, isSignedIn, userId, sessionId, orgId, orgRole, orgSlug, has, // Permission-Check signOut, getToken, // JWT für API-Calls } = useAuth() if (!isLoaded) return <div>Laden...</div> if (!isSignedIn) return <div>Nicht angemeldet</div> return ( <div> <p>User ID: {userId}</p> <p>Session: {sessionId}</p> {orgId && <p>Organisation: {orgSlug} ({orgRole})</p>} <button onClick={() => signOut()}>Abmelden</button> </div> ) }

useUser Hook — Profil-Daten

// components/UserProfile.tsx 'use client' import { useUser } from '@clerk/nextjs' export function UserProfile() { const { isLoaded, isSignedIn, user } = useUser() if (!isLoaded || !isSignedIn) return null const primaryEmail = user.primaryEmailAddress?.emailAddress const fullName = user.fullName ?? `${user.firstName} ${user.lastName}` const avatarUrl = user.imageUrl const createdAt = new Date(user.createdAt!).toLocaleDateString('de-DE') return ( <div className="flex items-center gap-3 p-4 bg-gray-50 rounded-lg"> <img src={avatarUrl} alt={fullName} className="w-12 h-12 rounded-full" /> <div> <p className="font-semibold">{fullName}</p> <p className="text-sm text-gray-500">{primaryEmail}</p> <p className="text-xs text-gray-400">Mitglied seit {createdAt}</p> </div> </div> ) }

Navigation mit UserButton und bedingtem Rendering

// components/Navigation.tsx 'use client' import { SignInButton, SignOutButton, SignUpButton, UserButton, useAuth, RedirectToSignIn, } from '@clerk/nextjs' import Link from 'next/link' export function Navigation() { const { isLoaded, isSignedIn } = useAuth() return ( <nav className="flex items-center justify-between px-6 py-4 border-b"> <Link href="/" className="font-bold text-xl"> MyApp </Link> <div className="flex items-center gap-4"> {!isLoaded ? ( <div className="w-8 h-8 rounded-full bg-gray-200 animate-pulse" /> ) : isSignedIn ? ( <> <Link href="/dashboard">Dashboard</Link> <UserButton afterSignOutUrl="/" appearance={{ elements: { avatarBox: "w-9 h-9", }, }} /> </> ) : ( <> <SignInButton mode="modal"> <button className="text-gray-600 hover:text-gray-900"> Anmelden </button> </SignInButton> <SignUpButton mode="modal"> <button className="bg-indigo-600 text-white px-4 py-2 rounded-lg"> Kostenlos starten </button> </SignUpButton> </> )} </div> </nav> ) }

Geschützter Bereich mit RedirectToSignIn

// components/ProtectedContent.tsx 'use client' import { useAuth, RedirectToSignIn } from '@clerk/nextjs' export function ProtectedContent({ children }: { children: React.ReactNode }) { const { isLoaded, isSignedIn } = useAuth() if (!isLoaded) { return <div className="flex justify-center p-8"><LoadingSpinner /></div> } if (!isSignedIn) { return <RedirectToSignIn /> } return <>{children}</> }
💡 Claude Code Workflow: "Generiere eine vollständige Navigation mit Clerk Auth, die im nicht-eingeloggten Zustand Sign-In und Sign-Up Modals zeigt, und im eingeloggten Zustand einen UserButton mit Custom-Menüpunkten für Einstellungen und Billing."

3. Server-Side Auth: auth(), currentUser & Route Handler

Server Components und Route Handler sind der Kern moderner Next.js-Anwendungen. Clerk bietet mit auth() und currentUser() zwei mächtige Funktionen für die Server-seitige Auth. Claude Code versteht den Unterschied und wählt automatisch die richtige Funktion.

auth() in Server Components

// app/dashboard/page.tsx — Server Component import { auth } from '@clerk/nextjs/server' import { redirect } from 'next/navigation' export default async function DashboardPage() { const { userId, orgId, sessionClaims } = await auth() if (!userId) { redirect('/sign-in') } // Daten serverseitig laden — kein API-Round-Trip nötig const userData = await db.user.findUnique({ where: { clerkId: userId }, include: { subscription: true, usage: true }, }) return ( <div> <h1>Dashboard</h1> <p>Willkommen zurück, {userData?.name}</p> {orgId && <OrganizationDashboard orgId={orgId} />} </div> ) }

currentUser() — Vollständiges User-Objekt

// app/profile/page.tsx import { currentUser } from '@clerk/nextjs/server' import { redirect } from 'next/navigation' export default async function ProfilePage() { const user = await currentUser() if (!user) redirect('/sign-in') // Direkter Zugriff auf alle Clerk-User-Felder const { id, firstName, lastName, emailAddresses, imageUrl, publicMetadata, privateMetadata, createdAt, } = user const role = (publicMetadata as { role?: string }).role ?? 'user' const primaryEmail = emailAddresses[0]?.emailAddress return ( <ProfileForm userId={id} firstName={firstName ?? ''} lastName={lastName ?? ''} email={primaryEmail ?? ''} role={role} avatarUrl={imageUrl} /> ) }

Route Handler absichern

// app/api/projects/route.ts import { auth } from '@clerk/nextjs/server' import { NextResponse } from 'next/server' import { db } from '@/lib/db' export async function GET() { const { userId } = await auth() if (!userId) { return NextResponse.json({ error: 'Nicht authentifiziert' }, { status: 401 }) } const projects = await db.project.findMany({ where: { ownerId: userId }, orderBy: { createdAt: 'desc' }, }) return NextResponse.json({ projects }) } export async function POST(request: Request) { const { userId, orgId } = await auth() if (!userId) { return NextResponse.json({ error: 'Nicht authentifiziert' }, { status: 401 }) } const body = await request.json() const { name, description } = body if (!name?.trim()) { return NextResponse.json({ error: 'Name ist erforderlich' }, { status: 400 }) } const project = await db.project.create({ data: { name: name.trim(), description: description?.trim() ?? '', ownerId: userId, organizationId: orgId ?? null, }, }) return NextResponse.json({ project }, { status: 201 }) }
auth() vs. currentUser() — Wann was verwenden?
Funktion Gibt zurück Performance Empfehlung
auth() userId, sessionId, orgId, claims Sehr schnell (kein HTTP) Standard für Auth-Checks
currentUser() Vollständiges User-Objekt HTTP-Request zu Clerk Nur wenn User-Details nötig

4. Organizations & Multi-Tenancy

Clerk Organizations ermöglichen Multi-Tenancy direkt aus der Box — ohne eigene Datenbankstruktur für Teams und Mitglieder. Claude Code kann komplette Multi-Tenant-Architekturen generieren, inklusive Organisation-Switching, Mitgliederverwaltung und rollenbasiertem Zugriff.

Organizations Multi-Tenancy Roles

useOrganization Hook

// components/OrgDashboard.tsx 'use client' import { useOrganization, useOrganizationList } from '@clerk/nextjs' export function OrgDashboard() { const { organization, membership, memberships, isLoaded, } = useOrganization({ memberships: { pageSize: 10 }, }) if (!isLoaded) return <LoadingSpinner /> if (!organization) return <NoOrgState /> const isAdmin = membership?.role === 'org:admin' const memberCount = memberships?.count ?? 0 return ( <div className="space-y-6"> <div className="flex items-center gap-4"> <img src={organization.imageUrl} alt={organization.name} className="w-16 h-16 rounded-xl" /> <div> <h2 className="text-2xl font-bold">{organization.name}</h2> <p className="text-gray-500">{memberCount} Mitglieder</p> </div> {isAdmin && <AdminBadge />} </div> <MemberList memberships={memberships?.data ?? []} isAdmin={isAdmin} /> </div> ) }

OrganizationSwitcher — Multi-Org Navigation

// components/AppSidebar.tsx 'use client' import { OrganizationSwitcher, UserButton } from '@clerk/nextjs' export function AppSidebar() { return ( <aside className="w-64 border-r h-screen flex flex-col"> <div className="p-4 border-b"> <OrganizationSwitcher hidePersonal={false} afterCreateOrganizationUrl="/org/:slug/dashboard" afterSelectOrganizationUrl="/org/:slug/dashboard" afterSelectPersonalUrl="/dashboard" appearance={{ elements: { organizationSwitcherTrigger: "w-full justify-between", }, }} /> </div> <nav className="flex-1 p-4 space-y-1"> <NavItem href="/dashboard">Übersicht</NavItem> <NavItem href="/projects">Projekte</NavItem> <NavItem href="/settings">Einstellungen</NavItem> </nav> <div className="p-4 border-t"> <UserButton afterSignOutUrl="/" /> </div> </aside> ) }

Organisation erstellen & Mitglieder einladen (API)

// app/api/org/create/route.ts import { auth, clerkClient } from '@clerk/nextjs/server' import { NextResponse } from 'next/server' export async function POST(request: Request) { const { userId } = await auth() if (!userId) { return NextResponse.json({ error: 'Nicht authentifiziert' }, { status: 401 }) } const { name, slug } = await request.json() const client = await clerkClient() // Organisation erstellen const organization = await client.organizations.createOrganization({ name, slug, createdBy: userId, }) return NextResponse.json({ organization }, { status: 201 }) } // app/api/org/invite/route.ts — Mitglieder einladen export async function POST(request: Request) { const { userId, orgId, orgRole } = await auth() if (!userId || !orgId || orgRole !== 'org:admin') { return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) } const { emailAddress, role } = await request.json() const client = await clerkClient() const invitation = await client.organizations.createOrganizationInvitation({ organizationId: orgId, emailAddress, role: role ?? 'org:member', inviterUserId: userId, redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/accept-invitation`, }) return NextResponse.json({ invitation }, { status: 201 }) }
💡 Claude Code Prompt für Multi-Tenancy: "Implementiere ein vollständiges Multi-Tenant-System mit Clerk Organizations. Jede Organisation soll eigene Projekte, eigene Mitglieder und rollenbasierte Berechtigungen (Admin, Member, Viewer) haben. Datenbankschema mit Prisma."

5. Webhooks & User-Sync mit der Datenbank

Clerk-Events per Webhook in deine Datenbank synchronisieren ist kritisch für produktionsreife Anwendungen. Claude Code generiert vollständige Webhook-Handler inklusive Signatur-Validierung mit Svix — sicher und produktionsbereit.

Webhooks Svix User Sync

Webhook-Handler mit Svix-Signatur-Validierung

// app/api/webhooks/clerk/route.ts import { Webhook } from 'svix' import { headers } from 'next/headers' import { WebhookEvent } from '@clerk/nextjs/server' import { NextResponse } from 'next/server' import { db } from '@/lib/db' const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET export async function POST(request: Request) { if (!WEBHOOK_SECRET) { throw new Error('CLERK_WEBHOOK_SECRET nicht gesetzt') } // Svix-Header lesen 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 NextResponse.json({ error: 'Fehlende Svix-Header' }, { status: 400 }) } // Payload lesen und Signatur verifizieren const payload = await request.text() const wh = new Webhook(WEBHOOK_SECRET) let event: WebhookEvent try { event = wh.verify(payload, { 'svix-id': svixId, 'svix-timestamp': svixTimestamp, 'svix-signature': svixSignature, }) as WebhookEvent } catch (err) { console.error('Webhook-Verifikation fehlgeschlagen:', err) return NextResponse.json({ error: 'Ungültige Signatur' }, { status: 401 }) } // Events verarbeiten await handleWebhookEvent(event) return NextResponse.json({ received: true }) }

User-Events: created, updated, deleted

// lib/webhook-handlers.ts import { WebhookEvent } from '@clerk/nextjs/server' import { db } from '@/lib/db' export async function handleWebhookEvent(event: WebhookEvent) { switch (event.type) { case 'user.created': { const { id, email_addresses, first_name, last_name, image_url, public_metadata } = event.data await db.user.create({ data: { clerkId: id, email: email_addresses[0]?.email_address ?? '', firstName: first_name ?? '', lastName: last_name ?? '', avatarUrl: image_url, role: (public_metadata as { role?: string })?.role ?? 'user', plan: 'free', }, }) // Willkommens-Email auslösen await sendWelcomeEmail(email_addresses[0]?.email_address ?? '', first_name ?? 'dort') break } case 'user.updated': { const { id, email_addresses, first_name, last_name, image_url } = event.data await db.user.update({ where: { clerkId: id }, data: { email: email_addresses[0]?.email_address, firstName: first_name ?? '', lastName: last_name ?? '', avatarUrl: image_url, updatedAt: new Date(), }, }) break } case 'user.deleted': { const { id } = event.data await db.$transaction([ db.project.deleteMany({ where: { ownerId: id! } }), db.session.deleteMany({ where: { userId: id! } }), db.user.delete({ where: { clerkId: id! } }), ]) break } case 'organization.created': { const { id, name, slug, image_url } = event.data await db.organization.create({ data: { clerkOrgId: id, name, slug: slug ?? id, logoUrl: image_url, plan: 'team', }, }) break } default: console.log(`Unbehandeltes Event: ${event.type}`) } }
⚠️ Webhook-Secret: Den CLERK_WEBHOOK_SECRET findest du im Clerk Dashboard unter "Webhooks". Niemals hardcoden — immer aus der .env-Datei laden.

Metadata über clerkClient aktualisieren

// Nach einem Stripe-Checkout: Plan in Clerk-Metadata speichern import { clerkClient } from '@clerk/nextjs/server' async function updateUserPlan(clerkUserId: string, plan: 'free' | 'pro' | 'enterprise') { const client = await clerkClient() await client.users.updateUserMetadata(clerkUserId, { publicMetadata: { plan, planUpdatedAt: new Date().toISOString(), }, }) }

6. Custom Claims & RBAC

Custom Claims erlauben es, zusätzliche Daten direkt im JWT-Token zu speichern — ohne zusätzliche Datenbankabfragen bei jedem Request. Kombiniert mit Clerks has()-API und protect() entsteht ein vollständiges RBAC-System. Claude Code generiert diese Patterns auf Basis deiner Rollen-Struktur.

Custom Claims RBAC protect() has()

JWT Template in Clerk Dashboard konfigurieren

// Clerk Dashboard → JWT Templates → Default (Shortcode) // Füge folgendes zur "Claims" Section hinzu: { "metadata": "{{user.public_metadata}}", "org_metadata": "{{org.public_metadata}}" } // Damit landen publicMetadata direkt im JWT: // sessionClaims.metadata.role, sessionClaims.metadata.plan etc.

Custom Claims in der Middleware lesen

// middleware.ts — mit Custom Claims import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' import { NextResponse } from 'next/server' // TypeScript-Typen für Custom Claims interface CustomClaims { metadata?: { role?: 'user' | 'admin' | 'superadmin' plan?: 'free' | 'pro' | 'enterprise' features?: string[] } } const isProRoute = createRouteMatcher(['/pro(.*)', '/api/pro/(.*)']) const isAdminRoute = createRouteMatcher(['/admin(.*)']) export default clerkMiddleware(async (auth, request) => { const { userId, sessionClaims } = await auth() const claims = sessionClaims as CustomClaims if (isProRoute(request)) { if (!userId) return auth.redirectToSignIn() const plan = claims?.metadata?.plan if (plan === 'free' || !plan) { return NextResponse.redirect(new URL('/upgrade', request.url)) } } if (isAdminRoute(request)) { if (!userId) return auth.redirectToSignIn() const role = claims?.metadata?.role if (role !== 'admin' && role !== 'superadmin') { return NextResponse.redirect(new URL('/unauthorized', request.url)) } } })

has() für Permission-Checks im Client

// components/FeatureGate.tsx 'use client' import { useAuth } from '@clerk/nextjs' export function ProFeatureGate({ children }: { children: React.ReactNode }) { const { has, isLoaded } = useAuth() if (!isLoaded) return <LoadingSpinner /> // has() prüft Clerk Permissions (im Dashboard konfiguriert) const canAccessPro = has?.({ permission: 'org:feature:pro_access' }) const isOrgAdmin = has?.({ role: 'org:admin' }) if (!canAccessPro) { return ( <div className="p-6 bg-amber-50 border border-amber-200 rounded-lg"> <p>Diese Funktion ist nur im Pro-Plan verfügbar.</p> <a href="/upgrade">Jetzt upgraden →</a> </div> ) } return <>{children}</> }

protect() in Server Actions

// app/actions/admin.ts 'use server' import { auth } from '@clerk/nextjs/server' export async function deleteUser(targetUserId: string) { // protect() wirft automatisch eine Unauthorized-Exception wenn Bedingung nicht erfüllt const { userId } = await auth.protect(has => { return has({ role: 'org:admin' }) || has({ permission: 'org:sys:manage_users' }) }) if (userId === targetUserId) { throw new Error('Du kannst dich nicht selbst löschen') } await db.user.delete({ where: { clerkId: targetUserId } }) } export async function generateReport() { // Nur für Superadmins via Custom Claims const { sessionClaims } = await auth() const role = (sessionClaims as { metadata?: { role?: string } })?.metadata?.role if (role !== 'superadmin') { throw new Error('Keine Berechtigung') } return generateAdminReport() }

Admin-Dashboard mit rollenbasierter UI

// app/admin/page.tsx — Server Component import { auth } from '@clerk/nextjs/server' import { redirect } from 'next/navigation' type AdminRole = 'admin' | 'superadmin' export default async function AdminPage() { const { sessionClaims, userId } = await auth() if (!userId) redirect('/sign-in') const metadata = sessionClaims?.metadata as { role?: AdminRole; plan?: string } const role = metadata?.role if (role !== 'admin' && role !== 'superadmin') { redirect('/unauthorized') } const isSuperAdmin = role === 'superadmin' const [users, stats] = await Promise.all([ db.user.findMany({ orderBy: { createdAt: 'desc' }, take: 50 }), db.user.aggregate({ _count: true }), ]) return ( <div className="p-8"> <h1>Admin Dashboard</h1> <p>Gesamt: {stats._count} Nutzer</p> <UserTable users={users} canDelete={isSuperAdmin} /> {isSuperAdmin && <SystemSettings />} </div> ) }
💡 RBAC Best Practice: Kombiniere Clerk Organizations Roles (org:admin) für team-basierte Berechtigungen mit Custom Claims (publicMetadata.role) für anwendungsweite Rollen. Claude Code kann dir helfen, die richtige Kombination für dein Use Case zu finden.

Auth-Modul im Kurs

Im Claude Code Mastery Kurs: vollständiges Clerk-Modul mit Organizations, Webhooks, Custom Claims und RBAC für produktionsreife Next.js-Anwendungen.

14 Tage kostenlos testen →