Auth.js passwordChangedAt: Warum eigene JWT-Claims lautlos verschwinden — und der iat-Fallback, der es behoben hat
Wir haben eine Routine-Härtung an einer Next.js-App ausgerollt: passwordChangedAt auf die User-Zeile stempeln, JWTs verwerfen, die vor diesem Zeitstempel ausgestellt wurden. Standard-Playbook. Admin-Logins liefen weiter. Reguläre Benutzer wurden bei der nächsten Anfrage nach dem Login ausgeloggt. Das ist die Aufzeichnung dessen, was uns Ausrollen — und Rückrollen — von passwordChangedAt-basierter Session-Invalidierung in Auth.js über Custom-Claim-Persistenz im JWT-Callback, den iat-Fallback, der die Regression behoben hat, und die Falle 'fehlender Claim = widerrufen' beigebracht hat.
Auth.js passwordChangedAt: Warum eigene JWT-Claims lautlos verschwinden — und der iat-Fallback, der es behoben hat
Die gefährlichste Sorte Sicherheits-Patch ist die, die im Review offensichtlich aussieht und jeden Test besteht, den das Team gegen sie geschrieben hat. Vor zwei Wochen haben wir eine Session-Härtung am ZSO-Management-System ausgerollt — der Next.js-App, mit der die Freiwilligen der Schweizer Zivilschutzorganisation ihre Aufgebote, Appelllisten und Lektionsplanung führen. Die Änderung war Routine: wenn ein Admin ein Benutzerpasswort zurücksetzt, passwordChangedAt auf die User-Zeile stempeln; im Auth.js-JWT-Callback die Session verwerfen, wenn der Token vor diesem Zeitstempel ausgestellt wurde. Lehrbuch. Zwei Absätze Code. Gemerged, deployed, weggegangen. Fünfzehn Minuten später meldeten reguläre Benutzer, sie würden nach dem Einloggen bei der nächsten Aktion sofort auf /login zurückgeworfen — während jeder Admin-Account, mit dem wir testeten, einwandfrei funktionierte. Das ist die Aufzeichnung der Auth.js-passwordChangedAt-JWT-Invalidierungsfalle, des iat-Fallbacks, der sie behoben hat, und der allgemeineren Lehre darüber, welche JWT-Claims Auth.js tatsächlich durch seine Callback-Kette persistiert.
Die Änderung, die im Review in Ordnung aussah
Das Muster ist eines, das jede session-basierte App irgendwann hinzufügt. Eine stateless JWT-Session ist schnell, günstig und liest sich wie eine Ein-Zeilen-Abhängigkeit. Sie ist auch per Konstruktion nicht widerrufbar — ein gestohlener Token bleibt gültig, bis er abläuft, und ein Passwort-Reset invalidiert keine bereits ausgestellte Session. Der Standard-Fix ist eine Zeitstempel-Spalte auf der User-Tabelle, ein eigener Claim auf dem JWT und ein Vergleich in der Mitte: wenn dbUser.passwordChangedAt > token.passwordChangedAt, dann liegt der Token vor dem Reset, verwerfen. Auth.js dokumentiert dieses Muster in seinen eigenen Callback-Docs; jedes Framework, das JWT-Sessions ausliefert, hat eine Variante davon.
Unsere Umsetzung war die langweilige Form. passwordChangedAt wurde als nullable DateTime am Prisma-User-Modell hinzugefügt, und die beiden Schreibstellen, die ein Passwort mutieren, stempelten den Zeitstempel beim Rausgehen. Der Auth.js-authorize-Callback gab den Zeitstempel mit dem restlichen User-Objekt zurück, damit der initiale JWT ihn tragen würde. Der jwt-Callback holte den aktuellen DB-User bei jeder Anfrage und, falls dbUser.passwordChangedAt gesetzt war und der Token-Claim entweder fehlte oder älter war, gab er einen leeren Token zurück — was Auth.js als "diese Session verwerfen und Re-Authentifizierung erzwingen" behandelt. Zwei Dutzend Zeilen. Tests für "JWT von gestern gegen passwordChangedAt von heute invalidieren" bestanden. Tests für "JWT von heute behalten, wenn passwordChangedAt gestern war" bestanden. Gemerged.
Der Bug tauchte unter einer Form auf, die kein Unit-Test abdeckte: ein regulärer Benutzer, dessen Passwort irgendwann in der Vergangenheit von einem Admin zurückgesetzt worden war, loggt sich frisch mit dem neuen Passwort ein, bekommt eine Session, und trifft eine authentifizierte Route. Die allererste Folge-Anfrage nach dem Login wurde abgelehnt. Seite neuladen — ausgeloggt. Erneut einloggen — nach einer Anfrage wieder ausgeloggt. Admin-Accounts, deren Passwörter nie durch den Reset-Flow gegangen waren, funktionierten problemlos.
Warum Admin-Logins den gleichen Check bestanden, den reguläre Logins nicht bestanden
Die Asymmetrie ist der verräterische Hinweis. Beide User gehen durch denselben authorize-Callback, denselben jwt-Callback, denselben Vergleich. Der Check, der die Invalidierung auslöst, ist wortwörtlich dbUser.passwordChangedAt && (fehlender Token-Claim || db > token). Was Admin von regulärem User trennt, in einem System, in dem noch nie ein Admin-Passwort zurückgesetzt wurde, ist genau eine Sache: dbUser.passwordChangedAt ist NULL für Admins und nicht-null für die regulären User, die einen Reset durchlaufen hatten. Der Kurzschluss auf der linken Seite des && übersprang den ganzen Zweig für Admins, also wurde die Frage "fehlender Claim?" nie gestellt. Reguläre User trafen die rechte Seite des && und bekamen mitgeteilt, ihr Token sei vor der Passwort-Änderung ausgestellt worden — auch wenn der Token drei Sekunden zuvor aus einem Login mit dem neuen Passwort geboren wurde.
Der Grund, warum die Frage "fehlender Claim" einen frischen Token durchfallen liess, ist der eigentliche Fund der Woche. Der JWT-Callback von Auth.js läuft bei jeder authentifizierten Anfrage. Das token-Argument beim ersten Aufruf ist die Form, die authorize zurückgibt; bei jedem folgenden Aufruf ist es die Form, wie sie aus dem Cookie dekodiert wird. Zwischen diesen beiden lebt ein Encode-Schritt, den Auth.js nicht vollständig offenlegt: es JSON-serialisiert den Token in eine JWE-Payload, und eigene, nicht-standardisierte Claims überleben diese Reise im Prinzip. In der Praxis sahen wir zu, wie token.passwordChangedAt — ein Date, das aus authorize zurückgegeben wurde — bei der Anfrage direkt nach dem Login als undefined durch die Callback-Kette zurückkam. Das Framework behandelte es als Objekt, das es nicht sicher serialisieren konnte, und liess es fallen. Der Rest des Tokens — id, role, iat, exp, sub, die Felder, die Auth.js besitzt — round-trippte exakt wie erwartet.
Der Zustand bei Anfrage N+1 war also: dbUser.passwordChangedAt = 2026-08-13T09:15:00Z (Admin-Reset aus dem Juli), token.passwordChangedAt = undefined, token.iat = 1755097200 (drei Sekunden alt). Unser Vergleich las das als "Token hat kein passwordChangedAt, ist also ein Legacy-Token aus der Zeit vor Einführung des Feldes, ist also per Definition älter als der DB-Zeitstempel, verwerfen." Diese Logik ist für einen Legacy-Token korrekt. Für einen frischen ist sie eine Katastrophe, und jedes Feld auf dem Token ausser dem einen eigenen, das uns interessierte, sagte uns bereits, in welchem Fall wir uns befanden — wir haben es nur nicht gefragt.
Der iat-Fallback
Der Fix ist kurz genug, um ihn auf eine Serviette zu schreiben. Auth.js setzt token.iat (Issued-at, in Sekunden) auf jedem Token, den es ausstellt, und lässt es nie fallen — es ist Teil des JWT-Standards, kein Custom-Claim. Wenn der Custom-Claim passwordChangedAt vorhanden ist, benutze ihn. Wenn er fehlt, greif zum Vergleich dbPasswordChangedAt gegen token.iat * 1000. Wurde der Token nach der Passwort-Änderung ausgestellt, Session behalten. Wurde er davor ausgestellt, Session verwerfen — denn jetzt weisst du tatsächlich, dass er vor dem Reset liegt, statt es aus einem fehlenden Claim zu erraten. Und wenn sowohl der Claim als auch (in einigen Randfällen) iat fehlen, nicht widerrufen: eine gestohlene Session läuft immer noch über jwt.maxAge ab, und die Kosten eines fälschlich widerrufenen frischen Logins sind höher als die einer Session, die ein, zwei Anfragen überlebt, bevor der nächste echte Check greift.
Zwei kleinere Details überlebten das Review dieses Fixes. Eine zwei Sekunden lange Uhr-Skew-Toleranz um den Vergleich, denn in load-balancierten Deploys sind die DB-Uhr und die App-Uhr nicht dieselbe Uhr, und ein strikter Grösser-als-Check stolpert gelegentlich über Writes, die vordergründig gleichzeitig sind. Und ein Akzeptiere-ISO-String-Zweig auf der Token-Seite, denn ein Date, das aus authorize zurückgegeben wird, kommt in einer Teilmenge der Fälle durch die JWE-Dekodierung als sein .toISOString() zurück — "2026-08-13T09:15:00.000Z", was weder Zahl noch undefined ist und sonst still durch den numerischen Coercion-Pfad gefallen wäre. Der vollständige Check zog in ein eigenes Modul, damit der JWT-Callback ein Einzeiler bleibt und die Freshness-Regel Unit-testbar ist, ohne Auth.js überhaupt anzufassen.
Der Test, der das im Review gefangen hätte, ist der, den wir jetzt fahren: ein dbPasswordChangedAt in der Vergangenheit, ein token.iat in der Zukunft, kein token.passwordChangedAt-Claim, und die Behauptung, dass die Session überlebt. Plus der spiegelbildliche Fall — gleiches dbPasswordChangedAt, token.iat davor, fehlender Claim — und die Behauptung, dass die Session fällt. Beide Fälle sind die fehlgeschlagene Regression, und keiner wird von "JWT von gestern gegen Reset von heute invalidieren" abgedeckt, denn dieser Test befüllt den Custom-Claim immer.
Der Second-Order-Fix, um den niemand gebeten hat
Eine kleinere Regression fiel aus derselben Wurzelursache heraus und ist es wert benannt zu werden, denn sie ist exakt dieselbe Bug-Klasse: ein Härtungs-Check, dessen Blast-Radius über den Workflow hinausging, für den er gedacht war. Der Sicherheits-Patch begann ausserdem, isActive = false-User an der Auth-Schicht abzulehnen — für Offboarding völlig richtig. Er hatte einen Second-Order-Effekt, den niemand im Review erwähnte: Admin-Passwort-Reset war umgesetzt als "neues Passwort setzen, isActive = false bis der User sich damit einloggt, bei erstem erfolgreichem Login zurückkippen." Ein Test-Modus-Workflow, aber ein realer. Die neue Inaktiv-Ablehnung bedeutete, dass der Reset den Account jetzt un-einlogbar machte, weil der Zweig "bei erstem erfolgreichem Login zurückkippen" nicht mehr erreicht werden konnte. Fix: die Admin-Passwort-Reset-Schreibstelle setzt jetzt isActive = true in derselben Transaktion, in der sie passwordChangedAt stempelt. Die Disziplin ist dieselbe, die den JWT-Bug fängt — benenne jeden Zweig, der die mutierte Spalte anfasst, und prüfe, ob die neue Invariante einen davon zerstört hat.
Drei Regeln für eigene JWT-Claims
Drei Regeln überlebten dieses Rewrite und lassen sich auf jedes Framework — Auth.js, NextAuth v4, better-auth oder eine handgeschriebene JWT-Schicht — verallgemeinern, das erlaubt, nicht-standardisierte Claims an einen Session-Token zu hängen:
Nur den Claims trauen, die das Framework besitzt. iat, exp, sub sind Teil des JWT-Standards, und jede ernsthafte Library round-trippt sie ohne zu droppen. Custom-Claims, die du hinzugefügt hast — passwordChangedAt, role, orgId, was auch immer — round-trippen nur so gut, wie sich Encoder und Decoder des Frameworks einig sind, und der Failure-Modus ist undefined, keine Exception. Wenn deine Invariante darauf beruht, dass ein Custom-Claim vorhanden ist, definiere immer, was passiert, wenn er es nicht ist, und bevorzuge "Fallback auf einen Standard-Claim" gegenüber "das Schlimmste annehmen."
Ein fehlender Claim ist kein Beweis für irgendetwas. Die Falle ist, token.customThing === undefined als "alter Token aus der Zeit vor dem Feld, widerrufen" zu behandeln. Ein frischer Token hat nicht mehr Beweise für das Feld als ein Legacy-Token; der Encoder hat es in beiden Fällen einfach gedroppt. Die richtige Frage ist "wurde der Token ausgestellt, bevor sich die Invariante änderte?" — und iat ist das, was das beantwortet, nicht die Anwesenheit des Custom-Claims.
Prüfe jeden Workflow, der eine mutierte Spalte anfasst, nicht nur den, den du gerade fixt. passwordChangedAt wurde aus einem Grund eingeführt (alte Sessions bei Reset invalidieren) und wuchs sofort auf drei weitere Konsumenten (den JWT-Check, die isActive-Interaktion, den E-Mail-Lookup-Fallback). Eine Spalte zu einer User-Zeile hinzuzufügen ist eine Änderung an jedem Workflow, der irgendein benachbartes Feld liest oder schreibt, und so zu tun, als wäre es nicht so, macht aus einem Ein-Datei-Fix einen Freitagabend-Rollback. Die Review-Disziplin, die das fängt, ist langweilig: grep nach jeder Referenz auf user.-Felder in derselben Datei wie die Mutation, und lies jede einzelne davon.
Die Zusammensetzung reimt sich auf die Debounced-Autosave-Finalize-Gate-Aufzeichnung — noch ein Fall, in dem der Fix ein Primitive war, aber der Audit, der den Fix sicher landen liess, viel grösser war als der Diff. Sie reimt sich auf der Auth-Seite auch auf die URL-Token-Portal-Auth-Aufzeichnung von letzter Woche: sobald du anfasst, wie Sessions erstellt oder validiert werden, sind die Second-Order-Fragen — was passiert bei Rotate, was passiert bei Ablauf, was passiert bei fehlendem Claim — diejenigen, die den Bug ausliefern.
Session-Härtung ist eine dieser Änderungen, bei denen jede einzelne Zeile offensichtlich korrekt aussieht und die Regression sich in der Komposition versteckt. Der passwordChangedAt-Claim selbst ist die richtige Form für das Problem. Der Fehler war, "fehlt bedeutet widerrufen" als Default zu erlauben, wenn ein Standard-, vom Framework besessener Claim uns bereits die Wahrheit sagte. Der Fallback auf iat ist ein Ein-Tages-Fix, sobald du die Falle kennst, und der Sinn dieser Aufzeichnung ist, dass das nächste Team keinen Freitagabend damit verlieren muss, sie zu finden.
Wenn du Session-Invalidierung auf einer Next.js-Auth.js-App verdrahtest — oder bereits eine hast, in der User aus Gründen ausgeloggt werden, die deine Logs dir nicht verraten — buche einen kostenlosen AI-Potenzial-Check, oder lies die Next.js-Worker-Split-mit-pg_notify-Aufzeichnung für die Prozessgrenzen-Hälfte desselben "welche Schicht besitzt welche Invariante"-Themas.