DEV Community

137Foundry
137Foundry

Posted on

Step-by-Step Webhook Signature Verification for Any Sender

Webhook signature verification is the first line of defense against forged events. Without it, any HTTP client that knows your endpoint URL can POST fabricated events. The verification process is the same across most webhook senders, even when the specific header names and hash algorithms differ.

This guide walks through implementing signature verification for a webhook receiver, from parsing the header to computing the HMAC to returning the right response.

Step 1: Get the Raw Request Body Before Any Parsing

This is the step most implementations get wrong. Signature verification computes an HMAC over the raw request body bytes. The HMAC must be computed before any framework deserialization happens.

Web frameworks parse JSON bodies automatically. When they do, they may normalize whitespace, change encoding, or reorder keys. Computing the HMAC over the parsed-then-re-serialized body will produce a different hash than the sender computed over the original bytes. The verification will fail even for valid payloads.

In Python with FastAPI:

from fastapi import Request

@app.post("/webhooks/events")
async def receive_webhook(request: Request):
    raw_body = await request.body()  # raw bytes, before any JSON parsing
    signature = request.headers.get("X-Signature-256", "")
    # verify against raw_body, not json.loads(raw_body)
Enter fullscreen mode Exit fullscreen mode

In Node.js with Express:

app.post('/webhooks/events', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body;  // Buffer, not parsed JSON
  const signature = req.headers['x-signature-256'];
  // verify against rawBody
});
Enter fullscreen mode Exit fullscreen mode

The express.raw() middleware captures the body as a Buffer instead of parsing it. Without it, req.body contains the parsed JSON object, which breaks verification.

Step 2: Parse the Signature Header

Webhook senders typically include both the HMAC hash and a timestamp in the signature header, either as separate headers or combined in a single header. Stripe combines them in a single header with a specific format:

Stripe-Signature: t=1714000000,v1=abc123...
Enter fullscreen mode Exit fullscreen mode

Parse the header to extract the timestamp and the hash:

def parse_stripe_signature(header: str) -> dict:
    parts = {}
    for part in header.split(","):
        key, value = part.split("=", 1)
        parts[key] = value
    return parts

sig_parts = parse_stripe_signature(request.headers.get("Stripe-Signature", ""))
timestamp = sig_parts.get("t", "")
received_hash = sig_parts.get("v1", "")
Enter fullscreen mode Exit fullscreen mode

Other senders use a simpler format with the hash prefixed:

X-Signature-256: sha256=abc123...
Enter fullscreen mode Exit fullscreen mode
received_hash = signature_header.removeprefix("sha256=")
Enter fullscreen mode Exit fullscreen mode

Check the sender's documentation for their specific header format. The concept is the same; only the parsing differs.

Step 3: Compute the Expected HMAC

With the raw body bytes and the shared secret, compute the expected HMAC using the algorithm your sender specifies. Most use SHA-256.

For Stripe-style signatures where the hash is computed over {timestamp}.{body}:

import hmac
import hashlib

def compute_hmac(raw_body: bytes, timestamp: str, secret: str) -> str:
    signed_payload = f"{timestamp}.".encode() + raw_body
    return hmac.new(
        secret.encode(),
        signed_payload,
        hashlib.sha256
    ).hexdigest()
Enter fullscreen mode Exit fullscreen mode

For simpler senders where the hash is computed directly over the body:

def compute_hmac_simple(raw_body: bytes, secret: str) -> str:
    return hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()
Enter fullscreen mode Exit fullscreen mode

Step 4: Compare Using Constant-Time Equality

Never use == to compare the expected and received hashes. Standard string equality short-circuits on the first differing character, which creates a timing side channel. An attacker can measure how long the comparison takes to determine how many leading characters of the hash they got right, eventually reconstructing a valid hash without knowing the secret.

is_valid = hmac.compare_digest(expected_hash, received_hash)
Enter fullscreen mode Exit fullscreen mode

