DEV Community

RemielBarrett8283
RemielBarrett8283

Posted on

Webhook Signature Verification: How to Preserve Raw Body Before 2 Parsing Stages

Keep two signing keys active for a bounded overlap, select the candidate key by an authenticated key identifier, and verify the signature against the exact request bytes before any JSON parser runs. For an e-commerce account platform, this is the defensible way to rotate a production webhook credential without choosing between an uncontrolled overlap bill and refused order traffic.

TL;DR: mount the webhook route's raw-body reader before the application-wide JSON middleware; cap the body size; authenticate timestamp, body, and key identifier as one message; claim the event ID in a durable idempotency store; then parse JSON. During rotation, accept current and next, but emit with only one key at a time. End the overlap only after delivery telemetry shows the old key is no longer in use and the documented maximum retry window has elapsed.

This is an architecture decision record, not a recipe for a particular webhook provider. The example contract uses HMAC-SHA-256 because its verification properties are specified publicly, while the operational method applies equally to an asymmetric scheme whose public keys have stable identifiers.

How should webhook signature verification use the raw body before parsing?

The signed object is a byte sequence, not a JavaScript value. If a sender signs {"order_id":"ord_1042","total":4900} and middleware first turns it into an object, reserialization may change whitespace, escaping, numeric representation, or member order. The object can remain semantically equivalent while its bytes differ, and the message authentication code must then differ too. Parsing first therefore converts a deterministic security check into an accidental canonicalization protocol that neither side agreed to implement.

In Express, express.json() consumes the request stream and assigns a parsed value to req.body. A webhook endpoint that needs the original bytes must instead use a raw parser for that route, and that route must be registered before any broad JSON parser that would match it. The signature check reads the resulting buffer. Only successful requests proceed to JSON decoding. Express also exposes a verify parser hook that can retain the bytes, but retaining a second copy for every ordinary JSON route widens memory use and the accidental-data-retention surface; a narrowly mounted raw route makes the boundary easier to audit.

Order matters.

The same rule holds outside Node.js. Framework names change, but a one-shot body stream, a decoder, and an authentication boundary still appear in that order. A useful test oracle sends two bodies that decode to the same object but have different whitespace. Each must verify only against its own signature.

Fail closed.

Decision record: invariants and failure boundaries

The decision is to operate a two-key receive window with a single-key send path. The receiver recognizes a non-secret key ID, looks up only that key, and performs one constant-time MAC comparison. It does not try every secret until one happens to match. The latter pattern makes removal difficult to observe, increases work per hostile request, and hides senders that failed to rotate.

Four invariants govern the path:

  1. The verifier sees exactly the bytes read from the network, subject only to an explicit maximum size.
  2. The authenticated message binds the timestamp and key ID as well as the body; metadata used for freshness or selection cannot float outside the signature.
  3. Authentication happens before JSON parsing or side effects.
  4. A valid signature does not imply a new event. Durable idempotency decides whether an authenticated event may change order state.

The failure boundary should be deliberately narrow. An unknown key ID, malformed signature, timestamp outside the accepted window, oversized body, or MAC mismatch produces a refusal without parsing the payload. A valid duplicate receives the contract's success response but performs no second ledger mutation. A temporary storage failure is different: the service must not acknowledge an event whose idempotency claim cannot be made durable, because a success response could suppress a retry while the order update was never committed.

Replay limits deserve compliance-level precision. A five-minute freshness window in the sample below is an example policy, not a universal limit; it must be no longer than the sender's documented tolerance and must account for controlled clock skew. NIST guidance treats key states, cryptoperiods, and transitions as lifecycle concerns rather than an informal secret swap. PCI DSS 4.0.1 likewise requires cryptographic keys used to protect account data to be managed through their lifecycle. Those sources do not prescribe this webhook header format, but they do support recording activation, retirement, access, and destruction as auditable events.

