Good Webhooks

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:

KeyRotating it changesReceivers must
Signing secret (whsec_…)What the receiver verifiesInstall the new secret
Storage encryption keyCiphertext in management storageNothing

Rotate a signing secret

const { secret } = await customer.endpoints.rotateSecret(endpointId, { graceMs: 3_600_000 })
  1. your appRotate

    rotateSecret() returns the new secret once.

  2. workerOverlap

    Every request carries signatures from both secrets.

  3. receiverInstall

    The customer switches to the new secret.

  4. Overlap endsOnly the new secret signs.
Signing secret rotation with a one-hour overlap

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 graceMs is 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 scope

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

On this page