Security model
Trust boundaries, secret storage, key management, and SSRF exposure.
The plugin's security posture is simple to state: admin access is the trust boundary. Everything below follows from it.
Operator-supplied URLs (SSRF)
Deliveries POST to whatever URL a subscription specifies, including private and internal hosts. That is the feature working as designed for trusted admins, and an SSRF vector if your admins are not fully trusted. The subscription form checks only that the URL is absolute http: or https:; it does not judge the host, because localhost and private addresses are what development and internal receivers look like. If your admins are not fully trusted:
- set
delivery.allowedHosts(below), or - restrict outbound egress from the Payload host at the network layer, or
- drop admin-managed subscriptions entirely and use only code subscriptions, whose URLs live in your repository and environment.
Allowing only named hosts
delivery.allowedHosts turns the open default into an allowlist:
webhooks({
collections: { posts: true },
delivery: { allowedHosts: ['hooks.example.com', '*.partners.example.com'] },
})An entry is an exact hostname, or *.example.com for any subdomain (not example.com itself); the port is not part of the match. Once set, a subscription for any other host is rejected on save, a code subscription for one fails at startup, and a row that already points at one is refused at delivery time and marked dead. Leave it unset in development, where the receiver is usually localhost. An entry that is not a bare hostname (a URL, a port, a path) fails at startup, and an empty list allows no host at all.
The list is checked against the URL a save sets, not the one already stored. A subscription saved before the list existed can still be renamed, rotated or switched off; what it cannot do is deliver, or be pointed at another host that is not on the list.
It is an allowlist rather than a block on private addresses on purpose. A name on the list is one you chose, whereas blocking private ranges would have to resolve DNS and pin the result to mean anything, since a public name can point at an internal address. Because deliveries do not follow redirects, an allowed host cannot hand the request on to one that is not.
Access defaults
- Both collections require a user of the admin collection (
admin.user) for every permitted operation; delivery rows additionally refuse create and update from any user (server-only writes). "Any logged-in user" would be too wide here: an app with a second auth collection (customers, members) would let each of those accounts read the delivery log, which stores the full body of every document a watched collection emitted, and register a subscription pointing wherever it liked. If your operators live in another collection, widen the rule through the overrides below. - Rotate secret runs the subscriptions collection's configured
updateaccess for the target document. Overriding that access, including scoping it per tenant, governs the endpoint as well, so rotation cannot outlive the rule that guards ordinary writes. - Redeliver runs the deliveries collection's configured
readaccess for the target delivery: whoever may read a delivery may replay it. The collection denies create and update to everyone by design, soreadis the rule there is to defer to, and an override that scopes the log per tenant scopes the replay with it. A delivery the caller cannot read answers 403 or 404, exactly as reading it would.
Tighten either through the plugin's own collection overrides, which is also the supported way to add role-based or tenant-scoped rules:
webhooks({
subscriptionsCollection: {
overrides: {
access: { update: ({ req }) => req.user?.role === 'admin' },
},
},
})Outbound requests
A delivery does not follow redirects. A 3xx would re-send the signed request, custom headers included, to an address nobody entered in the subscription, and it is how a public URL reaches an internal host. The redirect is recorded as the failed delivery it is; enter the final URL instead. Only the first 2,000 characters of a response are read, so a receiver cannot make the server buffer an arbitrarily large body.
Cross-site requests
Both endpoints authenticate the way every other Payload route does, so Payload's own CSRF handling applies before req.user is set: a cookie-authenticated request is accepted only if its Origin is in config.csrf, or, when no Origin is sent, if Sec-Fetch-Site says it is not cross-site.
That allowlist is empty by default, which means any origin is accepted for cookie auth. Set it in your Payload config, and rotation and redelivery are covered along with every ordinary write:
export default buildConfig({
csrf: ['https://admin.example.com'],
})Secrets at rest
Signing secrets are stored through encryptedField from @10x-media/fields, at protection: 'writeOnly'. The database holds an AES-256-GCM sealed value carrying an authentication tag and a key id, under a key derived from your PAYLOAD_SECRET or from a key ring you configure. A dump, a backup, or a read replica contains sealed values rather than usable signing keys.
@10x-media/fields is a peer dependency. The admin components are import-map paths into @10x-media/fields/client and /rsc, and under pnpm your app cannot import a transitive dependency, so install it alongside this plugin: pnpm add @10x-media/fields.
Write-only means the field is stripped from every read result: admin, REST, GraphQL, exports, and the create response too. A read carries secret_set (a boolean saying whether one is stored) and secret_hint, and nothing else.
The hint is the last six characters of the secret, stored beside it at seal time. Six is deliberately fewer than the field's default: on a signing secret every character is key material rather than an identifier, so the hint exposes the minimum that still lets an operator tell two keys apart in the admin. It is not stripped from responses, so treat it as public.
The plaintext leaves the server on exactly three paths:
- the create response, under
generatedSecret, revealing a generated secret once - the rotation response, revealing the new secret once
- internal delivery signing, which unseals in memory to compute the HMAC
What this does and does not protect
Encryption at rest defends against read exposure of the database: dumps, backups, replicas, a stolen disk.
It is not a defence against an attacker who can write to your database. Such an attacker can already point a subscription at their own URL and receive your events. If your database is writable by an untrusted party, the webhook secrets are not the first thing to worry about.
What the sealed form adds is binding and detection. The ciphertext is bound by AAD to 10x-webhooks:subscriptions.secret, so a value cannot be moved to a different field, and the GCM tag means a deliberate edit fails authentication rather than decrypting to plausible garbage. Copying one subscription's sealed secret into another row still works, because both rows share that binding: the AAD is per field, not per document.
That scope is pinned rather than derived from the collection slug, because the slug is a plugin option. Renaming the collection would otherwise make every stored secret fail authentication, with nothing able to recover them. It must never change once secrets exist.
Custom header values
A subscription's custom header values are encrypted at rest with the same key ring, because that is where a receiver's own credential goes. Unlike the signing secret they are masked, not write-only: the admin shows dots with a reveal toggle, and a read through REST or GraphQL returns the value to whoever may read the subscription. Plenty of header values are not secrets, and an operator needs to read them back.
A value stored before this release reads back and delivers as it is, and is sealed the next time the subscription is saved or by the adoption utility, which counts those rows as headersSealed. A sealed value that can no longer be decrypted (its key left the ring) refuses the delivery, like an unrecoverable secret does: put the key back, or enter the value again.
A secret that cannot be recovered refuses the delivery
The row is marked dead, an error is logged naming the subscription, and nothing is sent. The error says which of these happened, because each has a different fix:
| What the log says | What happened | What to do |
|---|---|---|
no configured key authenticates it | PAYLOAD_SECRET changed, or a key was dropped from the ring | Pin the previous key in secretEncryption.keys, or rotate the secret |
key id that is not in secretEncryption.keys | The value was sealed under a key that is no longer configured | Put that key back in the ring, or rotate the secret |
the stored value is not encrypted | The row predates encryption at rest. This is decided by the seal, so a plaintext value is refused even when it is already a well-formed whsec_ secret | Run encryptExistingSecrets, or rotate the secret |
not a canonical 'whsec_' secret | A sealed value that was not written through the plugin | Rotate the secret |
A subscription that is supposed to be signed is never downgraded to an unsigned delivery. Receivers commonly verify only when a signature header is present, so an unsigned POST would sail through that check. A refused delivery is a visible failure you can act on; a silently unsigned one is not.
That refusal is about the current secret. If only a retired secret inside an open rotation window is unreadable, the delivery still goes out signed with the current secret and a warning names what was dropped: a correctly signed delivery beats no delivery, and the only loss is the overlap for receivers that have not switched over yet.
Subscriptions with no secret at all are unaffected and continue to deliver unsigned, which is what they always did.
The encryption key
With no configuration the encryption key derives from PAYLOAD_SECRET, which means changing PAYLOAD_SECRET makes every stored webhook secret unreadable. Configure the key ring explicitly and it stops depending on it:
webhooks({
secretEncryption: {
keys: {
active: 'k1',
keys: { k1: process.env.WEBHOOKS_ENCRYPTION_KEY! },
},
},
})Every configured key can decrypt; the active one is used to seal. The key id travels inside the stored value, so a row sealed under k1 keeps opening after k2 becomes active.
Rotating the encryption key
This is not the same thing as rotating a signing secret. It re-seals what is already stored and changes nothing a receiver can observe.
-
Add the new key to the ring and mark it active, keeping the old one:
keys: { active: 'k2', keys: { k1: OLD, k2: NEW } } -
Re-seal every stored value under the new key:
import { rotateEncryptedFields } from '@10x-media/fields/encrypted' await rotateEncryptedFields(payload, { collections: ['webhook-subscriptions'] }) -
Re-save every subscription that has custom headers, from the admin or with a
payload.updatethat resubmits itsheaders.rotateEncryptedFieldsdoes not reach fields inside an array, so header values stay sealed underk1until a save re-seals them. -
Drop
k1from the ring. A header value still sealed under it can no longer be decrypted, and that subscription's deliveries are refused until the value is entered again.
Changing PAYLOAD_SECRET
If you never configured secretEncryption.keys, pin your current key before changing PAYLOAD_SECRET:
keys: { active: 'k0', keys: { k0: process.env.PAYLOAD_SECRET! } }Deploy that, then change PAYLOAD_SECRET. Stored secrets keep opening under k0, and you can move to fresh key material with the rotation above whenever you like.
If PAYLOAD_SECRET has already changed and the old key was not pinned, the stored secrets are gone: rotate each subscription and distribute the new secrets to receivers. Set graceSeconds: 0 for those, since the old secret is already unusable and there is nothing to overlap with.
Migrating subscriptions created before encryption
Subscriptions created before this release hold plaintext secrets, and their deliveries fail until you migrate or rotate them, because an unsealed secret is refused rather than sent unsigned.
Step zero: the schema migration
This release adds columns to the subscriptions collection: previous_secret, previous_secret_expires_at, and secret_hint, and one to the deliveries collection: subscription_source. (secret_set is virtual, computed on read from whether the sealed value is present, so it has no column.) On MongoDB that is free and there is nothing to do. On any SQL adapter (Postgres, SQLite, Vercel Postgres) the migration has to run first, or the adoption utility below writes into columns that do not exist yet.
pnpm payload migrate:create add-webhook-secret-columns
pnpm payload migrateThen the adoption utility
Run it once, ideally after a dry run:
import { encryptExistingSecrets } from '@10x-media/webhooks'
const report = await encryptExistingSecrets(payload, { dryRun: true })
// { scanned, migrated, alreadyEncrypted, noSecret, headersSealed, failed: [{ id, field, reason }] }Drop dryRun to write. It is idempotent, so re-running is safe.
The utility does not invent new secrets: a legacy 48-character hex secret is already valid base64, so it keeps its characters and gains a whsec_ prefix. Anything it cannot normalize is reported by row id and field name; rotate those subscriptions. A row's other secret field still migrates, so one unusable value does not strand a recoverable one in plaintext, which is why a row can appear in both migrated and failed. Custom header values stored before header encryption are sealed in the same run and counted as headersSealed; a header name that has since become reserved does not hold a row back, since the run is not changing it. A row that will not save for some other reason is reported in failed with its id, and the run carries on with the rest.
Receivers must be updated regardless of this migration. The signature format, the header names, and the key derivation all changed, so an unchanged receiver will reject every delivery. See Signing.
Receiver-side checklist
- Prefer an off-the-shelf Standard Webhooks library over hand-rolled verification.
- Verify against the raw body, before any JSON parsing.
- Enforce a timestamp tolerance (300 seconds is the usual choice).
- Accept any of the signatures in the header, so rotation does not break you.
- Compare in constant time, never with
===. - Treat
dataas untrusted input; it is whatever the document (or yourtransform) contained. - Respond quickly and process async. The sender aborts at
timeoutMs(default 10s) and a slow receiver burns retry attempts.