Option Refused-traffic risk Resource ceiling Audit quality Decision
Stop old, start new High at the cutover boundary Lowest overlap Clear but incomplete when senders lag Reject for production rotation
Try every active secret Low while secrets remain Work grows with key count and hostile input Poor attribution to a key Reject
Key ID plus two-key overlap Low during a bounded window Fixed at one lookup and one MAC per request Explicit use by key and time Adopt
Unbounded multi-key acceptance Low initially Storage and governance grow without a terminal condition Retirement becomes ambiguous Reject

The spend ceiling is expressed as a structural constraint: at most two receive keys, one verification attempt, a 1 MiB request limit in this example, and a fixed overlap deadline. This avoids turning cost into a guess about a vendor's mutable unit price. The refused-traffic side is measured separately through counts of accepted events by key ID, unknown-key refusals, stale timestamps, invalid MACs, duplicates, and downstream processing failures. The trade-off is explicit: one additional secret slot and a finite interval of duplicate configuration buy tolerance for in-flight requests and rolling deployments, while the two-key cap prevents a temporary migration state from becoming an accumulating key ring. If the observed old-key traffic cannot reach zero before the deadline, the correct response is to locate the remaining producer or queued delivery, not to erase the ceiling.

Critical path in Go

The following program is a runnable reference implementation of the security boundary. Its example protocol defines X-Webhook-Key-Id, X-Webhook-Timestamp, and X-Webhook-Signature; the signature is lowercase hexadecimal HMAC-SHA-256 over timestamp + "." + rawBody. Use the sender's documented construction in a real integration. Do not silently adapt one provider's format to another.

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "log"
    "net/http"
    "strconv"
    "sync"
    "time"
)

const maxBodyBytes = 1 << 20

type Keyring interface {
    Secret(keyID string) ([]byte, bool)
}

type StaticKeyring map[string][]byte

func (k StaticKeyring) Secret(keyID string) ([]byte, bool) {
    secret, ok := k[keyID]
    return secret, ok
}

type EventStore interface {
    Claim(eventID string) (bool, error)
}

type MemoryStore struct {
    mu   sync.Mutex
    seen map[string]struct{}
}

func (s *MemoryStore) Claim(eventID string) (bool, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    if _, exists := s.seen[eventID]; exists {
        return false, nil
    }
    s.seen[eventID] = struct{}{}
    return true, nil
}

type OrderEvent struct {
    ID      string `json:"id"`
    OrderID string `json:"order_id"`
    Type    string `json:"type"`
}

func verify(secret, body []byte, timestamp, encodedMAC string) error {
    provided, err := hex.DecodeString(encodedMAC)
    if err != nil || len(provided) != sha256.Size {
        return errors.New("malformed signature")
    }

    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(body)
    if !hmac.Equal(mac.Sum(nil), provided) {
        return errors.New("signature mismatch")
    }
    return nil
}

func webhook(keys Keyring, events EventStore, now func() time.Time) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        keyID := r.Header.Get("X-Webhook-Key-Id")
        timestamp := r.Header.Get("X-Webhook-Timestamp")
        signature := r.Header.Get("X-Webhook-Signature")

        secret, ok := keys.Secret(keyID)
        if !ok {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }

        seconds, err := strconv.ParseInt(timestamp, 10, 64)
        if err != nil || abs(now().Unix()-seconds) > int64(5*time.Minute/time.Second) {
            http.Error(w, "stale request", http.StatusUnauthorized)
            return
        }

        r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
        body, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "invalid body", http.StatusBadRequest)
            return
        }
        if err := verify(secret, body, timestamp, signature); err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }

        var event OrderEvent
        if err := json.Unmarshal(body, &event); err != nil || event.ID == "" {
            http.Error(w, "invalid event", http.StatusBadRequest)
            return
        }

        claimed, err := events.Claim(event.ID)
        if err != nil {
            http.Error(w, "temporary failure", http.StatusServiceUnavailable)
            return
        }
        if claimed {
            log.Printf("accepted event_id=%s order_id=%s key_id=%s type=%s",
                event.ID, event.OrderID, keyID, event.Type)
        }
        w.WriteHeader(http.StatusNoContent)
    }
}

