Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.

Rotating your PAYLOAD_SECRET

Your PAYLOAD_SECRET is used to encrypt sensitive data at rest (such as API keys), to index API keys for lookup, and to sign authentication tokens. Rotating it — after a leak, or as part of a periodic key-rotation policy — requires re-keying the data that was encrypted under the previous secret.

Payload provides the rotateSecret utility and the encrypt/decrypt/reencrypt primitives to do this safely. The previousSecrets config keeps the old secret accepted for reads during the rotation so it can be done with zero downtime.

What a rotation affects

  • JWT sessions are signed with the secret. While the old secret is listed in previousSecrets, tokens signed under it still verify, so active sessions survive the rotation. Once you remove the old secret, those tokens stop verifying and users log in again.
  • Encrypted API keys are stored as ciphertext plus an apiKeyIndex lookup value, both derived from the secret at write time. While the old secret is in previousSecrets, keys indexed under it still authenticate; rotateSecret re-keys them to the current secret.
  • Encrypted values at rest (API keys and any field you encrypt with payload.encrypt) can only be read while a secret that can decrypt them is in the keyring. A value whose secret is no longer present throws on read, so keep the old secret in previousSecrets until rotateSecret has re-keyed everything.

Procedure (zero-downtime)

  1. Add the previous secret to previousSecrets while setting the new secret as the active secret, and deploy:
1
export default buildConfig({
2
secret: process.env.PAYLOAD_SECRET, // the new secret
3
previousSecrets: [process.env.OLD_PAYLOAD_SECRET], // still accepted for reads
4
// ...
5
})

New data is written under the new secret; existing sessions, API keys, and encrypted values keep working because the old secret is still in the keyring.

  1. Create a migration that calls rotateSecret to re-key stored data:
1
payload migrate:create rotate-secret
1
import type { MigrateUpArgs } from '@payloadcms/db-mongodb'
2
3
import { rotateSecret } from 'payload'
4
5
export async function up({ payload }: MigrateUpArgs): Promise<void> {
6
const { migrated, skipped } = await rotateSecret({
7
payload,
8
oldSecret: process.env.OLD_PAYLOAD_SECRET,
9
})
10
11
payload.logger.info(
12
`rotateSecret: migrated ${migrated}, skipped ${skipped}`,
13
)
14
}
  1. Verify with a dry run first — this reads and validates every row against the old and current secrets without writing anything. If oldSecret is wrong, it throws before making any changes:
1
await rotateSecret({
2
payload,
3
oldSecret: process.env.OLD_PAYLOAD_SECRET,
4
dryRun: true,
5
})
  1. Run the migration for real:
1
payload migrate
  1. Once everything is re-keyed, remove the old secret from previousSecrets and deploy. Retiring it is what actually contains a leaked secret — do not keep old secrets in the keyring indefinitely.

rotateSecret is idempotent — running it again skips rows that are already on the current secret — and fail-closed: a row that matches neither the old nor the current secret aborts the run before that row is written. Rows migrated earlier in an aborted run remain correct, so after fixing the secret you can simply re-run.

previousSecrets

previousSecrets is an array of prior secret values that Payload still accepts for reads — verifying JWTs, matching API keys, and decrypting stored values — during a bounded rotation. New data is always written with the active secret.

Expiring a previous secret

Payload does not track an expiration for entries in previousSecrets — the array is read as-is when the config is loaded. If you want a previous secret to stop being accepted after a set window, gate it in your own config using a date you control, for example an OLD_PAYLOAD_SECRET_EXPIRATION environment variable holding the date the secret was put into rotation:

1
const oneMonth = 1000 * 60 * 60 * 24 * 30
2
3
// The date the previous secret was put into rotation, e.g. "2026-08-06".
4
const previousSecretAddedAt = process.env.OLD_PAYLOAD_SECRET_EXPIRATION
5
? new Date(process.env.OLD_PAYLOAD_SECRET_EXPIRATION).getTime()
6
: 0
7
8
// Accept the previous secret only for one month after it was added.
9
const previousSecrets =
10
process.env.OLD_PAYLOAD_SECRET &&
11
previousSecretAddedAt > Date.now() - oneMonth
12
? [process.env.OLD_PAYLOAD_SECRET]
13
: []
14
15
export default buildConfig({
16
secret: process.env.PAYLOAD_SECRET,
17
previousSecrets,
18
// ...
19
})

rotateSecret

Re-keys the built-in apiKey/apiKeyIndex fields for every auth collection configured with useAPIKey.

Option

Description

payload

The Payload instance (available as args.payload inside a migration).

oldSecret

The previous raw PAYLOAD_SECRET that existing data was encrypted under. Required.

collections

Optional array of collection slugs to limit the rotation to. Defaults to all useAPIKey collections.

batchSize

Number of documents processed per batch. Defaults to 100.

dryRun

When true, verifies every row without writing. Defaults to false.

Returns { migrated, skipped } counts.

Lower-level utilities

For data your own project encrypted with payload.encrypt (for example a custom encrypted field), re-key it with payload.reencrypt, which decrypts with the old secret and re-encrypts with the current one:

1
const next = payload.reencrypt(storedValue, {
2
oldSecret: process.env.OLD_PAYLOAD_SECRET,
3
})

payload.encrypt and payload.decrypt also accept an explicit { secret } to override the key, where secret is the raw PAYLOAD_SECRET value:

1
const raw = payload.decrypt(storedValue, {
2
secret: process.env.OLD_PAYLOAD_SECRET,
3
})
4
const next = payload.encrypt(raw)

Unlike rotateSecret — which verifies each row against the stored apiKeyIndex, so it can be re-run safely — a hand-written reencrypt loop is not idempotent, and has no equivalent way to tell an already-migrated value from a wrong oldSecret. On a v1 value already re-keyed to the active secret it throws; on a legacy AES-256-CTR value a wrong oldSecret does not throw — it decrypts to garbage and silently re-encrypts it. So don't guard by "decrypt under the current secret first" (unreliable for legacy values): track which rows you have migrated, run the loop once, and pass the correct oldSecret.

Encryption format

New encrypted values use an authenticated envelope: v1:<keyId>:<iv>:<authTag>:<ciphertext> (AES-256-GCM). The keyId is a non-secret fingerprint used to select the right key from the keyring; the auth tag means a value encrypted under an unknown or wrong key throws on decrypt rather than returning garbage. Values written by older versions of Payload (AES-256-CTR, no prefix) are still read transparently and are upgraded to the v1 envelope whenever they are re-encrypted (for example by rotateSecret).

Was this page helpful?

Next

Rich Text Editor