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 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.
# 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.// 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.
class User < ApplicationRecord
has_secure_password
# Rails generiert automatisch:
# password_digest Spalte
# user.authenticate("geheim")
# Das Hashing passiert im Hintergrund.
end// 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.
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// 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.
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.
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.
- Melde dich unter
/loginmitdemo@example.com/password123an. - Öffne die Browser-DevTools (
F12oderCmd+Option+I). - Gehe zum Tab Application (Chrome) oder Storage (Firefox).
- Klicke links auf Cookies →
http://localhost:3000. - Suche den Cookie mit dem Namen
devnotes-session. - 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.
- Beachte:
HttpOnlyist gesetzt — JavaScript im Browser kann diesen Cookie nicht lesen (document.cookiegibt ihn nicht zurück). - 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.