func abs(n int64) int64 {
    if n < 0 {
        return -n
    }
    return n
}

func main() {
    keys := StaticKeyring{
        "orders-2026-a": []byte("replace-from-secret-manager"),
        "orders-2026-b": []byte("replace-from-secret-manager"),
    }
    events := &MemoryStore{seen: make(map[string]struct{})}
    http.Handle("/webhooks/orders", webhook(keys, events, time.Now))
    fmt.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

The in-memory store exists only to keep the program executable. Production correctness requires a durable uniqueness constraint on the sender's stable event ID, ideally committed in the same database transaction as the order-state transition or an outbox record. A preliminary SELECT followed by an INSERT is not enough under concurrency; two workers can observe absence together. The storage operation must make the claim atomic.

The audit log deliberately records key ID, event ID, decision, and time, but never the secret, signature, authorization header, or raw customer payload. OWASP's secrets guidance recommends centralized lifecycle management, least privilege, rotation, and logging around secret access. Log access also needs controls because identifiers can still be sensitive operational data.

Rotation runbook under a hard ceiling

Generate the next key inside the approved secret-management boundary, assign it a unique identifier, and record who authorized the transition. Load it into receivers first. A readiness probe should confirm that the process can resolve both allowed identifiers without printing either secret; readiness must not claim success merely because configuration text exists.

Next, run deterministic fixtures through the exact deployed middleware chain. Include the original compact JSON, whitespace-modified JSON with its own MAC, a valid MAC paired with the wrong body, an unknown key ID, an expired timestamp, an oversized body, malformed JSON with a valid MAC, and the same valid event delivered twice. This set separates transport authentication from document validity and business idempotency. It also catches the Express ordering regression: if global JSON parsing reaches the route first, the raw-byte fixture cannot survive unchanged.

Switch the sender to the next key only after every receiver instance accepts it. Observe acceptance grouped by key ID and deployment version. Keep the old key available for the documented delivery-retry horizon plus measured clock-skew allowance, subject to the preapproved overlap deadline. If old-key traffic remains near that deadline, stop and identify the sender or retry queue; extending acceptance without an owner silently converts an exception into permanent policy.

Then retire the old key from receivers, revoke it at its source, and write an immutable rotation record containing activation time, first observed use, last observed use, retirement time, approver, and evidence links. Secret material does not belong in that record.

This staged sequence makes the commercial trade-off legible. The overlap consumes a fixed second secret slot and a bounded period of monitoring. Premature retirement can refuse paid orders, while indefinite overlap enlarges the compromise window and governance burden. The deadline is a risk control, not a calendar ritual.

Why reject a single instant cutover?

An atomic replacement looks attractive because it minimizes simultaneous credentials. It is valid when the sender and every receiver share one transactional control plane, delivery is paused, no signed messages are queued, and rollback can restore the exact prior state. Those conditions rarely hold for internet webhook delivery: requests may be in flight, retries may have been signed earlier, and a rolling deployment temporarily runs different receiver versions.

The rejected option can still be appropriate in a maintenance window for an internal batch system whose queue is drained and cryptographically accounted for. Document those preconditions. Do not generalize that exception to checkout or fulfillment callbacks where refused traffic has immediate operational consequences.

Dual-key acceptance is not exactly-once delivery. Networks can repeat requests, processes can fail after committing and before replying, and senders can retry after losing a response. The achievable property is an exactly-once business effect built from authenticated input, an atomic idempotency claim, and a replayable audit trail. Authenticate bytes first; deduplicate effects second. Keeping that distinction visible prevents a successful MAC check from becoming permission to post the same payment or shipment transition twice.

References

Top comments (0)