Skip to content

fix(payload): write only sessions in session updates so concurrent user changes survive - #19434

Open
zawoj wants to merge 1 commit into
payloadcms:3.xfrom
zawoj:fix/session-writes-overwrite-user-row
Open

zawoj wants to merge 1 commit into
payloadcms:3.xfrom
zawoj:fix/session-writes-overwrite-user-row

Conversation

@zawoj

@zawoj zawoj commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

What?

Session bookkeeping on login, refresh, logout and failed-login lockout wrote the whole user row back from a copy read earlier in the request. A change committed to the user in between (password change, a field changed by an admin or a hook) was silently reverted.

Why?

With auth.useSessions (the default), these writes pass the in-memory user to payload.db.updateOne as data:

Code path Write
addSessionToUser (login, reset-password) data: user
revokeSession (login error path, reset-password) data: user
refreshOperation data: { ...user, sessions }
logoutOperation data: userWithSessions (whole row from findOne)
incrementLoginAttempts, the "parallel failed attempts reached the limit" branch data: user

Every column of the row is rewritten, so the stale values of every field win over whatever was committed after the read. The updatedAt = null trick on these paths only keeps the timestamp from moving; it does not stop the other columns from being rewritten.

Observed downstream (Postgres, 3.90.2): a loop of about 8 logins per second against one account reverted a concurrent password change (the old password worked again), an account block, and a custom field reset. The effect is a security problem as well as a data-loss one: a password reset or block can be undone by anyone who keeps logging in.

How?

These writes now send only the columns they own: { sessions, updatedAt }.

  • addSessionToUser, revokeSession, refresh and logout: updatedAt: null (unchanged semantics: leave the column alone).
  • Lockout branch of incrementLoginAttempts: updatedAt keeps the refreshed value it already set.

Nothing else changes; the in-memory user mutations that callers rely on are untouched.

main (v4) has the same pattern (addSessionToUser, revokeSession, extendSession, logoutOperation and the same lockout branch in incrementLoginAttempts), so it is affected too. This PR only targets 3.x; I can send the matching change for main once the approach here is agreed, as the files differ.

Why the tests missed it

  1. Every existing session test is sequential. Login, refresh and logout are each exercised alone; nothing changes the user between the read and the write inside one request, which is the only place a stale whole-row write loses anything.
  2. The assertions look at sessions only. The tests check that sessions were added, removed or refreshed. No test checks that other fields of the user survive that write.
  3. A concurrent change can't be produced from outside. The window between read and write is a few milliseconds inside one operation, so a test has to hold it open from inside (see below).

Test coverage added

New describe('session writes do not overwrite concurrent user changes') in test/auth/int.spec.ts. A helper wraps payload.db.updateOne and, right before the first write that carries sessions, commits a change through the original updateOne (separate transaction), so the race is deterministic rather than timing-based.

Test Guards
field changed before the session is added on login addSessionToUser
password changed before the session is added on login (old password must be rejected, new accepted) addSessionToUser, the reported symptom
field changed before the session is refreshed refreshOperation
field changed before the session is removed on logout logoutOperation
field changed before the lockout branch removes recent sessions after a failed login incrementLoginAttempts

revokeSession shares its write shape with addSessionToUser and is not covered by a separate test; it is only reached from the login/reset-password error paths.

Verification

Run on the unfixed source first, to confirm the failure reason, then with the fix.

Adapter Without fix With fix
Postgres (pnpm test:int:postgres auth/int.spec.ts -t "session writes") 5 failed (concurrent field comes back null; the old password still logs in) 5 passed
MongoDB (pnpm test:int auth/int.spec.ts -t "session writes") 1 failed (lockout: field reverted to its initial value); 4 skipped 1 passed; 4 skipped
  • Full test/auth/int.spec.ts with the fix: Postgres 101 passed / 1 skipped; MongoDB 97 passed / 5 skipped. pnpm test:int:postgres auth (all auth suites): 143 passed / 1 skipped.
  • tsc --noEmit clean for packages/payload, drizzle, db-mongodb, graphql.
  • ESLint: no new errors in the touched files (the test file has 12 errors at HEAD, 12 after); prettier clean.

Why 4 of the tests are skipped on MongoDB

Login, refresh and logout write inside the request's transaction. MongoDB aborts a transaction that writes a document changed after the transaction began (WriteConflict, HTTP 500) instead of overwriting it, both before and after this change. So the lost update cannot be reproduced there with these paths, and the tests skip. The lockout path runs without a transaction and fails/passes on both adapters. The Mongo WriteConflict on a login that races an update is a separate behaviour that I did not change.

Not verified

  • SQLite and the other drizzle adapters were not run; the change only narrows the data object, but I did not execute it there.
  • The reported symptom was measured on a downstream app (about 8 logins/s loop, Postgres, 3.90.2) with an equivalent patch to the built JS; the in-repo tests reproduce the same lost update deterministically rather than by load.
  • db-postgres was not typechecked (its dependency dist is not built in my checkout); the change does not touch it.
  • No e2e/admin UI tests; the change is on server-side auth operations.
  • main was inspected but not changed or tested.

