Authentifizierung in Next.js

Wer aus der Rails-Welt kommt, kennt das beruhigende Gefühl, wenn gem 'devise' in der Gemfile steht und danach fast alles von selbst funktioniert: Passwort-Hashing, Sessions, Mailer, Reset-Flow — alles mit drei Befehlen generiert. Next.js liefert so etwas nicht im Lieferumfang. Das ist kein Fehler, sondern eine Entscheidung: Das Framework bleibt agnostisch, und Auth-Logik lebt in Libraries oder im eigenen Code.

In diesem Modul bauen wir Auth von Grund auf — nicht weil das in der Produktion empfehlenswert ist, sondern weil man dadurch versteht, was hinter den Kulissen passiert. Wer einmal selbst eine Cookie-Session verdrahtet hat, versteht auch, warum Auth.js gewisse Konfiguration braucht.

Auth in Next.js – ein Überblick

Es gibt drei Hauptwege, Authentifizierung in Next.js umzusetzen:

Auth.js (ehemals NextAuth.js) ist die nächste Analogie zu Devise. Die Library abstrahiert OAuth-Provider (GitHub, Google, etc.), Sessions und Callbacks. Sie ist meinungsstark, integriert sich eng mit Next.js und ist in den meisten Projekten die richtige Wahl. Der Nachteil: Der Konfigurationsaufwand ist anfangs höher als erwartet, und manche Konzepte erklären sich erst nach gründlicher Lektüre der Docs.

Clerk ist ein vollständiger Auth-Dienst als SaaS. Man bindet eine React-Component ein, und Login, Registrierung, Profilverwaltung, Magic Links und MFA funktionieren sofort — inklusive hübscher UI. Wer schnell produktiv werden will und keine eigene Auth-Infrastruktur betreiben möchte, fährt mit Clerk gut. Entsprechend gibt es ein kostenpflichtiges Modell ab einer gewissen Nutzerzahl.

DIY (Do It Yourself) ist der Weg, den wir in diesem Modul gehen. Wir kombinieren zwei kleine Libraries — iron-session für Cookie-Verschlüsselung und bcryptjs für Passwort-Hashing — und bauen alle Teile selbst zusammen. Das erzeugt kein produktionsreifes Auth-System (kein Passwort-Reset-Flow, keine E-Mail-Bestätigung, kein Rate-Limiting), ist aber der beste Weg, die zugrunde liegenden Konzepte zu verstehen.

Rails-Analogie

Rails hat mit Devise und Sorcery etablierte Conventions, die sich über Generatoren ins Projekt integrieren und sofort funktionieren. Next.js hat keinen offiziellen Standard — Auth.js kommt dem am nächsten, ist aber eine externe Library ohne besondere Next.js-Privilegien. Das Ökosystem ist jünger und weniger konsolidiert.

Cookie-Session mit iron-session

In Rails kümmert sich das Framework diskret um Sessions. Man schreibt session[:user_id] = user.id und Rails verschlüsselt, signiert und speichert diesen Wert in einem Cookie — ohne dass man sich um die Details kümmern muss.

In Next.js gibt es diese Magie nicht eingebaut. Wir nutzen iron-session, eine Library, die dasselbe tut: verschlüsselte, signierte Cookies. Der Cookie-Inhalt ist nicht im Browser lesbar (er ist AES-256-CBC-verschlüsselt), aber er braucht keinen Server-seitigen Session-Store wie Redis.

Zunächst definieren wir die Form unserer Session-Daten als TypeScript-Interface — das gibt uns Typsicherheit überall, wo wir auf die Session zugreifen:

import { getIronSession, type IronSession } from "iron-session";
import { cookies } from "next/headers";

// Was wir in der Session speichern — bewusst minimal halten
export interface SessionData {
  userId?: number;
  email?: string;
  name?: string | null;
}

const sessionOptions = {
  // Muss 32+ Zeichen haben. Wird aus .env gelesen.
  password: process.env.SESSION_SECRET ?? "fallback-dev-secret-change-me-32-chars",
  cookieName: "devnotes-session",
  cookieOptions: {
    secure: process.env.NODE_ENV === "production",
    httpOnly: true,          // JavaScript im Browser kann den Cookie nicht lesen
    maxAge: 60 * 60 * 24 * 7, // 7 Tage
  },
};

// Nutzbar in Server Components und Server Actions
export async function getSession(): Promise<IronSession<SessionData>> {
  const cookieStore = await cookies();
  return getIronSession<SessionData>(cookieStore, sessionOptions);
}

getSession() ist eine async Funktion, weil cookies() in Next.js 15 selbst async ist — ein kleines Artefakt der Umstellung auf asynchrone Request-Objekte. Man kann getSession() in jedem Server Component oder jeder Server Action aufrufen; sie liest den devnotes-session-Cookie, entschlüsselt ihn, und gibt ein typisiertes Objekt zurück.

