Skip to content

fix(security): invalidate JWT sessions after password change#28657

Open
claygeo wants to merge 3 commits intocalcom:mainfrom
claygeo:fix/jwt-invalidate-password-change-clean
Open

fix(security): invalidate JWT sessions after password change#28657
claygeo wants to merge 3 commits intocalcom:mainfrom
claygeo:fix/jwt-invalidate-password-change-clean

Conversation

@claygeo
Copy link
Copy Markdown

@claygeo claygeo commented Mar 29, 2026

Problem

Fixes #28392

Existing JWT sessions persist after password change, allowing stolen tokens to retain access for up to 30 days.

Fix

  • Add passwordChangedAt to User model
  • Set it when password is changed
  • Check token.iat <= changedAtSeconds in JWT callback to invalidate old sessions

3 files, 17 lines. Replaces #28642 which had unrelated changes.

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
@claygeo claygeo requested a review from a team as a code owner March 29, 2026 14:05
@github-actions github-actions bot added the 🐛 bug Something isn't working label Mar 29, 2026
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

- 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)
@pull-request-size pull-request-size bot added size/M and removed size/S labels Mar 29, 2026
… 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.
@claygeo
Copy link
Copy Markdown
Author

claygeo commented Mar 30, 2026

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 { ...token, email: "", name: "", id: 0 } was fragile: subsequent autoMergeIdentities calls would hit the DB with email: "", get null back, return the broken token unchanged, and still expose token.email! assertions.

Replaced with an explicit error: "SessionInvalidated" flag following NextAuth's documented error pattern:

  • autoMergeIdentities now has an early-exit: if (token.error === "SessionInvalidated") return token; — no DB lookup, no broken assertions
  • The invalidation return is now return { ...token, error: "SessionInvalidated" } — preserves token shape
  • The session callback propagates the flag: if (token.error === "SessionInvalidated") return { ...session, error: "SessionInvalidated" } — client can detect and redirect to sign-in
  • Both Session and JWT interfaces in packages/types/next-auth.d.ts now declare error?: "SessionInvalidated" for type safety

P2 — Non-atomic password + passwordChangedAt update (already addressed)

The changePassword handler at changePassword.handler.ts:76 already wraps both operations in prisma.$transaction([...]):

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.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug Something isn't working size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cal.com JWT Session Token Persistence After Password Change

1 participant