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 blockssafeToRetirePreviouson 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.