Auth.js passwordChangedAt: Why Custom JWT Claims Silently Vanish, and the iat Fallback That Fixed It
We rolled out a routine session-hardening change to a Next.js app: stamp passwordChangedAt on the user row, invalidate JWTs that were minted before that timestamp. Standard playbook. Admin logins kept working. Regular users were signed out on their very next request after login. This is what shipping — and then unshipping — passwordChangedAt-based session invalidation in Auth.js taught us about custom-claim persistence in the JWT callback, the iat fallback that fixed the regression, and why the missing-claim-means-revoked branch is the trap that catches everyone.
Auth.js passwordChangedAt: Why Custom JWT Claims Silently Vanish, and the iat Fallback That Fixed It
The most dangerous kind of security patch is the one that looks obvious in review and passes every test the team ran against it. Two weeks ago we rolled out a session-hardening pass on the ZSO Management System — the Next.js app the Swiss Zivilschutzorganisation (civil-protection) volunteers use to run rosters, attendance lists, and lesson planning. The change was routine: when an admin resets a user's password, stamp passwordChangedAt on the user row; in the Auth.js JWT callback, drop the session if the token was minted before that timestamp. Textbook. Two paragraphs of code. Merged, deployed, walked away. Fifteen minutes later regular users started reporting they were bounced back to /login on their very next click after signing in — while every admin account we tested with kept working perfectly. This is the writeup of the Auth.js passwordChangedAt JWT invalidation trap, the iat fallback that fixed it, and the more general lesson about which JWT claims Auth.js will actually persist through its callback chain.
The Change That Looked Fine In Review
The pattern is one every session-based app eventually adds. A stateless JWT session is fast, cheap, and reads like a single-line dependency. It is also unrevocable by construction — a stolen token stays valid until it expires, and rotating a user's password does not invalidate any session already handed out. The standard fix is one timestamp column on the user table, one custom claim on the JWT, and one comparison in the middle: if dbUser.passwordChangedAt > token.passwordChangedAt, the token predates the password reset, drop it. Auth.js documents this pattern in its own callback docs; every framework that ships JWT sessions has some variant of it.
Our implementation was the boring shape. passwordChangedAt was added as a nullable DateTime on the Prisma User model, and the two write sites that mutate a password stamped it on the way out. The Auth.js authorize callback returned the timestamp with the rest of the user object so the initial JWT would carry it. The jwt callback fetched the current DB user on each request and, if dbUser.passwordChangedAt was set and the token's claim was either missing or older, returned an empty token — which Auth.js treats as "drop this session and force re-authentication." Two dozen lines. Tests for "invalidate a JWT issued yesterday against a passwordChangedAt set today" passed. Tests for "keep a JWT issued today when passwordChangedAt was set yesterday" passed. Merged.
The bug showed up under a shape no unit test exercised: a regular user, whose password had been reset by an admin some time in the past, logs in fresh with the new password, receives a session, and hits any authenticated route. The very next request after login was rejected. Reload the page — signed out. Log in again — signed out again after one request. Admin accounts, whose passwords had never been through the reset flow, worked fine.
Why Admin Logins Passed The Same Check Regular Logins Failed
The asymmetry is the tell. Both users go through the same authorize callback, the same jwt callback, the same comparison. The check that fires the invalidation is literally dbUser.passwordChangedAt && (missing token claim || db > token). What separates admin from regular user, in a system where nobody has ever reset an admin's password, is exactly one thing: dbUser.passwordChangedAt is NULL for admins and non-null for the regular users who had gone through a reset. The short-circuit on the left of the && skipped the whole branch for admins, so the missing-claim question never got asked. Regular users hit the right side of the && and were told their token had been minted before their password changed — even when the token had just been minted three seconds ago from a login that used the new password.
The reason the missing-claim question failed a fresh token is the actual finding of the week. Auth.js's JWT callback runs on every authenticated request. The token argument on the first call is the shape returned by authorize; on every subsequent call it is the shape as decoded from the cookie. Between those two lives an encode step Auth.js does not fully expose: it JSON-encodes the token to a JWE payload, and custom non-standard claims survive that trip in principle. In practice, we watched token.passwordChangedAt — a Date returned from authorize — come back through the callback chain as undefined on the request immediately after login. The framework treated it as an object it was not sure how to serialize and dropped it. The rest of the token — id, role, iat, exp, sub, the fields Auth.js owns — round-tripped exactly as expected.
So the state on request N+1 was: dbUser.passwordChangedAt = 2026-08-13T09:15:00Z (admin reset from July), token.passwordChangedAt = undefined, token.iat = 1755097200 (three seconds ago). Our comparison read that as "token has no passwordChangedAt, so it is a legacy token from before we added the field, so it is definitionally older than the DB timestamp, drop it." That logic is correct for a legacy token. It is a disaster for a fresh one, and every field on the token except the custom one we cared about was already telling us which case we were in — we were just not asking.
The iat Fallback
The fix is short enough to write on a napkin. Auth.js sets token.iat (issued-at, in seconds) on every token it mints, and never drops it — it is part of the JWT standard, not a custom claim. When the custom passwordChangedAt claim is present, use it. When it is missing, fall back to comparing dbPasswordChangedAt against token.iat * 1000. If the token was issued after the password change, keep the session. If it was issued before, drop it — because now you actually know it predates the reset, instead of guessing from a missing claim. And when both the claim and (in some edge cases) iat are missing, do not revoke: a stolen session still expires via jwt.maxAge, and the cost of a wrongly-revoked fresh login is higher than the cost of a session that survives an extra request or two before the next real check catches it.
Two smaller details survived the review of that fix. A two-second clock-skew slack around the comparison, because in load-balanced deploys the DB clock and the app clock are not the same clock and a strictly-greater-than check will occasionally trip on writes that are ostensibly simultaneous. And an accept-ISO-string branch on the token side, because a Date returned from authorize in a subset of cases comes back through the JWE decode as its .toISOString() — "2026-08-13T09:15:00.000Z", which is neither a number nor undefined and would otherwise fall through the numeric-coercion path silently. The full check moved into its own module so the JWT callback stays a one-liner and the freshness rule is unit-testable without touching Auth.js at all.
The test that would have caught this in review is the one we now run: assemble a dbPasswordChangedAt in the past, a token.iat in the future, no token.passwordChangedAt claim, and assert the session survives. Plus the mirror-image case — same dbPasswordChangedAt, token.iat before it, missing claim — and assert the session drops. Both cases are the failing regression, and neither is exercised by "invalidate a JWT issued yesterday against a reset today" because that test always populates the custom claim.
The Second-Order Fix Nobody Asked For
One smaller regression fell out of the same root cause and is worth naming, because it is the exact same class of bug: a hardening check whose blast radius extended past the workflow it was intended for. The security patch also started rejecting isActive = false users at the auth layer — straightforwardly right for offboarding. It had a second-order effect nobody mentioned in review: admin password reset was implemented as "set new password, flip isActive = false until the user logs in with it, flip back on first successful login." A test-mode workflow, but a real one. The new inactive-rejection meant the reset now made the account unloginnable, because the "flip back on first successful login" branch could no longer be reached. Fix: the admin password-reset write site now sets isActive = true in the same transaction that stamps passwordChangedAt. The discipline is the same one that catches the JWT bug — name every branch that touches the mutated column, and audit whether the new invariant broke any of them.
Three Rules For Custom JWT Claims
Three rules survived this rewrite and generalize to any framework — Auth.js, NextAuth v4, better-auth, or a hand-rolled JWT layer — that lets you attach non-standard claims to a session token:
Only trust the claims the framework owns. iat, exp, sub are part of the JWT spec, and every serious library round-trips them without dropping. Custom claims you added — passwordChangedAt, role, orgId, whatever — round-trip only as well as the framework's encoder and decoder happen to agree, and the failure mode is undefined, not an exception. When your invariant depends on a custom claim being present, always define what happens when it is not, and prefer "fall back to a standard claim" over "assume the worst."
A missing claim is not evidence of anything. The trap is treating token.customThing === undefined as "old token, before the field existed, revoke it." A fresh token has no more evidence of the field than a legacy one; the encoder just dropped it either way. The right question is "was the token issued before the invariant changed?" — and iat is what answers that, not the presence of the custom claim.
Audit every workflow that touches a mutated column, not just the one you were fixing. passwordChangedAt was introduced for one reason (invalidate old sessions on reset) and immediately grew three other consumers (the JWT check, the isActive interaction, the email-lookup fallback). Adding a column to a user row is a change to every workflow that reads or writes any nearby column, and pretending otherwise is what turns a one-file fix into a Friday evening rollback. The review discipline that catches this is boring: grep for every reference to user. fields in the same file as the mutation, and read every one of them.
The composition rhymes with the debounced-autosave finalize-gate writeup — another case where the fix was one primitive, but the audit that made the fix land safely was much larger than the diff. It also rhymes on the auth side with the URL token portal auth writeup from last week: whenever you touch how sessions are created or validated, the second-order questions — what happens on rotate, what happens on expiry, what happens when a claim goes missing — are the ones that ship the bug.
Session hardening is one of those changes where every individual line looks obviously correct and the composition is where the regression hides. The passwordChangedAt claim itself is the right shape for the problem. The mistake was letting "missing means revoked" be the default when a standard, framework-owned claim already told us the truth. Falling back to iat is a one-day fix once you know the trap exists, and the point of writing this up is that the next team should not have to lose a Friday evening to find it.
If you are wiring up session invalidation on a Next.js Auth.js app — or already have one where users are being signed out for reasons your logs are not telling you about — book a free AI Potenzial-Check, or read the Next.js worker split with pg_notify writeup for the process-boundary half of the same "which layer owns which invariant" theme.