Rails-Analogie
Ruby on Rails
# Einfach in jedem Controller verfügbar:
session[:user_id] = user.id
session[:email] = user.email

# Rails kümmert sich um Verschlüsselung,
# Signierung und Cookie-Lifetime automatisch.
Next.js
// Explizite Typdefinition, explizites Speichern:
const session = await getSession();
session.userId = user.id;
session.email = user.email;
await session.save(); // nicht vergessen!

// iron-session verschlüsselt den Cookie selbst.

Ein wichtiger Unterschied: In Rails ist die Session ein Hash, der automatisch am Ende eines Requests persistiert wird. Mit iron-session muss man session.save() explizit aufrufen — das ist mehr Code, aber auch klarer: Man sieht genau, wann Zustandsänderungen stattfinden.

Passwort-Hashing mit bcrypt

Rails-Entwickler kennen has_secure_password: Dieses Macro fügt einem Model automatisch Passwort-Hashing (via BCrypt) hinzu. Man übergibt password: "geheim", und Rails speichert einen Hash — nie das Klartext-Passwort.

In unserem Setup übernimmt bcryptjs diese Rolle, aber wir rufen die Funktionen explizit auf:

import bcrypt from "bcryptjs";

// Hash eines neuen Passworts (≈ has_secure_password beim Speichern)
export async function hashPassword(plain: string): Promise<string> {
  return bcrypt.hash(plain, 12); // 12 = cost factor (mehr = langsamer = sicherer)
}

// Passwort gegen gespeicherten Hash prüfen (≈ user.authenticate("geheim"))
export async function verifyPassword(
  plain: string,
  hash: string
): Promise<boolean> {
  return bcrypt.compare(plain, hash);
}

Der cost factor von 12 ist eine bewusste Entscheidung: BCrypt ist absichtlich langsam, damit Brute-Force-Angriffe unpraktikabel werden. Je höher der Wert, desto mehr Rechenzeit braucht eine einzelne Hash-Berechnung — auf moderner Hardware sind 12 ein guter Kompromiss zwischen Sicherheit und Login-Geschwindigkeit.

Rails-Analogie
Ruby on Rails
class User < ApplicationRecord
has_secure_password
# Rails generiert automatisch:
# password_digest Spalte
# user.authenticate("geheim")
# Das Hashing passiert im Hintergrund.
end
Next.js
// Explizit beim Registrieren:
const hashed = await hashPassword(password);
await prisma.user.create({
data: { email, password: hashed }
});

// Explizit beim Login:
const ok = await verifyPassword(plain, user.password);

Login-Flow Schritt für Schritt

Der Login-Flow ist eine Server Action — eine async Funktion, die im Browser als Form-Submit-Handler registriert wird, aber auf dem Server läuft. Kein API-Endpoint, kein fetch, kein Controller — einfach eine Funktion:

"use server";

import { redirect } from "next/navigation";
import prisma from "@/lib/prisma";
import { getSession } from "@/lib/session";
import { verifyPassword } from "@/lib/auth";
import { LoginSchema } from "@/lib/validators";

export async function loginAction(formData: FormData): Promise<void> {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  // 1. Eingabe mit Zod validieren
  const result = LoginSchema.safeParse({ email, password });
  if (!result.success) {
    redirect("/login?error=Ungueltige+Eingabe");
  }

  // 2. User per E-Mail suchen
  const user = await prisma.user.findUnique({
    where: { email: result.data.email },
  });
  if (!user) {
    redirect("/login?error=Email+oder+Passwort+falsch");
  }

  // 3. Passwort gegen BCrypt-Hash prüfen
  const valid = await verifyPassword(result.data.password, user.password);
  if (!valid) {
    redirect("/login?error=Email+oder+Passwort+falsch");
  }

  // 4. Session schreiben (≈ session[:user_id] = user.id)
  const session = await getSession();
  session.userId = user.id;
  session.email = user.email;
  session.name = user.name;
  await session.save();

  // 5. Weiterleitung nach erfolgreichem Login
  redirect("/notes");
}

Zwei Punkte sind wichtig: Erstens gibt es bei fehlgeschlagenem Login keine unterschiedlichen Fehlermeldungen für "User nicht gefunden" vs. "Passwort falsch" — das wäre ein User-Enumeration-Angriff, der es Angreifern ermöglicht, gültige E-Mail-Adressen zu sammeln. Zweitens rufen wir session.save() erst nach der erfolgreichen Validierung auf, nie früher.

Registrierung

Die Registrierung folgt demselben Muster, fügt aber einen Schritt hinzu: den Blick in die Datenbank, ob die E-Mail-Adresse bereits vergeben ist, und das explizite Hashen des Passworts vor dem Speichern:

