Skip to content

Rotating the server secret

View as Markdown

SECRET encrypts three kinds of stored credentials (AES-256-GCM): webhook signing secrets, design-provider credentials and user connection tokens, and notification-channel secrets (chat webhook URLs and git tokens). Changing it without a plan leaves those rows unreadable, so StoryShelf keeps the old value as a decrypt-only fallback while you migrate.

  1. Set the new value and keep the old one:

    Terminal window
    SECRET=<new value>
    SECRET_PREVIOUS=<old value>

    The scaffolded src/index.ts, dev-server and fly-app read both. In code this is ShelfConfig.secret and ShelfConfig.previousSecret (a single value, not a list).

  2. Restart. The server decrypts with SECRET first and falls back to SECRET_PREVIOUS, so webhooks, git statuses and notifications keep working.

  3. Re-encrypt the stored credentials under the new secret, in either way:

    • Automatically at boot: also set SECRET_MIGRATE=true (ShelfConfig.migrateCredentialsOnBoot). It is off by default.
    • From the UI: a site admin opens System and presses Re-encrypt with current secret. This is available whether or not auto-migration is on. The same action is POST /api/v1/admin/credentials/reencrypt.
  4. Check the Stored credentials card on the System page: every credential should be under the current secret and none unreadable. Then remove SECRET_PREVIOUS (and SECRET_MIGRATE).

A credential that neither secret can decrypt is never silent:

  • The server logs an error at boot with the table and row id (never the value).
  • The System page shows the Unreadable count with an alert.
  • POST /api/v1/health reports a failed credentials entry, so the overall status is degraded.
  • A channel whose secret cannot be decrypted (including a git channel’s token) is skipped at delivery, with a logged warning.

Fix it by setting SECRET_PREVIOUS to the secret that row was encrypted with, or recreate the webhook, token or channel.

SECRET also signs login sessions and CSRF tokens. Rotating it signs everyone out once; sessions are not carried over by SECRET_PREVIOUS.