Authentifizierung mit Claude Code einbauen (Supabase Auth)
Ergänze E-Mail/Passwort-Signup, Google OAuth, Magic Links, geschützte Routen und Session-Handling in einer Next.js-16-App mit Claude Code und Supabase Auth.
Sie möchten das Framework hinter diesen Projekten?
Holen Sie sich das Claude Code System, mit dem wir produktionsreife Software planen, bauen, testen und ausliefern.
Supabase Auth gibt dir ein gehostetes Nutzersystem mit Sessions, JWTs und OAuth, schon fertig gebaut. Du schreibst keine Passwort-Hashing-Routine und keinen Token-Refresh-Loop. Was du schreibst, ist die Verkabelung, die es an Next.js anbindet: zwei Supabase-Clients, einen Session-Refresh-Schritt in proxy.ts, und die Formulare und Server Actions, die es aufrufen. Claude Code kann das alles korrekt generieren, wenn du ihm die richtigen Patterns gibst, und genau das zeigt dieser Post mit funktionierendem Code.
Was Supabase Auth dir gibt
Supabase Auth läuft auf deiner Postgres-Datenbank. Nutzer leben in einem Schema namens auth.users, das du nie direkt abfragst. Jeder Signup, jeder Login und jeder OAuth-Flow endet gleich: Supabase stellt ein JWT aus, und @supabase/ssr legt es in Cookies ab, damit dein Next.js-Server es bei jedem Request lesen kann.
Du bekommst E-Mail/Passwort-Auth, Magic Links (passwortloser Login über einen einmaligen Link per E-Mail) und OAuth mit über 30 Providern out of the box. Dieser Post nimmt Google als OAuth-Beispiel, weil das der ist, zu dem die meisten Builder zuerst greifen. Das Pattern ist für jeden anderen Provider dasselbe, sobald Google läuft.
Die Pakete installieren
Zwei Pakete, beide von Supabase. @supabase/supabase-js ist der Kern-Client. @supabase/ssr ergänzt cookie-basiertes Session-Handling, gebaut für Server-Rendering-Frameworks wie Next.js.
npm install @supabase/supabase-js @supabase/ssrDu brauchst außerdem zwei Environment-Variablen aus deinem Supabase-Projekt-Dashboard, unter Project Settings > API.
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyDer Anon-Key kann gefahrlos im Browser offengelegt werden. Die Zugriffskontrolle kommt von Row-Level-Security-Policies auf deinen Tabellen, nicht davon, dass du diesen Key versteckst.
Zwei Clients: Browser und Server
Supabase Auth in Next.js braucht zwei getrennte Clients, weil Cookies auf jeder Seite anders funktionieren. Der Browser-Client liest und schreibt Cookies über document.cookie. Der Server-Client liest und schreibt sie über die Request- und Response-Objekte, die Next.js dir gibt.
Lass Claude zuerst den Browser-Client erstellen. Er ist kurz.
// utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}Der Server-Client wird in Server Components, Server Actions und Route Handlers genutzt. In Next.js 16 gibt cookies() aus next/headers ein Promise zurück, also braucht es await.
// utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Called from a Server Component that can't set cookies.
// proxy.ts refreshes the session instead, so this is safe to ignore.
}
},
},
}
)
}Dieses try/catch ist keine Deko. Server Components können Cookies gar nicht setzen, nur lesen. Der Session-Refresh-Schritt in proxy.ts (nächster Abschnitt) ist das, was die Session tatsächlich am Leben hält, also ist eine Server Component, die hier ein Cookie nicht setzen kann, erwartet, kein Bug.
E-Mail/Passwort-Signup und Login
Server Actions erledigen die Formular-Submits. Signup und Login folgen beide derselben Form: Felder aus dem FormData ziehen, die Supabase-Methode aufrufen, bei Erfolg weiterleiten.
// app/login/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function signup(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const password = formData.get('password') as string
const { error } = await supabase.auth.signUp({ email, password })
if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`)
}
revalidatePath('/', 'layout')
redirect('/check-email')
}
export async function login(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const password = formData.get('password') as string
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`)
}
revalidatePath('/', 'layout')
redirect('/dashboard')
}
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
revalidatePath('/', 'layout')
redirect('/login')
}Standardmäßig verlangt Supabase eine E-Mail-Bestätigung, bevor sich ein neuer Account einloggen kann, weshalb signup auf eine /check-email-Seite weiterleitet statt direkt aufs Dashboard. Du kannst die Bestätigung im Supabase-Dashboard unter Authentication > Providers fürs lokale Testen abschalten, aber lass sie in Produktion an.
Das Formular selbst ist eine Client Component, damit es den Validierungs-Status anzeigen kann, aber die Submit-Action läuft auf dem Server.
// app/login/page.tsx
import { login, signup } from './actions'
export default function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>
}) {
return (
<form className="max-w-sm mx-auto py-12 space-y-4">
<h1 className="text-2xl font-bold">Log in</h1>
<input
id="email"
name="email"
type="email"
required
placeholder="you@example.com"
className="w-full border rounded px-3 py-2"
/>
<input
id="password"
name="password"
type="password"
required
placeholder="Password"
className="w-full border rounded px-3 py-2"
/>
<div className="flex gap-2">
<button formAction={login} className="flex-1 bg-black text-white rounded py-2">
Log in
</button>
<button formAction={signup} className="flex-1 border rounded py-2">
Sign up
</button>
</div>
</form>
)
}Lass Claude die Anzeige von searchParams.error ergänzen, und du bekommst Fehlermeldungen gratis. Da searchParams in Next.js 16 ein Promise ist, braucht das Lesen innerhalb einer Server Component weiterhin await, wenn du den Wert direkt im Component-Body verwendest.
Session-Refresh in proxy.ts
Next.js 16 hat middleware.ts in proxy.ts umbenannt, und die exportierte Funktion heißt jetzt proxy statt middleware. Claude schreibt ohne eine Projekt-Anweisung, die es anders sagt, immer noch standardmäßig middleware.ts, also füg eine Zeile zu CLAUDE.md hinzu: "Middleware logic goes in proxy.ts, exported as proxy, not middleware.ts."
Die Refresh-Logik lebt zuerst in einer Helper-Datei, weil sie sich leichter testen und wiederverwenden lässt.
// utils/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const {
data: { user },
} = await supabase.auth.getUser()
const protectedPaths = ['/dashboard', '/account']
const isProtected = protectedPaths.some((path) => request.nextUrl.pathname.startsWith(path))
if (!user && isProtected) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}getUser() hier aufzurufen macht zwei Dinge gleichzeitig: Es verifiziert das Token gegen Supabases Server, und es refresht das Session-Cookie, wenn das Token kurz vor dem Ablaufen ist. Deshalb läuft das bei jedem Request statt nur auf geschützten Seiten, eine ablaufende Session muss sich refreshen, bevor sie abläuft, nicht danach.
Die Root-Datei proxy.ts ruft nur den Helper auf und legt fest, auf welchen Pfaden er läuft.
// proxy.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/proxy'
export async function proxy(request: NextRequest) {
return updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}Der Matcher schließt statische Assets und Bilder aus, damit der Proxy keine Session-Checks bei jedem Favicon-Request macht. Er läuft trotzdem auf jeder Seite und jeder API-Route, was die Sessions über die ganze App hinweg frisch hält.
Routen schützen (Defense in Depth)
Der Proxy-Redirect oben deckt die meisten Fälle ab, aber behandle ihn nicht als einzigen Check. Ein Matcher-Pattern ist leicht subtil falsch zu treffen, und wenn es eine Route, die du schützen wolltest, stillschweigend ausschließt, ist diese Route jetzt offen. Prüf nochmal innerhalb der Seite.
// app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return <>{children}</>
}Das kostet einen zusätzlichen Netzwerk-Round-Trip zu Supabase pro geschütztem Seitenaufruf. Das ist ein fairer Deal dafür, sich nicht auf einen Regex-Matcher als einzige Autorisierungsgrenze zu verlassen. Der eigentliche Rückhalt unter beiden dieser Checks ist Row-Level-Security auf deinen Tabellen, weiter unten behandelt, denn das ist die Schicht, um die ein Angreifer nicht herumrouten kann.
Google OAuth
Aktiviere den Google-Provider im Supabase-Dashboard unter Authentication > Providers, bevor du Code schreibst. Du brauchst eine Client-ID und ein Client-Secret aus der Google Cloud Console, und die Redirect-URI, die Google akzeptieren muss, ist die, die Supabase dir auf dieser Seite anzeigt, in der Form https://your-project-ref.supabase.co/auth/v1/callback.
Die Sign-in-Action fordert eine URL von Supabase an und leitet den Browser dorthin weiter.
// app/login/actions.ts (add to the same file)
export async function signInWithGoogle() {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
})
if (error || !data.url) {
redirect('/login?error=Could not sign in with Google')
}
redirect(data.url)
}NEXT_PUBLIC_SITE_URL muss auf deine tatsächlich deployte URL gesetzt sein (oder http://localhost:3000 in der Entwicklung), damit der Redirect zurück auf deiner App landet, nicht auf Supabases Standard.
<form>
<button formAction={signInWithGoogle} className="w-full border rounded py-2">
Continue with Google
</button>
</form>Magic Links
Ein Magic Link ist ein einmaliger Login-Link, per E-Mail geschickt. Kein Passwort zum Setzen oder Merken. Supabase nennt das unter der Haube einen passwortlosen OTP-Flow, und er nutzt dieselbe Callback-Route wie OAuth.
// app/login/actions.ts (add to the same file)
export async function sendMagicLink(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
})
if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`)
}
redirect('/check-email')
}Verbinde es mit einem eigenen kleinen Formular, da es nur ein E-Mail-Feld braucht, kein Passwort.
<form className="space-y-2">
<input name="email" type="email" required placeholder="you@example.com" className="w-full border rounded px-3 py-2" />
<button formAction={sendMagicLink} className="w-full border rounded py-2">
Send magic link
</button>
</form>Die Callback-Route, die beide Flows teilen
Google OAuth und Magic Links enden beide damit, dass Supabase den Browser zurück zu deiner App leitet, mit einem code-Query-Parameter. Ein einzelner Route Handler tauscht diesen Code gegen eine Session.
// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/utils/supabase/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/login?error=Could not authenticate`)
}Das ist der einzige Ort, an dem exchangeCodeForSession aufgerufen wird. Jeder OAuth-Provider und jeder Magic Link zeigt hierher, also braucht das spätere Hinzufügen eines zweiten Providers keine neue Callback-Route, nur eine neue Sign-in-Action, die auf dasselbe /auth/callback weiterleitet.
Row-Level-Security, an auth.uid() gekoppelt
Nichts vom Auth-Flow oben schränkt ein, was ein eingeloggter Nutzer in deiner Datenbank sehen kann. Das ist Postgres' Aufgabe, erzwungen über Row-Level-Security (RLS). Ein gängiges Pattern ist eine profiles-Tabelle, die automatisch eine Zeile bekommt, sobald sich jemand registriert, über einen Postgres-Trigger auf auth.users.
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text,
display_name text,
created_at timestamptz default now()
);
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, email)
values (new.id, new.email);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();Sobald die Tabelle steht, schalt RLS ein und schreib Policies, die den Owner der Zeile gegen die ID des eingeloggten Nutzers prüfen.
alter table public.profiles enable row level security;
create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = id );
create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = id )
with check ( (select auth.uid()) = id );auth.uid() liest die Nutzer-ID aus dem verifizierten JWT, das @supabase/ssr an jeden Request anhängt. Es gibt keinen separaten Autorisierungs-Check, den du für diese Tabelle in deinem Anwendungscode schreiben musst, Postgres verweigert die Query rundweg, wenn die Zeile nicht dem Aufrufer gehört. Das ist dieselbe Garantie, die den proxy.ts-Redirect und den Layout-Check oben stützt, nur dass sie nicht durch einen fehlenden Route-Check umgangen werden kann. Fürs vollständige RLS-Bild (INSERT/DELETE-Policies, Views und der security_invoker-Fallstrick) siehe Claude Code mit Supabase.
Lass Claude diesen ganzen Flow einmal von Anfang bis Ende durchlaufen, sobald er verkabelt ist: mit einer neuen E-Mail registrieren, sie bestätigen, einloggen, mit Google anmelden, einen Magic Link anfordern, ausgeloggt eine geschützte Seite aufrufen und den Redirect bestätigen. Das ist der eigentliche Test, der zählt, nicht nur ein sauberer Type-Check.
Jedes Stück hier (die zwei Clients, das proxy.ts-Refresh, die Callback-Route, die Form der RLS-Policy) ist etwas, das Claude Code einmal korrekt schreibt, aber in jedem neuen Projekt von Grund auf neu herleitet, es sei denn, du hast das Pattern irgendwo gespeichert. Das $29 Code Kit liefert das schon verkabelt: geschützte Seiten, Google OAuth und RLS auf jeder Tabelle ab der ersten Migration, damit ein neues Projekt hinter dieser Verkabelung startet, statt sie neu zu bauen. Es ist ein einmaliges Gerüst auf Claude Code, kein Abo, und Claude Code selbst braucht darunter weiterhin seinen eigenen bezahlten Anthropic-Plan.
Posted by @speedy_devv
Sie möchten das Framework hinter diesen Projekten?
Holen Sie sich das Claude Code System, mit dem wir produktionsreife Software planen, bauen, testen und ausliefern.
So baust du ein SaaS-MVP mit Claude Code
Ein Wochenend-Build-Log: ein Next.js-16-Projekt aufsetzen, Supabase Auth und Postgres ergänzen, Stripe-Abrechnung anbinden und auf Vercel deployen, alles mit Claude Code.
Transaktions-E-Mails mit Claude Code einbauen (Resend + React Email)
So baust du Willkommens-, Beleg- und Passwort-Reset-E-Mails in einer Next.js-16-App mit Resend und React Email, wobei Claude Code die Templates und die Send-Logik schreibt.

