fix(security): invalidate JWT sessions after password change#28657
fix(security): invalidate JWT sessions after password change#28657claygeo wants to merge 3 commits intocalcom:mainfrom
Conversation
When a user changes their password, existing JWT session tokens on other devices remain valid for up to 30 days. Add passwordChangedAt timestamp to User model, set it on password change, and check it in the JWT callback to invalidate sessions issued before the change. Fixes calcom#28392
There was a problem hiding this comment.
2 issues found across 3 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/features/auth/lib/next-auth-options.ts">
<violation number="1" location="packages/features/auth/lib/next-auth-options.ts:664">
P1: Returning an empty JWT breaks downstream assumptions (`token.email!`) in the jwt callback and can cause invalid user lookup/session behavior after password-change invalidation.</violation>
</file>
<file name="packages/trpc/server/routers/viewer/auth/changePassword.handler.ts">
<violation number="1" location="packages/trpc/server/routers/viewer/auth/changePassword.handler.ts:76">
P2: Password hash update and passwordChangedAt update are not atomic; a failure after the upsert can leave passwordChangedAt unset, allowing old JWTs to remain valid despite the password change.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
packages/trpc/server/routers/viewer/auth/changePassword.handler.ts
Outdated
Show resolved
Hide resolved
- Return token with cleared identity fields instead of empty JWT to prevent downstream crashes on token.email!, token.id etc. (P1) - Wrap password hash + passwordChangedAt updates in $transaction for atomicity — prevents stale JWTs if second write fails (P2)
… change Replace the previous approach of clearing email/name/id fields with a typed `error: "SessionInvalidated"` flag on the JWT token. This prevents unnecessary DB lookups on subsequent JWT refreshes (the early-exit in autoMergeIdentities short-circuits immediately), and propagates the error through the session callback so the client can redirect to sign-in cleanly. Also adds `error?: "SessionInvalidated"` to both the Session and JWT TypeScript interfaces so callers can type-safely check for invalidated sessions.
|
Thanks for the review @cubic-dev-ai — both issues are addressed in the latest push. P1 — Empty JWT breaking downstream assumptions (fixed) The previous approach of returning Replaced with an explicit
P2 — Non-atomic password + passwordChangedAt update (already addressed) The await prisma.$transaction([
prisma.user.update({ where: { id: user.id }, data: { password: { upsert: ... } } }),
prisma.user.update({ where: { id: user.id }, data: { passwordChangedAt: new Date() } }),
]);If the second update fails, Prisma rolls back the first — passwordChangedAt and the password hash are always in sync. The atomicity concern cubic flagged was valid for an earlier version of this PR; the transaction was added in a prior commit. |
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/features/auth/lib/next-auth-options.ts">
<violation number="1" location="packages/features/auth/lib/next-auth-options.ts:671">
P2: Invalidated JWTs now keep identity fields and server-side session creation ignores token.error, so password-change invalidation can still yield authenticated sessions on server routes.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| // Mark the token as invalidated. The early-exit at the top of autoMergeIdentities | ||
| // prevents any further DB lookups on subsequent JWT refreshes, and the session | ||
| // callback propagates the error to the client so it can redirect to sign-in. | ||
| return { ...token, error: "SessionInvalidated" } as JWT; |
There was a problem hiding this comment.
P2: Invalidated JWTs now keep identity fields and server-side session creation ignores token.error, so password-change invalidation can still yield authenticated sessions on server routes.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/features/auth/lib/next-auth-options.ts, line 671:
<comment>Invalidated JWTs now keep identity fields and server-side session creation ignores token.error, so password-change invalidation can still yield authenticated sessions on server routes.</comment>
<file context>
@@ -661,10 +665,10 @@ export const getOptions = ({
+ // Mark the token as invalidated. The early-exit at the top of autoMergeIdentities
+ // prevents any further DB lookups on subsequent JWT refreshes, and the session
+ // callback propagates the error to the client so it can redirect to sign-in.
+ return { ...token, error: "SessionInvalidated" } as JWT;
}
}
</file context>
Problem
Fixes #28392
Existing JWT sessions persist after password change, allowing stolen tokens to retain access for up to 30 days.
Fix
passwordChangedAtto User modeltoken.iat <= changedAtSecondsin JWT callback to invalidate old sessions3 files, 17 lines. Replaces #28642 which had unrelated changes.