2 min read
Rotating SETTINGS_SECRET
SETTINGS_SECRET encrypts (AES-256-GCM, packages/core/src/secrets.ts) every store’s:
- provider credentials: payments, shipping, notifications, storage and shopper sign-in apps
(
store.payments,.shipping,.notifications,.storage,.login_providers); - gift card codes (
gift_card.code_cipher); - Shopify importer tokens (
shopify_import_connection.access_token); - customer phone numbers (
customer.phone), whose search indexcustomer.phone_indexis an HMAC keyed by the same secret.
Rotate it when it may have leaked (a laptop lost, a log that printed the environment, a departing admin), and as routine once a year.
How rotation works
- Key ids. Every ciphertext written today is
v2.<keyId>.<iv>.<tag>.<ct>, wherekeyIdis the first 8 hex characters ofsha256(key). Decryption picks the matching key straight away. Oldv1.<iv>.<tag>.<ct>ciphertexts (before key ids) have no id; they are tried against every key and keep decrypting. - Keyring.
SETTINGS_SECRETis the current key: every new write uses it.SETTINGS_SECRET_PREVIOUSis an optional comma-separated list of retired keys that may still decrypt.decryptSecretreads it from the environment itself, so every read path (checkout, webhooks, the worker, the storefront) keeps working during a rotation. - Lazy re-encryption. Whenever a settings blob is saved (a merchant edits Settings → Payments, for example), every secret in it that is still under a retired key is re-encrypted under the current one, untouched secrets included.
- One-off re-encryption.
bun run rotate-secretsmoves everything else in one pass, one transaction per store (rotateStoreSecretsinpackages/core/src/secret-rotation.ts). It also recomputescustomer.phone_indexunder the new key.
Runbook
Generate a new key
openssl rand -hex 32Keep it in the deployment’s secret store (Dokploy environment), never in the repo.
Deploy with both keys
Set SETTINGS_SECRET_PREVIOUS to the current value and SETTINGS_SECRET to the new one,
then redeploy the admin, storefront and worker. Both keys decrypt; only the new one encrypts.
If an earlier rotation is still in flight, list both retired keys, comma-separated.
See what will move
From the tasks image (the same docker run pattern as
Deployment, Restore):
bun run rotate-secretsThe dry run prints, per store, how many settings secrets, gift card codes, Shopify tokens and customer phones are still under an old key, and how many values no key opens (normally zero).
Re-encrypt
bun run rotate-secrets --applyRun it soon after the deploy: until it runs, phone search and phone sign-in cannot find a
customer whose phone index is still under the old key (the index is keyed by the secret).
Everything else works throughout. The command is idempotent: a second run reports zero.
It exits with status 1 if any value could not be decrypted with any key; fix
SETTINGS_SECRET_PREVIOUS and run it again.
Retire the old key
When a dry run reports zero values left to move, remove SETTINGS_SECRET_PREVIOUS and
redeploy. Anything still under the retired key would now fail to decrypt, so do not skip the
previous step.
Plaintext phones from before encryption
bun run backfill-phone-encryption is a different job: it encrypts customer.phone values
that are still plaintext (rows from before phone encryption existed). Run it once after
the migration that added phone_index; rotate-secrets handles phones that are already
encrypted.
Adding a new encrypted column
Use encryptSecret / decryptSecret. If the column lives in core, add it to
CORE_CIPHER_COLUMNS in packages/core/src/secret-rotation.ts; if a plugin owns the table,
add it to PLUGIN_COLUMNS in scripts/rotate-secrets.ts. Settings blobs that go through
encryptProviderSettings are covered already.