"use server";

import { redirect } from "next/navigation";
import prisma from "@/lib/prisma";
import { getSession } from "@/lib/session";
import { hashPassword } from "@/lib/auth";
import { RegisterSchema } from "@/lib/validators";

export async function registerAction(formData: FormData): Promise<void> {
  const result = RegisterSchema.safeParse({
    name: formData.get("name") ?? undefined,
    email: formData.get("email") as string,
    password: formData.get("password") as string,
  });
  if (!result.success) {
    const msg = result.error.errors[0]?.message ?? "Ungueltige+Eingabe";
    redirect(`/register?error=${encodeURIComponent(msg)}`);
  }

  // E-Mail bereits vergeben?
  const existing = await prisma.user.findUnique({
    where: { email: result.data.email },
  });
  if (existing) {
    redirect("/register?error=Diese+E-Mail-Adresse+wird+bereits+verwendet");
  }

  // Passwort hashen und User anlegen (≈ User.create! in Rails)
  const hashed = await hashPassword(result.data.password);
  const user = await prisma.user.create({
    data: {
      email: result.data.email,
      password: hashed,
      name: result.data.name ?? null,
    },
  });

  // Direkt einloggen nach Registrierung
  const session = await getSession();
  session.userId = user.id;
  session.email = user.email;
  session.name = user.name;
  await session.save();

  redirect("/notes");
}

Das Muster "nach Registrierung direkt einloggen" ist aus UX-Sicht fast immer richtig: Niemand möchte nach einem erfolgreichen Sign-up noch einmal seinen Benutzernamen und sein Passwort eingeben. Rails macht das mit sign_in @user nach @user.save!, hier schreiben wir die Session direkt.

getCurrentUser – überall nutzbar

Ein zentrales Pattern in Rails ist current_user — eine Methode in ApplicationController, die einmal definiert ist und in jedem Controller und jedem View verfügbar steht.

In Next.js gibt es keinen Kontext, der automatisch durch den Render-Baum propagiert wird (zumindest nicht ohne React.createContext + Client-Komponenten). Stattdessen exportieren wir eine einfache async Funktion, die in jedem Server Component aufgerufen werden kann:

import prisma from "@/lib/prisma";
import { getSession } from "@/lib/session";

// ≈ current_user in Rails — liest Session, fragt DB ab
export async function getCurrentUser() {
  const session = await getSession();
  if (!session.userId) return null;

  return prisma.user.findUnique({
    where: { id: session.userId },
    select: { id: true, email: true, name: true },
    // Wichtig: password wird NICHT zurückgegeben
  });
}

Das select-Statement ist kein nettes Extra, sondern Pflicht: Wir wollen nie versehentlich den Passwort-Hash in eine Komponente durchreichen. prisma.user.findUnique ohne select würde alle Felder inklusive password zurückgeben.

requireLogin – Routen schützen

getCurrentUser gibt null zurück, wenn niemand eingeloggt ist. Für geschützte Seiten möchten wir stattdessen automatisch weiterleiten. Das erledigt requireLogin:

import { redirect } from "next/navigation";

// ≈ before_action :authenticate_user! in Devise
export async function requireLogin() {
  const user = await getCurrentUser();
  if (!user) redirect("/login");
  return user; // TypeScript weiß: Rückgabewert ist nicht null
}

Der Aufruf in einer geschützten Seite sieht dann so aus:

import { requireLogin } from "@/lib/auth";

export default async function NotesPage() {
  // Wirft redirect("/login") wenn nicht eingeloggt;
  // sonst ist user garantiert nicht null
  const user = await requireLogin();

  return <div>Willkommen, {user.name ?? user.email}</div>;
}

Das ist absichtlich explizit: Jede geschützte Seite muss requireLogin() selbst aufrufen. Das ergibt etwas mehr Boilerplate als Rails' before_action, macht aber auch klarer, welche Routen geschützt sind — man muss nicht eine Filterliste in ApplicationController suchen.

Rails-Analogie
Ruby on Rails
class NotesController < ApplicationController
# Dieser Filter schützt ALLE Actions:
before_action :authenticate_user!

def index
  # current_user ist garantiert gesetzt
  @notes = current_user.notes
end
end
Next.js
// app/notes/page.tsx
export default async function NotesPage() {
// Explizit in jeder geschützten Seite:
const user = await requireLogin();

const notes = await prisma.note.findMany({
  where: { userId: user.id }
});
}

middleware.ts – Edge-basierter Schutz

requireLogin() schützt einzelne Server Components. Aber es gibt einen zweiten, früheren Schutzmechanismus: die Next.js Middleware. Sie läuft auf der Edge, also noch bevor eine Route gerendert wird — vergleichbar mit einem Rack-Middleware oder einem globalen before_action in ApplicationController.

