payloadcms / payloadcms/payload
apiKey corrupted by routine update from instance with different PAYLOAD_SECRET
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 44.8k
- Forks
- 4.2k
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 53
Description
Describe the Bug
Two Payload instances sharing one MongoDB but holding different PAYLOAD_SECRET values silently corrupt users.apiKey and users.apiKeyIndex on any payload.update to a user document — even when the data patch does not include apiKey.
The corruption chain:
afterRead.decryptKey(packages/payload/src/auth/baseFields/apiKey.ts) decrypts the stored ciphertext under the current instance's normalized secret. Wrong secret → silent garbage (auth/crypto.tsuses AES-256-CTR, which has no MAC).- The merged data passed to field hooks contains
data.apiKey = <garbage>. apiKeyIndex.beforeValidateseesdata.apiKeytruthy →HMAC(garbage, current_secret)overwrites the storedapiKeyIndex.apiKey.beforeChange.encryptKeyreceivesvalue = <garbage>→ re-encrypts under the current secret → overwrites the stored ciphertext.
Net effect: the original plaintext API key is permanently invalidated. There is no error, no log line, and no warning. A user's API key works until the day a different-secret instance happens to touch their record.
Link to the code that reproduces this issue
https://github.com/ShaunMWallace/payload-secret-repro
Reproduction Steps
git clone https://github.com/ShaunMWallace/payload-secret-repro
cd payload-secret-repro
pnpm install
pnpm all
pnpm all runs four steps in sequence (each in its own Node process):
step1:setup— boots Payload withSECRET_A, creates a user, generates an API keyK1(encrypted under SECRET_A), persists{userId, K1}for downstream steps.step2:auth-on-a-baseline— boots Payload withSECRET_A, looks up the user viaapiKeyIndex = HMAC(K1, A). Expected: lookup succeeds.step3:corrupt-via-b— boots Payload withSECRET_B(same MongoDB, different secret), runs:await payloadB.update({ collection: 'users', id: state.userId, data: { name: 'edited-on-b' }, // NOTE: apiKey is intentionally NOT in data });step4:auth-on-a-after— boots Payload withSECRET_A, looks up the user viaapiKeyIndex = HMAC(K1, A). Expected: lookup fails.
Sample output (sanitized):
[step1] user id : 69f3575a69710e191b9a2d8f
[step1] plaintext key: 6614fc54-8c26-4903-ab56-6ca7842a4d8c
[step2] auth lookup : OK — found user 69f3575a69710e191b9a2d8f
[step3] cipher BEFORE B : 7ed2b5a48d65cace5aa35921d7dd294a849907b2665c0536…
[step3] cipher AFTER B : 087f6229f337237c2182ca3e58f37c1995e7771de298389f…
[step3] cipher changed : YES (B re-encrypted under SECRET_B)
[step4] auth lookup : FAILED (expected) — apiKeyIndex no longer matches; original apiKey is now unrecoverable
The repository's README walks through the chain in detail and includes a supplementary script (pnpm investigate) that contrasts safe same-secret re-saves (cipher rotates harmlessly under same secret, index unchanged) vs unsafe different-secret re-saves (both fields clobbered), ruling out simple IV rotation as the underlying cause.
Which area(s) are affected?
- area: core
- db: mongodb
Environment Info
payload: 3.84.1
@payloadcms/db-mongodb: 3.84.1
mongoose: 9.6.1
node: 22
mongodb: 7.x
OS: darwin (arm64)
Real-world impact
We hit this in a Next.js + Payload + Vercel production application after a forced PAYLOAD_SECRET rotation. Vercel captures env at build time, so long-lived branch deployments continued serving traffic with the previous PAYLOAD_SECRET baked in. Routine editorial saves on user records from those stale-secret deployments corrupted production API keys at random — with no detectable signal on either side.
The repro deliberately removes Vercel and Next.js from the picture (two local Node processes against one local MongoDB) to prove the bug lives entirely in @payloadcms/payload.
Suggested fixes (in order of preference)
- Detect wrong-secret decryption. Switch
auth/crypto.tsfrom AES-256-CTR to an authenticated mode (AES-256-GCM, or HMAC-over-CTR) soreq.payload.decryptthrows on a wrong secret instead of returning garbage. Single biggest impact — would have surfaced our incident immediately rather than over weeks. - Skip the apiKey round-trip on updates that don't include it. If
data.apiKeyis absent from the user-supplied patch, leave the stored ciphertext +apiKeyIndexuntouched. Makes routine updates safe across instances regardless of secret state. - Document the single-secret-per-database invariant. The current docs do not state that
PAYLOAD_SECRETis a database-level invariant. Optionally surface a startup warning that samples a few existing apiKey ciphertexts and verifies they decrypt cleanly under the current secret. - Support graceful rotation. Allow
PAYLOAD_SECRETto be a list ordered newest-first; decrypt against any, encrypt only against the first. Permits a phased rollout across multi-instance topologies without the all-or-nothing cliff.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.