Notes for review

  • Left alone on purpose: resetLoginAttempts already writes only lockUntil / loginAttempts.
  • Same whole-row pattern, deliberately not changed to keep this PR to session writes: verifyEmail (data: { ...user, _verified: true, ... }) and resetPassword (data: user with sessions = []). They are token-gated one-shot flows rather than something an attacker can trigger in a loop, but they have the same stale-write shape; happy to follow up if you want them narrowed too.
  • A password change through payload.update clears sessions; a login that read the user before it will still write its own sessions array. That remaining "stale sessions" behaviour is narrower than the full-row overwrite and is not changed here.

…er changes survive

Login (`addSessionToUser`), `revokeSession`, refresh, logout and the parallel-attempt lockout
branch of `incrementLoginAttempts` passed the whole user row, read earlier in the request, to
`payload.db.updateOne`. Any change committed between that read and the write (a password change,
a field changed by an admin or a hook) was overwritten with the stale values.

These writes now send only `sessions` and `updatedAt`. `updatedAt: null` still means "leave the
column untouched"; the lockout path keeps writing its own refreshed `updatedAt`.

On MongoDB the transactional paths (login, refresh, logout) fail with a WriteConflict instead of
overwriting, so the new tests for those paths are skipped there; the lockout path runs without a
transaction and is covered on both adapters.
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Pull Request titles must follow the Conventional Commits specification and have valid scopes.

Unknown scope "payload" found in pull request title "fix(payload): write only sessions in session updates so concurrent user changes survive". Scope must match one of: cpa, claude, codemod, db-*, db-d1-sqlite, db-mongodb, db-postgres, db-vercel-postgres, db-sqlite, db-d1-sqlite, drizzle, email-*, email-nodemailer, email-resend, eslint, evals, graphql, kv, kv-redis, live-preview, live-preview-react, live-preview-vue, next, payload-cloud, plugin-cloud, plugin-cloud-storage, plugin-ecommerce, plugin-form-builder, plugin-import-export, plugin-mcp, plugin-multi-tenant, plugin-nested-docs, plugin-redirects, plugin-search, plugin-sentry, plugin-seo, plugin-stripe, richtext-*, richtext-lexical, sdk, skills, storage-*, storage-azure, storage-gcs, storage-r2, storage-vercel-blob, storage-s3, tanstack-start, translations, ui, templates, examples(/(\w|-)+)?, deps.

feat(ui): add Button component
^    ^    ^
|    |    |__ Subject
|    |_______ Scope
|____________ Type

@coderabbitai

coderabbitai Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

Authentication session updates now write only session-related data and timestamp fields instead of the full user object. Integration tests cover concurrent user and password changes during login, refresh, logout, and failed-login flows.

Changes: Authentication session writes

Layer / File(s) Summary
Narrow session update scope
packages/payload/src/auth/operations/logout.ts, packages/payload/src/auth/operations/refresh.ts, packages/payload/src/auth/sessions.ts, packages/payload/src/auth/strategies/local/incrementLoginAttempts.ts
Logout, refresh, session creation and revocation, and failed-login cleanup now update session data rather than the full user object. The updates pass updatedAt: null where specified.
Concurrent-write integration tests
test/auth/int.spec.ts
New tests inject concurrent user-field or password changes before session writes and verify that the changes remain stored. Selected race tests are skipped for Mongoose transactions.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Possibly related PRs

  • payloadcms/payload#19045: Moves the refresh session extension and its database write into extendSession; this PR narrows that refresh write.
  • payloadcms/payload#19040: Builds on extendSession to redeem refresh tokens and revoke sessions; this PR narrows refresh and revocation session writes.

Suggested labels: v3

Suggested reviewers: alessiogr

Merge Risk: Minimal · up to e8fd1

Session writes now touch only session-related columns, so concurrent changes to other user fields such as password or custom fields are no longer overwritten. No merge-blocking risk was identified. The PR title scope fails the repository's title check and should be corrected before merge.

Pre-merge checks: Passed 5

Check name Status Explanation
Title check Passed The title clearly describes the session write change and its purpose.
Description check Passed The description covers the session write changes, race tests, and verification limits.
Docstring Coverage Passed No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 0 functions across 5 files.

Finishing Touches

Generate unit tests (beta)

  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Some tools did not complete. Review the errors below.

ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

packages/payload/src/auth/operations/logout.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

packages/payload/src/auth/operations/refresh.ts

ESLint skipped: the matched ESLint configuration already failed (missing-dependency).

packages/payload/src/auth/sessions.ts

ESLint skipped: the matched ESLint configuration already failed (missing-dependency).

  • 2 others

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 6eb8e57 and e8fd158.

Files selected for processing: 5

  • packages/payload/src/auth/operations/logout.ts
  • packages/payload/src/auth/operations/refresh.ts
  • packages/payload/src/auth/sessions.ts
  • packages/payload/src/auth/strategies/local/incrementLoginAttempts.ts
  • test/auth/int.spec.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant