# Key rotation

> Rotate a receiver's signing secret with an overlap, and rotate storage encryption keys without changing what receivers verify.

Source: https://good-webhooks.dev/docs/operations/key-rotation



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 [#rotate-a-signing-secret]

**Combined API**

```ts
const { secret } = await customer.endpoints.rotateSecret(endpointId, { graceMs: 3_600_000 })
```

**Management provider**

```ts
const { secret } = await management.rotateSecret(scope, endpointId, { graceMs: 3_600_000 })
```

**Better Auth client**

```ts
const { data } = await authClient.goodWebhooks.rotateSecret({ id: endpointId, graceMs: 3_600_000 })
```

Signing secret rotation with a one-hour overlap:

1. **Rotate** (your app): `rotateSecret()` returns the new secret once.
2. **Overlap** (worker): Every request carries signatures from both secrets.
3. **Install** (receiver): The customer switches to the new secret.
4. **Overlap ends**: Only 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 `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 [#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 [#generate-and-store-the-new-key]

```sh
node -e 'import("good-webhooks").then(m => console.log(m.generateEncryptionKey()))'
```

### Let every process read both keys [#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 [#switch-the-writer]

Once all readers accept both keys, make the new key `encryptionKey` and move the old one into `decryptionKeys`:

```ts
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 [#re-encrypt-each-scope]

```ts
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 [#retire-the-old-key]

Only after every affected scope reports success and active endpoints resolve under the new key.

## Rotate Better Auth secrets [#rotate-better-auth-secrets]

Better Auth 1.7.7 supports [versioned secrets](https://better-auth.com/docs/reference/options#secrets). The first entry encrypts new values; the others decrypt values written under their version.

> **Warning: 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:

```ts
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:

```ts
const auth = betterAuth({
  ...authOptions,
  secret: oldSecret,
  secrets: [
    { version: 2, value: newSecret },
    { version: 1, value: oldSecret },
  ],
})
```

Then rewrite endpoint records:

```ts
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.