import { NextResponse, type NextRequest } from "next/server";
import { getIronSession } from "iron-session";
import type { SessionData } from "@/lib/session";

const PROTECTED_PREFIXES = ["/notes"];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  const requiresAuth = PROTECTED_PREFIXES.some((prefix) =>
    pathname.startsWith(prefix)
  );
  if (!requiresAuth) return NextResponse.next();

  // Iron-session auf dem Edge: direkt aus den Request-Cookies lesen
  const session = await getIronSession<SessionData>(
    request.cookies as never,
    {
      password: process.env.SESSION_SECRET ?? "fallback-dev-secret-change-me-32-chars",
      cookieName: "devnotes-session",
    }
  );

  if (!session.userId) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("from", pathname);  // Ziel merken für Redirect-Back
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

export const config = {
  // Welche Pfade diese Middleware überhaupt aufgerufen wird:
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\..*$).*)"],
};

Die Middleware und requireLogin() überlappen sich absichtlich: Die Middleware verhindert, dass die Seite überhaupt gerendert wird (schnelles Feedback, kein DB-Aufruf). requireLogin() ist ein zweiter Schutzmechanismus direkt im Code der Seite — nützlich als Defense-in-Depth, aber auch wenn eine Seite kein eigenes Middleware-Matching hat.

Rails-Analogie

In Rails kann man before_action :authenticate_user! in ApplicationController setzen und damit alle Controller-Unterklassen schützen — mit skip_before_action für Ausnahmen. Next.js' Middleware ist ähnlich, aber konzeptionell breiter: Sie läuft außerhalb des React-Renderings, auf dem Edge, noch vor dem eigentlichen Request-Handler. Das matcher-Muster übernimmt die Rolle von except: und only:.

Live-Demo: Session-Status

Diese Demo zeigt den aktuellen Session-Zustand — direkt auf dem Server ausgewertet. Kein useEffect, kein fetch, kein Client-State. Die Komponente ist ein reiner Server Component, der getSession() aufruft und das Ergebnis rendert.

M9SessionDemoServer Component — kein Client-JS
Ausgeloggtdevnotes-session cookie
Session-Daten
{ } — kein eingeloggter Nutzer

Um die Demo mit einem eingeloggten Zustand zu sehen: Besuche /login und melde dich mit demo@example.com / password123 an (der Seed-User aus prisma/seed.ts). Danach wird hier der Name, die E-Mail und die User-ID aus dem entschlüsselten Cookie angezeigt.

Session-Cookie im Browser untersuchen
  1. Melde dich unter /login mit demo@example.com / password123 an.
  2. Öffne die Browser-DevTools (F12 oder Cmd+Option+I).
  3. Gehe zum Tab Application (Chrome) oder Storage (Firefox).
  4. Klicke links auf Cookieshttp://localhost:3000.
  5. Suche den Cookie mit dem Namen devnotes-session.
  6. Kopiere den Wert und füge ihn in ein Base64-Decoder-Tool ein — du wirst verschlüsselten Binärinhalt sehen, keinen lesbaren JSON. Das ist iron-session in Aktion.
  7. Beachte: HttpOnly ist gesetzt — JavaScript im Browser kann diesen Cookie nicht lesen (document.cookie gibt ihn nicht zurück).
  8. Melde dich über /login (mit einem Logout-Link) ab und lade diese Seite neu. Die Session-Demo oben sollte wieder "Ausgeloggt" anzeigen.

Im Capstone: DevNotes

In der DevNotes-App kommt das gesamte Auth-System zusammen. Alle Routen unter /notes/* sind auf zwei Ebenen geschützt:

Auf Middleware-Ebene fängt middleware.ts jeden Request an /notes ab, liest den devnotes-session-Cookie und leitet unauthentifizierte Nutzer sofort zu /login weiter — mit dem ursprünglichen Pfad als ?from=-Parameter, damit nach dem Login automatisch dorthin zurückgeleitet werden kann.

Auf Component-Ebene ruft jede geschützte Seite requireLogin() auf. Das gibt TypeScript die Gewissheit, dass user nicht null ist — alle Datenbankabfragen in dieser Seite können where: { userId: user.id } verwenden, ohne zusätzliche Null-Checks.

Die Registrierung unter /register und der Login unter /login sind bewusst nicht geschützt — ein eingeloggter Nutzer, der /login aufruft, wird nicht automatisch weitergeleitet. Das wäre ein nettes Feature (analog zu Rails' redirect_if_signed_in), aber für das Capstone-Projekt bleibt es ein optionales TODO.

Das getCurrentUser()-Pattern aus diesem Modul wird in späteren Modulen auch in Route Handlers (Modul 10) auftauchen, wo die Session genauso ausgelesen werden kann — iron-session funktioniert sowohl in Server Components als auch in API-Routen.