Rotating BETTER_AUTH_SECRET

How to change the deployment secret without losing the credentials encrypted under it — the rotation window, the re-wrap sweep, and what cannot be recovered.

#Rotating BETTER_AUTH_SECRET

BETTER_AUTH_SECRET does two jobs. It signs sessions — which is why the project's docs/known-issues.md tells you to rotate it after any incident where a flow author could have read the worker's environment — and it derives the AES key that encrypts every credential this instance stores on your behalf:

What Where
Storage credentials (S3/R2) storage_connections.secret_key
Workspace OAuth apps tenant_oauth_apps.client_secret
Migration source configs migrations.source_config

Those two jobs used to conflict. Rotating the secret made every one of those values undecryptable at the same instant, across every workspace, with no way back except a human re-entering each credential — so the advice to rotate was advice to break your own storage.

It no longer is, provided you rotate in this order.

#The rotation

1. Check what you have. As a super-admin:

curl -s -H "Authorization: Bearer $TOKEN" https://your-api/api/v1/system/secrets

This decrypts nothing. It reads the key id stamped on the front of each stored value and counts them, so you can size the job before touching a credential.

2. Set the OLD secret as BETTER_AUTH_SECRET_PREVIOUS, then set the new one. Both must be present together — this is the window in which either secret opens a stored value.

npx wrangler secret put BETTER_AUTH_SECRET_PREVIOUS   # paste the CURRENT value
npx wrangler secret put BETTER_AUTH_SECRET            # paste the NEW value

Do it in that order. Setting the new secret first leaves a gap in which nothing opens.

3. Re-wrap.

curl -s -X POST -H "Authorization: Bearer $TOKEN" https://your-api/api/v1/system/secrets/rewrap

Idempotent, and capped per table per call — if the response reports remaining above zero, call it again. Values already carrying the current key id are counted and skipped without being decrypted.

4. Unset the previous secret — but only when the report says so.

npx wrangler secret delete BETTER_AUTH_SECRET_PREVIOUS

safeToRetirePrevious is true only when nothing is left stale and every table could be read. Until then the old secret is still load-bearing, and deleting it is the irreversible step.

#What the report tells you

  • unopenable — rows no configured secret can open. Usually a rotation that happened before this mechanism existed. The credential still exists in whichever service issued it; the fix is re-entering it in the UI. These are reported and never deleted, because only you know which connection is which.
  • skipped — a table that could not be read, normally a migration this instance has not applied yet. It blocks safeToRetirePrevious on purpose: a column nobody could read has not been proven safe.
  • superseded — rows somebody rewrote while the sweep was running. The sweep updates a row only if it still holds the value it read, so a credential someone rotated mid-sweep is left alone rather than overwritten with the older one. Whoever wrote it already sealed it under the current secret, so these resolve themselves; run the sweep again and they come back as current.
  • unrewrappable — the list below, returned on every report including a clean one. Nothing about it is conditional on the rest of the report.
  • Key ids are short fingerprints, deliberately derived so they are not computable from the encryption key. They are safe to paste into a ticket.

#What rotation still costs

Read this section before rotating, not after. safeToRetirePrevious: true means "no sealed column still needs the old secret" — it does not mean the rotation was free. The sweep can only speak for the columns it owns, and the items below are outside them. The API returns them as unrewrappable on every report, for exactly this reason: the cleanest-looking report is the one most likely to be misread as a finished job.

Not-yet-migrated legacy passwords. Imported credentials are wrapped with an HMAC keyed off BETTER_AUTH_SECRET (see docs/legacy-password-migration.md in the project). An HMAC has no plaintext to recover, so there is nothing for a sweep to re-seal. Affected users get a generic invalid-credentials error and must reset their password. Rotate before an import, or after the migration window closes.

Live sessions. The secret signs them. Everyone signs in again.

#Two-factor authentication

Covered by the sweep, and worth knowing why it is called out separately.

TOTP secrets and backup codes are encrypted by Better Auth under its own key schedule rather than the one the sealed columns use. Until recently this deployment passed it a single key, and a single key has no fallback: every enrolment and every backup code stopped being readable the instant the secret changed — before any sweep could run, with no warning, and with the escape hatch (the backup codes) sealed under the same key as the thing it exists to recover from.

Both halves are fixed. Better Auth is given the current secret and the previous one, so a rotation window covers 2FA the same way it covers everything else; and the sweep re-wraps twoFactor.secret and twoFactor.backupCodes onto the current key, so the window can actually be closed.

What this means in practice: follow the four steps above and 2FA needs no special handling. The one thing not to do is skip step 3 — retiring the previous secret without running the sweep still costs every enrolled user their second factor, and now that the sweep covers these columns, safeToRetirePrevious will tell you so before you do it.

#If you have already rotated and lost access

Set BETTER_AUTH_SECRET_PREVIOUS to the old value and run the sweep — this works retroactively, and it is why the old value is worth keeping in your password manager rather than discarding at rotation time.

If the old value is genuinely gone, the affected credentials must be re-entered. Storage is the good case: for an R2 bucket on your own Cloudflare account, the data is not lost — a bucket is reachable from any valid credential on the account, so you mint a new access key and re-enter it. For an external S3, the key must be re-issued by whoever owns that bucket.