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
apiKeyIndexlookup value, both derived from the secret at write time. While the old secret is inpreviousSecrets, keys indexed under it still authenticate;rotateSecretre-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 inpreviousSecretsuntilrotateSecrethas re-keyed everything.
Procedure (zero-downtime)
- Add the previous secret to
previousSecretswhile setting the new secret as the activesecret, and deploy:
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.
- Create a migration that calls
rotateSecretto re-key stored data:
- Verify with a dry run first — this reads and validates every row against the old and current secrets without writing anything. If
oldSecretis wrong, it throws before making any changes:
- Run the migration for real:
- Once everything is re-keyed, remove the old secret from
previousSecretsand 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:
rotateSecret
Re-keys the built-in apiKey/apiKeyIndex fields for every auth collection configured with useAPIKey.
Option | Description |
|---|---|
| The Payload instance (available as |
| The previous raw |
| Optional array of collection slugs to limit the rotation to. Defaults to all |
| Number of documents processed per batch. Defaults to |
| When |
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:
payload.encrypt and payload.decrypt also accept an explicit { secret } to override the key, where secret is the raw PAYLOAD_SECRET value:
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?