Email Webhook Security: Verify Signatures and Stop Replays
An email webhook is safe to act on only after you verify the exact request bytes, reject stale signatures, and claim a stable delivery ID before applying a side effect. A valid signature answers who could have produced the request and whether its signed content changed. It does not make retries disappear.
That distinction matters for delivery, bounce, and complaint events. Providers retry when your endpoint times out or returns an error. An attacker may also replay a captured request. Both can produce a second request with a valid signature.
Start with the actual artifact. This hypothetical contract signs the timestamp, delivery ID, and raw body with HMAC-SHA256:
POST /webhooks/email-events HTTP/1.1
Content-Type: application/json
X-Event-Id: evt_01K5P7R0A9
X-Webhook-Timestamp: 1800000000
X-Webhook-Signature: v1=<64 hexadecimal characters>
{"id":"evt_01K5P7R0A9","type":"hard_bounce","recipient":"person@example.com"}
The header names and signature format above are illustrative. A real provider defines its own signing contract. Implement that contract exactly, including its byte encoding and covered fields. Do not translate one provider's verifier into another provider's endpoint.
The signature must bind every trusted value
RFC 2104 defines HMAC as message authentication using a cryptographic hash function and a shared secret. The useful word is message. The verifier and signer must authenticate the same bytes in the same order.
For this contract, the signature base is:
timestamp + "." + delivery_id + "." + raw_request_body
Including the delivery ID binds the deduplication key to the signed body. Including the timestamp lets the receiver reject a captured request after a short window. The body carries the event type and recipient, so changing either value breaks the signature.
RFC 9421's security guidance makes the broader rule explicit: unsigned message components can be modified without invalidating a signature. If your provider uses HTTP Message Signatures, verify its required covered components, key, algorithm, and time boundaries instead of inventing a reduced subset.
Verify the raw bytes before parsing JSON
A JSON parser turns bytes into values. Re-serializing those values may change whitespace, escaping, key order, or Unicode representation. A signature created over the original request body will not necessarily match the reconstructed string.
Keep the body as a Buffer until the signature passes. Only then decode and parse it. This dependency-free Node.js verifier implements the hypothetical contract:
import { createHmac, timingSafeEqual } from 'node:crypto'
const MAX_AGE_SECONDS = 300
export function signWebhook({ body, deliveryId, secret, timestamp }) {
const prefix = Buffer.from(`${timestamp}.${deliveryId}.`, 'utf8')
return createHmac('sha256', secret)
.update(Buffer.concat([prefix, body]))
.digest('hex')
}
export function verifyWebhook({
body,
deliveryId,
secret,
signature,
timestamp,
now = Math.floor(Date.now() / 1000)
}) {
if (!Buffer.isBuffer(body)) throw new TypeError('body must be a Buffer')
if (!deliveryId || !secret) throw new Error('missing webhook configuration')
const sentAt = Number(timestamp)
if (!Number.isSafeInteger(sentAt)) throw new Error('invalid timestamp')
if (Math.abs(now - sentAt) > MAX_AGE_SECONDS) {
throw new Error('stale webhook')
}
const [version, hex, extra] = String(signature || '').split('=')
if (version !== 'v1' || extra || !/^[a-f0-9]{64}$/i.test(hex || '')) {
throw new Error('invalid signature')
}
const expected = Buffer.from(
signWebhook({ body, deliveryId, secret, timestamp: sentAt }),
'hex'
)
const supplied = Buffer.from(hex, 'hex')
if (
supplied.length !== expected.length ||
!timingSafeEqual(supplied, expected)
) {
throw new Error('invalid signature')
}
const event = JSON.parse(body.toString('utf8'))
if (event.id !== deliveryId) throw new Error('delivery id mismatch')
if (!['delivered', 'soft_bounce', 'hard_bounce', 'complaint'].includes(event.type)) {
throw new Error('unsupported event type')
}
return event
}
Node's createHmac() computes the authentication tag. timingSafeEqual() compares equal-length byte sequences using a constant-time algorithm. Check the decoded length first because Node throws when the lengths differ.
Freshness is not deduplication
The five-minute window limits how long a captured signature remains useful. It does not stop the same request from arriving twice inside those five minutes.
The delivery ID solves the second problem. After verification, insert the event into a durable inbox where delivery_id is unique:
CREATE TABLE email_webhook_inbox (
delivery_id text PRIMARY KEY,
event_type text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
INSERT INTO email_webhook_inbox (delivery_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (delivery_id) DO NOTHING;
A retry with the same ID becomes a successful no-op. The endpoint can return a 2xx response whether the insert created the row or found it already present. A worker then processes unhandled inbox rows outside the request path.
An in-memory Set is useful in a unit test, but it is not a production replay store. It disappears on restart and cannot coordinate multiple application instances. The uniqueness boundary belongs in the shared durable system that accepts the event.
The same rule applies even when a provider documents redelivery as normal behavior. GitHub's official webhook guidance, for example, recommends a unique delivery header and notes that a requested redelivery keeps the original delivery ID. That is the shape your inbox table should expect: at-least-once delivery with a stable deduplication key.
Process business state after admission
Signature verification should not directly send email, delete a contact, or overwrite a customer record. It should admit an authenticated event into your processing system. The worker still needs business rules.
A delivered event records one accepted delivery. A soft_bounce can support a bounded retry. A hard_bounce can suppress the affected address after you verify the failure evidence. A complaint should create a durable suppression that a later delivery event cannot erase.
The hard-bounce versus soft-bounce guide shows how to read delivery evidence before changing state. The suppression-list model explains why complaint and unsubscribe events should survive later verification or delivery signals.
This is also the boundary between pre-send and post-send evidence. An email verification result helps decide whether to attempt signup or delivery. A webhook reports what a sending system observed afterward. The API result-handling guide keeps those application states separate.
Prove failure paths, not only a valid signature
Positive tests are not enough. A receiver that never checks the signature will still pass a valid-signature fixture. RFC 9421 specifically recommends testing invalid signatures so skipped verification does not hide behind the happy path.
I ran the verifier against five cases:
✔ accepts an authentic fresh event
✔ rejects a body changed after signing
✔ rejects an event outside the five-minute window
✔ binds the signed body to the delivery id
✔ stores one copy when the same delivery is retried
tests 5
pass 5
fail 0
Add provider-specific fixtures for missing headers, wrong keys, key rotation, malformed encodings, oversized bodies, unsupported event types, and concurrent duplicate requests. Also test that your framework exposes the original bytes. Route-level raw-body handling is safer than turning off JSON parsing for the entire application.
HTTPS and signatures protect different boundaries
No. HTTPS protects the connection between TLS endpoints. A webhook signature authenticates the covered request data under the provider's signing key or shared secret. You need both.
There is a fair objection here: if the provider already retries, why build your own inbox? Because a retry is part of the delivery contract, not an error the provider promises to prevent. Your endpoint owns the side effect. It must decide whether this event has already crossed that boundary.
The complete request path is short: keep the raw bytes, verify the signature, reject stale timestamps, insert the unique delivery ID, return 2xx, and process the inbox asynchronously. Each step answers a different question. Combining them into one “webhook verified” Boolean is how duplicate complaints, repeated suppression work, and forged events reach production.