Docs / webhook-signature-verification

Webhook signature verification

← All docs

Penaxtra signs every outbound webhook with HMAC-SHA256 over a <timestamp>.<body> payload. Receivers MUST verify the signature before acting on a webhook to defeat tampering + replay.

Headers

HeaderPurpose
X-Penaxtra-Signaturet=<unix_ts>,v1=<hex_hmac_sha256>
X-Penaxtra-EventEvent slug, e.g. finding.created, scan.completed
X-Penaxtra-DeliveryUnique delivery id; safe to dedupe on this

The signing secret is shown once when the webhook target is created in /app/integrations. The DB stores only the sealed-box encrypted copy.

Verification reference (Python)

import hmac, hashlib, time

def verify(secret: bytes, body: bytes, signature_header: str) -> bool:
    # Parse "t=<ts>,v1=<hex>"
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    t = int(parts["t"])
    sig = parts["v1"]
    # Reject if the timestamp is more than 5 minutes old (replay window).
    if abs(int(time.time()) - t) > 300:
        return False
    payload = f"{t}.".encode() + body
    expected = hmac.new(secret, payload, hashlib.sha256).hexdigest()
    # Constant-time comparison defeats timing-side-channel attacks.
    return hmac.compare_digest(expected, sig)

Verification reference (Node.js)

const crypto = require("crypto");

function verify(secret, rawBody, signatureHeader) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const t = parseInt(parts.t, 10);
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;
  const payload = Buffer.concat([Buffer.from(`${t}.`), rawBody]);
  const expected = crypto.createHmac("sha256", secret).update(payload).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Common pitfalls

  • Body parsing before verification: verify against the RAW request body, not a re-serialised JSON object. Re-serialisation reorders keys and breaks the HMAC.
  • String comparison via ==: timing-side-channel risk. Always use a constant-time comparison primitive.
  • No replay window: a leaked signature is replayable for the entire lifetime of the secret without a timestamp check.
  • **Trusting non-Penaxtra-supplied X-Forwarded-* headers**: signature applies to body + timestamp only.

Error semantics

If signature verification fails, return a 4xx response and do NOT process the event. Penaxtra retries with exponential backoff up to seven attempts spanning ~24 hours.

Related

Last reviewed: 2026-06-15. Reviewed by: Engineering. Content type: Developer documentation. Reach the maintainers: [email protected] .