Key rotation
Rotate a receiver's signing secret with an overlap, and rotate storage encryption keys without changing what receivers verify.
Two different keys, two different procedures:
| Key | Rotating it changes | Receivers must |
|---|---|---|
Signing secret (whsec_…) | What the receiver verifies | Install the new secret |
| Storage encryption key | Ciphertext in management storage | Nothing |
Rotate a signing secret
const { secret } = await customer.endpoints.rotateSecret(endpointId, { graceMs: 3_600_000 })- your appRotate
rotateSecret()returns the new secret once. - workerOverlap
Every request carries signatures from both secrets.
- receiverInstall
The customer switches to the new secret.
- Overlap endsOnly the new secret signs.
During the grace period both secrets are usable: delivery signs with both, and parseWebhook() accepts either. Hand the new secret to the receiver before the period ends.
- Default and maximum
graceMsis 24 hours. - A second rotation during an active overlap fails, unless you pass
graceMs: 0, which replaces the current secret immediately and drops the old one.
Rotate the standalone storage key
The standalone PostgreSQL provider decrypts with the current key first, then any decryptionKeys. Reads never rewrite ciphertext, so rotation is explicit:
Generate and store the new key
node -e 'import("good-webhooks").then(m => console.log(m.generateEncryptionKey()))'Let every process read both keys
Deploy the new key as a decryptionKeys entry on every app and worker while encryptionKey stays the old one.
Switch the writer
Once all readers accept both keys, make the new key encryptionKey and move the old one into decryptionKeys:
const management = createPostgresManagement({
database,
eventTypes,
encryptionKey: newKey,
decryptionKeys: [oldKey],
})Pass this provider to createWebhooks({ management }) or its source to createDelivery(). The plain encryptionKey option on createWebhooks() has no decryptionKeys.
Re-encrypt each scope
const rewritten = await management.reencrypt(scope) // including null for application scopeYou need an inventory of scopes with live endpoints; the library does not enumerate owners. Re-encryption covers paused endpoints and is safe under concurrent edits. A failed run can leave a mix of old and new ciphertext. Keep both keys and run again.
Retire the old key
Only after every affected scope reports success and active endpoints resolve under the new key.
Rotate Better Auth secrets
Better Auth 1.7.7 supports versioned secrets. The first entry encrypts new values; the others decrypt values written under their version.
The first move to versioned secrets needs coordination
Ciphertext written under a single secret has no version. Keep that value in secret when you
add secrets; moving it into the array alone does not decrypt old records. The first process that
uses secrets also starts writing a versioned envelope that single-secret processes cannot read.
Pause writes, drain old processes, deploy the new configuration everywhere, then resume.
Start by preloading the new key behind the old one on every app and worker:
const auth = betterAuth({
...authOptions,
secret: oldSecret, // still required for unversioned ciphertext
secrets: [
{ version: 1, value: oldSecret },
{ version: 2, value: newSecret },
],
})When every reader accepts version 2, promote it:
const auth = betterAuth({
...authOptions,
secret: oldSecret,
secrets: [
{ version: 2, value: newSecret },
{ version: 1, value: oldSecret },
],
})Then rewrite endpoint records:
const management = await createBetterAuthManagement(auth)
await management.reencrypt(scope)This rewrites webhook endpoint records only. Other Better Auth encrypted state (TOTP secrets, OAuth tokens) follows Better Auth's own procedure. Keep secret and old versions until all of it is rewritten. Never reuse a version number for a different key. Receiver-facing signing secrets are unchanged throughout.