Authorisierung und Sitzungsverwaltung in Next.js
Dieses Dokument behandelt das Frontend, das eine Sitzung vom Backend erhält (Token oder Sitzungs-ID) und diese speichern, verlängern und prüfen muss. Wenn das Projekt eine Bibliothek oder einen Anbieter verwendet (Auth.js, Better Auth, Clerk usw.), übernimmt diese die Sitzungsmechanik, aber die Regeln der Abschnitte 2, 6–10 bleiben gültig. Der Code befindet sich in references/implementation.md.
Priorität der Regeln. Die Anweisungen des Benutzers und die Agent-Dateien des Projekts sind wichtiger. Wenn das Backend etwas anderes vorgibt (z. B. selbst Cookies setzt), befolge das Backend und dokumentiere die Abweichung in der README oder einem ADR.
Bezeichnungen: MUST – darf nicht verletzt werden; SHOULD – standardmäßig ja, Abweichung erfordert eine Begründung.
1. Auswahl des Modells
| Modell | Wann |
|---|---|
| BFF: Next-Route-Handler erhalten Token vom Backend und legen sie in HttpOnly-Cookie | Backend gibt Token im Antwortkörper zurück. Standardoption für Next |
| Backend setzt selbst HttpOnly-Cookie | Frontend und API auf derselben Website, Backend unter eigener Kontrolle |
| Bibliothek oder Anbieter | OAuth, MFA, Benutzerverwaltung erforderlich – nicht selbst schreiben |
| Access im Speicher, Refresh in HttpOnly-Cookie | SPA ohne BFF |
Token in localStorage/sessionStorage | Verboten: Jedes XSS entführt die Sitzung |
Zweite Entscheidung – was der BFF proxyt:
| Option | Vorteile | Nachteile |
|---|---|---|
| Nur Login / Refresh / Logout, alle anderen Anfragen gehen direkt mit Cookie zur API | BFF kein Engpass; weniger Code | API liest Token aus Cookie und konfiguriert CORS; Cookies müssen die API-Domain erreichen |
| Alle Anfragen über BFF | API kennt keine Cookies und CORS; ein Ursprung | Jede Anfrage ein zusätzlicher Hop; BFF muss skaliert werden |
Die Entscheidung wird in einem ADR dokumentiert.
2. Cookie (MUST)
HttpOnlyfür alles, das Zugriff gewährt (Access, Refresh, Sitzungs-ID).Securein der Produktion. Lokal erlauben BrowserSecureaufhttp://localhost, aber nicht auf anderen http-Hosts.SameSite=Laxstandardmäßig.Strictsicherer gegen CSRF, aber Benutzer, die über einen Link aus einer E-Mail oder einer anderen Website kommen, wirken beim ersten Aufruf nicht autorisiert – wähle bewusst und dokumentiere den Grund.Path=/; Lebensdauer ausexpdes Tokens oder aus der Backend-Antwort, nicht „nach Gefühl“.- SHOULD: Präfix
__Host-im Namen (erfordertSecure,Path=/, ohneDomain) – Cookie kann nicht von einer Subdomain überschrieben werden. - Cookies werden nur serverseitig gesetzt und gelöscht (Route Handler, Server
Action,
proxy.ts). Server Components können keine Cookies setzen. - Ein aus JS nicht lesbares Cookie ist der einzige Sitzungsträger. Ein Flag wie
is_authenticatedohneHttpOnlyist nur als UI-Hinweis zulässig und wird niemals für Zugriffsentscheidungen verwendet.
3. BFF: Route Handler (MUST)
- Login wird mit einem Schema validiert (
zod); fehlerhafter Körper –400. - Mutierende Handler prüfen
Origingegen den Ursprung der Anwendung:SameSite=Laxschützt nicht vor Anfragen von Subdomains derselben Site. Server Actions vergleichenOriginselbst mitHost. - Der Client-Kontext wird an das Backend weitergeleitet:
X-Forwarded-For,User-Agent– sonst sieht das Rate-Limit des Backends nur die IP des Servers. - Backend-Fehler werden in Antworten mit
kindund Status (401,403,422,429mitRetry-After,503) übersetzt, der Backend-Text gelangt nicht nach außen. - Refresh: Wenn das Backend den Refresh-Token abgelehnt hat – Cookie löschen und mit
401antworten. Wenn das Backend nicht erreichbar ist (Netzwerk, 5xx) – Cookie nicht anfassen, mit503antworten: Ein temporärer Ausfall sollte nicht ausloggen. - Logout: Token im Backend widerrufen (Fehler loggen, aber nicht anzeigen), Cookie in jedem Fall löschen.
4. Wo die Sitzung geprüft wird (MUST)
| Ort | Was es tut | Was es nicht tut |
|---|---|---|
proxy.ts (Next 16+, früher middleware.ts) | Optimistische Prüfung auf Vorhandensein des Cookies: Redirect zum Login und von Login-Seiten | Greift nicht auf DB oder API zu; nicht der einzige Schutz |
Data Access Layer (verifySession() in server-only-Modul) | Prüfung nahe an den Daten: in Serverkomponenten, Route Handlern, Server Actions | – |
| Layout | Nichts: Layouts werden bei Navigation nicht neu gerendert | Prüft keinen Zugriff |
| Backend | Quelle der Wahrheit: prüft Token bei jeder Anfrage | – |
- Jeder Route Handler und jede Server Action ist ein öffentlicher Einstiegspunkt und
ruft selbst
verifySession()auf. proxy.tsprüft das Refresh-Cookie (oder die Sitzungs-ID): Access könnte abgelaufen sein, die Sitzung aber noch leben.- Pfadabgleich – nach Segmenten:
/appund/app/…, aber nicht/apple. - Mit
cacheComponents(standardmäßig in Projekten mit Next 16.4) gelten für das Lesen der Sitzung und das Zwischenspeichern von Benutzerdaten eigene Regeln: Bevor du DAL schreibst, liesnode_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md. - Berechtigungen (Rolle, Besitz) werden anhand geprüfter Daten entschieden: per Signatur (JWKS,
jose) oder per Backend-Antwort. Ein ungeprüfter JWT-Payload ist keine Grundlage für Zugriff. - Server Components können Cookies nicht aktualisieren. Wenn der Access zum Zeitpunkt des
serverseitigen Renderings abgelaufen ist: entweder aktualisiert
proxy.tsdie Sitzung (es kann Cookie in der Antwort setzen – tue dies nur für Dokumentnavigationen, nicht für Prefetch, und beachte die Rotation von Refresh-Token), oder die Seite rendert einen clientseitigen Ladevorgang und der Refresh erfolgt clientseitig.
5. Client: Refresh ohne Race Conditions (MUST)
- Erkennung „nicht autorisiert“: HTTP
401, oder in einer GraphQL-Antwort (die200ist) mindestens ein Fehler mitextensions.code === "UNAUTHENTICATED"(errors.some, nichterrors[0]). Vergleich mit dem Nachrichtentext – nur wenn das Backend keine Codes liefert, in einer einzigen Funktion, mit Kommentar und Test. - Single-Flight: Parallele Anfragen, die 401 erhalten, warten auf einen gemeinsamen Refresh und
verwenden sein Ergebnis. Das Promise wird in
finallyzurückgesetzt. - Die ursprüngliche Anfrage wird einmal wiederholt. Ein erneuter 401 – Sitzung verloren, ohne zweiten Refresh.
- Die Refresh-Anfrage selbst durchläuft nicht den Interceptor (sonst Rekursion).
- Das Refresh-Ergebnis unterscheidet drei Fälle: ausgestellt (
true) → wiederholen; abgelehnt (false) → Sitzung abgelaufen; Netzwerk oder 5xx (Exception) → Fehlernetwork, ohne Ausloggen. - Während des Logouts ist der Refresh blockiert: Sonst würde eine Anfrage, die während des Logouts einen 401 erhält, sofort die Sitzung wiederherstellen.
- Abgelaufene Sitzung: Anfrage-Cache leeren, zur Login-Seite mit
callbackUrlder aktuellen Seite umleiten. - Der Körper der wiederholten Anfrage muss wiederholbar sein (String,
FormData), kein einmaligerReadableStream.
6. Redirect nach Login (MUST)
callbackUrl(next,returnTo) wird mit der FunktiongetSafeRedirect(raw, fallback, appOrigin)geprüft: lehnt\und Steuerzeichen ab (Browser wandeln/\evil.comund/<tab>/evil.comin//evil.comum), parstnew URL(raw, appOrigin), vergleicht den Origin und gibt nur den Pfad zurück. Eine Prüfung auf „beginnt mit/und nicht mit//“ ist nicht ausreichend.- Ein Testsatz dafür – in
references/implementation.md, Abschnitt 7. - Nach dem Login müssen Serverkomponenten die neuen Cookies sehen: vollständige
Navigation (
window.location.assign) oderrouter.replace+router.refresh().
7. Logout (MUST)
- Der Server löscht das Cookie (Abschnitt 3).
- Der Client leert den Anfrage-Cache (
queryClient.clear()) und alle clientseitigen Stores mit Benutzerdaten: Der nächste Benutzer im selben Tab darf keine fremden Daten sehen, nicht einmal für einen Moment. - Navigation zur Login-Seite ohne Möglichkeit, „zurück“ zum zwischengespeicherten geschützten Bildschirm zu gehen (
replaceoder vollständige Navigation). - Logout-Bestätigung – nach Produktwunsch; Button-Sperre während der Anfrage – zwingend.
- SHOULD: Andere Tabs erfahren vom Logout (
BroadcastChanneloderstorage-Event über ein Flag), sonst zeigen sie weiterhin Daten bis zum ersten 401 an.
8. Token in Links (Passwort-Reset, E-Mail-Bestätigung)
- Das Token wird einmalig aus der URL in einen Zustand gelesen, dann wird die Adresse ohne Token ersetzt (
router.replace(pathname)): Es bleibt nicht im Verlauf und gelangt nicht in denReferer. - Für diese Seiten –
Referrer-Policy: no-referrerund keinerlei Drittanbieter-Skripte (Analytics, Widgets). - Das Token wird nicht geloggt. Einmaligkeit und Lebensdauer gewährleistet das Backend; das Frontend zeigt einen verständlichen Bildschirm „Link abgelaufen“ mit der Aktion „Neuen anfordern“.
9. Login- und Wiederherstellungsformulare
- Passwort-Wiederherstellung antwortet gleich für existierende und nicht existierende E-Mail-Adressen („Wenn ein Konto existiert, wurde eine E-Mail gesendet“), ohne preiszugeben, wer registriert ist. Wenn das Backend dies preisgibt – als Einschränkung dokumentieren.
- Das Absenden wird während der Anfrage blockiert; erneutes Absenden erzeugt keine zweite Anfrage.
429: Nachricht mit Wartezeit ausRetry-After, falls vorhanden.- Felder:
type="email"+autoComplete="email"; Passwort –autoComplete="current-password"beim Login und"new-password"bei Registrierung und Änderung; Einfügen wird nicht blockiert. - Button „Passwort anzeigen“ –
type="button",aria-labelundaria-pressed, entzieht dem Feld nicht den Fokus. - Fehler „Ungültige E-Mail oder Passwort“ präzisiert nicht, was genau falsch ist.
- Serverfehler werden Feldern per Code zugeordnet; allgemeiner Fehler – über dem Formular.
10. Daten und Logs
- Benutzerabhängige Antworten gelangen nicht in allgemeine Caches
(
"use cache", CDN). QueryClient auf dem Server – neu pro Anfrage. - In Logs befinden sich keine Token, Passwörter, E-Mail-Adressen. Maximal – Benutzer-ID und
kinddes Fehlers. - Der Payload des Sitzungstokens ist minimal: ID und Rolle, ohne personenbezogene Daten.
11. Tests
- Unit:
getSafeRedirect(vollständiger Satz aus references); Erkennung „nicht autorisiert“ (401, GraphQL-Codes, leerer Körper, HTML statt JSON). - Single-Flight: N parallele Anfragen mit 401 → ein Refresh, N Wiederholungen;
Refresh abgelehnt → keine Wiederholung, ein Aufruf von
onSessionExpired; Refresh via Netzwerk fehlgeschlagen → Fehlernetwork, ohne Logout; erneuter 401 nach erfolgreichem Refresh → ohne zweiten Refresh; während des Logouts → kein Refresh. - Route Handler: Cookie-Flags; Refresh abgelehnt → Cookie gelöscht;
Backend nicht erreichbar → Cookie vorhanden,
503; ungültiger Körper →400; fremderOrigin→403. - e2e: Login → geschützte Seite → Logout → geschützte Seite nicht erreichbar; Ablauf des Access (kurzer TTL in der Testumgebung) → transparenter Refresh; Passwort-Reset-Link → Token aus der Adressleiste verschwunden.
12. Definition of Done
- Kein Token wird aus JS gelesen;
localStoragewird nicht für die Sitzung verwendet. - Cookie:
HttpOnly,Securein der Produktion,SameSite,Path, Lebensdauer aus Token. - Refresh: Ablehnung und Nichterreichbarkeit des Backends werden unterschieden.
- Single-Flight mit Ergebnis, eine Wiederholung, Blockade während Logout – durch Tests abgedeckt.
-
proxy.ts– nur optimistische Prüfung; DAL wird in Handlern, Actions und Serverkomponenten aufgerufen. -
getSafeRedirectmit vollständigem Testsatz. - Logout leert Cache und Client-Stores.
- Token aus Links werden aus der Adressleiste entfernt.
- In Logs keine Token und personenbezogenen Daten.
- Modell- und Proxy-Auswahl im ADR dokumentiert.