Security model
Signing secrets, the storage encryption key, destination checks, and the boundaries your application must enforce.
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.
How a request is protected
- managementRegister
HTTPS only, no credentials in the URL, no unsafe IP literals.
- workerConnect
Resolve DNS again, reject private addresses, pin the checked address.
- workerSign
HMAC-SHA256 over the ID, timestamp, and exact body. Redirects refused.
- 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.sourcereturns 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.