# Security model

> Signing secrets, the storage encryption key, destination checks, and the boundaries your application must enforce.

Source: https://good-webhooks.dev/docs/concepts/security



## Two kinds of secrets [#two-kinds-of-secrets]

| Secret                         | Held by                                           | Used for                                            |
| ------------------------------ | ------------------------------------------------- | --------------------------------------------------- |
| **Signing secret** (`whsec_…`) | The receiver, and endpoint management (encrypted) | Signing each request. The receiver verifies with it |
| **Storage encryption key**     | Your app and worker processes                     | Encrypting signing secrets in management storage    |

Standalone PostgreSQL management takes a 32-byte storage encryption key from `generateEncryptionKey()`. Better Auth management uses the auth instance's `secret` or versioned `secrets` instead.

`create()` and `rotateSecret()` return the plaintext signing secret once. Reads never include it. Give it to the receiver over a secure channel and never log it.

Rotating a signing secret changes what the receiver verifies, with an overlap. Rotating the storage encryption key re-encrypts stored values and leaves receivers alone. See [key rotation](https://good-webhooks.dev/docs/operations/key-rotation.md).

## How a request is protected [#how-a-request-is-protected]

1. **Register** (management): HTTPS only, no credentials in the URL, no unsafe IP literals.
2. **Connect** (worker): Resolve DNS again, reject private addresses, pin the checked address.
3. **Sign** (worker): HMAC-SHA256 over the ID, timestamp, and exact body. Redirects refused.
4. **Verify** (receiver): Signature must match and the timestamp must be recent (five minutes by default).

### Signing [#signing]

Requests follow the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) specification:

```http
webhook-id: 7a0c3f3e-5b2c-4f8e-9d1a-2e6b8c4d9f10
webhook-timestamp: 1760000000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj6D2hFcTjqRVo=
```

The signature is HMAC-SHA256 over `{id}.{timestamp}.{body}`. During a secret rotation the header carries one signature per usable secret, and `parseWebhook()` accepts the request if any of them matches.

### Destination checks [#destination-checks]

Standalone PostgreSQL management also resolves DNS when an endpoint is registered. Registration checks alone are not enough, because DNS can change later. That's why the worker resolves the hostname again before every connection, rejects private and otherwise unsafe addresses, and connects to the address it checked.

`allowLocalhost: true` permits plain-HTTP loopback destinations for local development. It opens no other private ranges. [Custom senders](https://good-webhooks.dev/docs/guides/custom-senders.md) must implement the same protection themselves.

## What you must enforce [#what-you-must-enforce]

* **Authorization.** Scopes keep customers apart; they don't check permissions. Check the caller before `forScope()` or any provider method. See [scopes](https://good-webhooks.dev/docs/concepts/scopes.md).
* **Trusted sources.** `management.source` returns plaintext signing secrets. Keep it in server code.
* **Receiver body limits.** Bound the request body before buffering it. `parseWebhook()` rejects bodies over 512 KiB, but only after your server has read them.
* **Receiver deduplication.** Verification proves who sent the bytes. It doesn't record which events you've already processed.
* **Delivery history.** Stored payloads and response bodies may contain customer data. Treat them like any other application data.

## Migrations [#migrations]

Constructors and `check()` never change the schema. The shipped SQL creates `webhook_`-prefixed tables in `public`, or in a schema you choose. Better Auth management uses Better Auth's own schema workflow. See [migrations](https://good-webhooks.dev/docs/operations/migrations.md).
