Signing and verification
What verify() actually checks, the v1 (HMAC) and v1a (Ed25519) signature schemes, and why constant-time comparison matters.
A signed webhook is just an HTTP request with three extra headers. The receiver's job is to reject every request that doesn't match a small, well-defined set of conditions — and to do so in a way that doesn't leak timing information.
The three headers
Every Standard Webhooks request carries:
| Header | Purpose |
|---|---|
webhook-id | Stable, unique message identifier. Used for idempotent receive (the dedup helper indexes on this) and for kid lookup in JWKS mode. |
webhook-timestamp | Unix epoch seconds, as a string. The receiver rejects timestamps outside a configurable window (default ±5 minutes). |
webhook-signature | One or more space-separated tokens of the form <version>,<base64-signature>. The receiver accepts a request if any signature in the header verifies against any of its configured secrets. |
The signed-content string is constructed from these primitives:
${webhook-id}.${webhook-timestamp}.${body}Where ${body} is the raw request body — byte-identical to what the producer sent. This is why raw-bytes preservation matters; if your framework parses and re-serializes JSON between receipt and verification, the signature will not match.
What verify checks, in order
const { event, matchedSecretIndex } = await verify(body, headers, secretOrKeyset);Under the hood:
- Header presence and shape. All three headers must exist and parse. The signature header must contain at least one token of the form
<version>,<base64>. Any failure throwsMalformedHeader. - Timestamp window. Reject if
|now - webhook-timestamp| > toleranceSeconds(default 300). ThrowsTimestampTooOld. - Key lookup (JWKS mode only). If a
Keysetis provided, extract thekidfrom the message id and look up the corresponding public key. ThrowsUnknownKeyIdif no match. - Signature comparison. For each
(secret, signature-token)pair, compute the expected signature and compare in constant time to the one in the header. Any one match succeeds; if none match, throwsSignatureInvalid. - Body parse. The verified raw bytes are parsed as JSON; the event shape is returned. (Parse failures throw
SignatureInvalid— a parse error after a successful signature check almost always indicates a body that was tampered with downstream.)
On success the return value names which secret matched (matchedSecretIndex), so callers can detect when a sender is still using a deprecated key.
The signature schemes
Postel implements two schemes. Both are encoded inline in the webhook-signature header, distinguished by their version prefix.
v1 — HMAC-SHA256 (Standard Webhooks default)
webhook-signature: v1,base64(HMAC-SHA256(signed-content, secret))Symmetric: the producer and the receiver share the same secret. Cheap to compute (~µs per verification). Default for Standard Webhooks-compliant producers.
The secret format is the Standard Webhooks convention: whsec_<base64> — a 32-byte random key, base64-encoded. The whsec_ prefix is a hint to humans and to leak-scanners; the verifier strips it before computing the HMAC.
v1a — Ed25519 (Postel extension)
webhook-signature: v1a,base64(Ed25519(signed-content, private-key))Asymmetric: the producer signs with a private key, the receiver verifies with the corresponding public key. Slightly slower (~tens of µs) but unlocks public-key distribution — receivers can verify without ever holding signing material. Pairs naturally with JWKS.
v1a is a Postel-specific extension on top of Standard Webhooks. It is byte-compatible with the v1 envelope: a request can carry both v1,... and v1a,... tokens in the same header, and the receiver accepts on first match.
Multi-secret verification
verify accepts a single secret or an array. Arrays are tried in order:
const { event, matchedSecretIndex } = await verify(
body,
headers,
[process.env.WEBHOOK_SECRET_CURRENT, process.env.WEBHOOK_SECRET_PREVIOUS],
);
if (matchedSecretIndex === 1) {
log.warn("webhook signed with deprecated secret", { id: event.type });
}This is the rotation-overlap window — see Key rotation for the full pattern.
Constant-time comparison
The signature check uses Web Crypto's crypto.subtle.timingSafeEqual (or an equivalent constant-time XOR-and-OR implementation when the platform doesn't ship it). Naive === comparison leaks information about which byte differs — an attacker observing response timing can mount a byte-by-byte chosen-plaintext attack on the signature.
The compliance suite includes a vector for this: two equal-length signatures that differ at byte 0 vs. byte 31 are required to verify in indistinguishable time.
Structured errors
verify never returns a boolean. Failures are typed:
import {
SignatureInvalid,
TimestampTooOld,
MalformedHeader,
UnknownKeyId,
RawBytesMismatchDetected,
} from "@postel/edge";
try {
const { event } = await verify(body, headers, secret);
} catch (err) {
if (err instanceof SignatureInvalid) {
// signature didn't match any configured secret
} else if (err instanceof TimestampTooOld) {
// outside the configured window — likely a replay or clock skew
} else if (err instanceof MalformedHeader) {
// a required header is missing or malformed
} else if (err instanceof UnknownKeyId) {
// JWKS mode: the kid in the request is not in the keyset
} else if (err instanceof RawBytesMismatchDetected) {
// framework adapter detected that the body was mutated before verify
}
}Each error names the failing step. The error message is safe to log — it never contains the secret or the signature bytes.
What's next
- Key rotation — the multi-secret overlap window and JWKS.
- Idempotency — at-least-once delivery and the dedup helper.
- Raw bytes — why framework integration matters.