hmac.compare_digest takes the same amount of time regardless of where the strings differ. The equivalent in JavaScript uses the crypto module's timingSafeEqual function.

Step 5: Check the Timestamp Window

If the sender includes a timestamp in the signature header, check that it's within an acceptable window (typically five minutes). This prevents replay attacks where an attacker captures a valid signed request and re-sends it later.

import time

def is_timestamp_valid(timestamp: str, max_age_seconds: int = 300) -> bool:
    try:
        event_time = int(timestamp)
        return abs(time.time() - event_time) < max_age_seconds
    except (ValueError, TypeError):
        return False
Enter fullscreen mode Exit fullscreen mode

If the timestamp is more than five minutes old, return 400 and log the event for investigation. The sender should not be sending payloads with stale timestamps; this is a potential replay attempt.

Step 6: Return the Right Status Codes

The response status code tells the sender whether to retry:

  • 200: Event received and accepted. Don't retry.
  • 400: Invalid signature or malformed request. Don't retry (retries won't fix a tampered payload).
  • 500: Server error. Retry according to retry policy.

Return 400 on signature failures, not 500. A 500 tells the sender to retry, which is wrong behavior for a forged payload. A 400 tells the sender the request was rejected as invalid.

Testing the Verification Logic

The most important tests to write:

  1. Valid signature passes verification
  2. Tampered body fails verification
  3. Wrong secret fails verification
  4. Missing signature header returns 400
  5. Expired timestamp is rejected

Use Postman to send test requests with custom signature headers to a running server. ngrok lets you test against a real external sender during development.

Handling Senders That Deviate from the Standard Pattern

Not all webhook senders follow the same verification scheme. Most use HMAC-SHA256 with a shared secret, but the payload construction, header format, and timestamp handling vary.

Timestamp in the signed payload. Stripe constructs the signed payload as {timestamp}.{raw_body}, not just the raw body. This means you need to extract the timestamp from the signature header, construct the signed string manually before computing the HMAC, and verify that string against the received hash. A receiver that computes the HMAC over just the raw body will fail verification for Stripe webhooks even if the secret is correct.

Multiple hash versions. Some senders include multiple hash versions in the header (for example, both a v0 and v1 hash). The receiver should check whether any of the provided hashes matches the expected value, rather than requiring a specific version. This allows the sender to rotate their signing scheme without breaking receivers.

No timestamp. Some simpler webhook senders include only the HMAC hash with no timestamp. For these, timestamp validation isn't possible, but you should still verify the HMAC. The absence of a timestamp means replay attacks are technically possible, which is worth noting in your integration documentation.

Custom algorithms. A small number of senders use HMAC-SHA1 instead of SHA256. HMAC-SHA1 is not considered broken for this use case, but SHA256 is preferred. Implement the algorithm your specific sender specifies.

Common Verification Failures in Production

A few signature verification failures that appear consistently across production webhook integrations:

Middleware that reads the body. Some logging middleware or request parsing middleware reads the request body before your verification code runs. If the body is consumed and not replaced with the original bytes, your verification code gets an empty body. Make sure any body-reading middleware restores the raw bytes before the request reaches the webhook handler.

Content-Type normalization. Some frameworks normalize or re-parse the body when the Content-Type header is application/json. Using express.raw() or the framework-equivalent prevents this. Always test signature verification explicitly in your actual framework environment, not just in isolation.

Header name case sensitivity. HTTP header names are case-insensitive by spec but some implementations are not. If your verification code looks for X-Signature-256 but the sender sends x-signature-256, some frameworks will pass it through and some won't. Use case-insensitive header lookup.

For the broader receiver architecture (async processing, idempotency, failure handling), How to Build a Webhook Receiver That Handles Real-World Traffic covers how signature verification fits into the complete pattern.

This development service builds and maintains data integration infrastructure including webhook receivers, event processors, and API integrations for production workloads.

Security key lock mechanism close-up
Photo by Hans on Pixabay

Top comments (0)