Good Webhooks

Security model

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

Two kinds of secrets

SecretHeld byUsed for
Signing secret (whsec_…)The receiver, and endpoint management (encrypted)Signing each request. The receiver verifies with it
Storage encryption keyYour app and worker processesEncrypting 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.

How a request is protected

  1. managementRegister

    HTTPS only, no credentials in the URL, no unsafe IP literals.

  2. workerConnect

    Resolve DNS again, reject private addresses, pin the checked address.

  3. workerSign

    HMAC-SHA256 over the ID, timestamp, and exact body. Redirects refused.

  4. receiverVerify

    Signature must match and the timestamp must be recent (five minutes by default).

Signing

Requests follow the Standard Webhooks specification:

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

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 must implement the same protection themselves.

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

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.

On this page