# Webhook HMAC Verification in Node.js and Python: A Secure Implementation Guide

> Step-by-step guide to implementing HMAC-SHA256 webhook signature verification in Node.js and Python, covering raw body parsing, timing-safe comparison, and replay attack prevention.

- **Published:** 2025-04-07
- **Author:** Clixo
- **Reading time:** 5 min read
- **Tags:** webhooks, security, hmac, nodejs, python
- **Canonical URL:** https://clixo.sh/blog/webhook-hmac-verification-nodejs-python

An open webhook endpoint is an open door. If you are accepting inbound HTTP callbacks from a payment processor, a shipping provider, or any third-party service, and you are not verifying the request signature, anyone who knows your endpoint URL can send fabricated events. A fake `payment.succeeded` event is not a theoretical attack — it is one HTTP POST away.

HMAC signature verification is the standard defence, and it takes about 20 lines of code to do correctly. Here is how.

## How HMAC Webhook Signatures Work

When you register a webhook with a provider, they give you a secret — a shared key known only to them and you. When they send an event, they compute an HMAC-SHA256 hash of the request body using that secret and include the hash in a request header. You receive the request, compute the same hash independently, and compare the two. If they match, the request is authentic.

```mermaid
sequenceDiagram
  participant P as Provider
  participant E as Your Endpoint
  participant Q as Job Queue
  P->>P: Compute HMAC-SHA256 of payload
  P->>E: POST payload with signature header
  E->>E: Compute HMAC of raw body
  E->>E: timingSafeEqual comparison
  alt Signature mismatch
    E-->>P: 401 Unauthorized
  else Valid signature
    E->>E: Check timestamp for replay attack
    E->>Q: Enqueue event for processing
    E-->>P: 200 OK
  end
```

The header name varies by provider:

- **Stripe**: `Stripe-Signature` (also includes a timestamp for replay protection)
- **Shopify**: `X-Shopify-Hmac-SHA256` (base64-encoded)
- **GitHub**: `X-Hub-Signature-256` (prefixed with `sha256=`)
- **Twilio**: `X-Twilio-Signature`

The algorithm is always HMAC-SHA256 across modern providers.

## The Critical Rule: Use the Raw Body

Before any code, understand this: you must verify the signature against the **raw, unmodified request body bytes**. If you parse the JSON first and then re-serialize it to verify, byte-level differences (whitespace, key ordering) will break the comparison.

In web frameworks, body parsers typically consume the raw bytes and replace them with a parsed object. You need to intercept the raw bytes before that happens.

## Implementation in Node.js (Express)

```js
const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

// Use raw body parser for the webhook route ONLY
app.post(
  '/webhooks/github',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-hub-signature-256'];
    if (!signature) {
      return res.status(400).send('Missing signature header');
    }

    const expected = 'sha256=' + crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');

    // Timing-safe comparison — never use === for signatures
    const sigBuffer = Buffer.from(signature);
    const expBuffer = Buffer.from(expected);

    if (
      sigBuffer.length !== expBuffer.length ||
      !crypto.timingSafeEqual(sigBuffer, expBuffer)
    ) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body);
    // process event...
    res.status(200).send('OK');
  }
);
```

Key points: `express.raw()` gives you `req.body` as a `Buffer` instead of a parsed object. `crypto.timingSafeEqual` prevents timing attacks where an attacker could infer the correct signature one byte at a time by measuring response times.

## Implementation in Python (FastAPI)

```python
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException, Header

app = FastAPI()
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()

@app.post("/webhooks/shopify")
async def shopify_webhook(
    request: Request,
    x_shopify_hmac_sha256: str = Header(...)
):
    raw_body = await request.body()

    expected = hmac.new(
        WEBHOOK_SECRET,
        raw_body,
        hashlib.sha256
    ).digest()

    import base64
    expected_b64 = base64.b64encode(expected).decode()

    # Timing-safe comparison
    if not hmac.compare_digest(expected_b64, x_shopify_hmac_sha256):
        raise HTTPException(status_code=401, detail="Invalid signature")

    # parse and process
    import json
    event = json.loads(raw_body)
    # process event...
    return {"status": "ok"}
```

`hmac.compare_digest` in Python provides the same constant-time comparison as `crypto.timingSafeEqual` in Node.js. Always use it instead of `==`.

## Replay Attack Prevention

An attacker who intercepts a valid, signed request could replay it later. Stripe includes a timestamp in its `Stripe-Signature` header for exactly this reason. You should reject requests with a timestamp older than 5 minutes:

```
Stripe-Signature: t=1714000000,v1=abc123...
```

Parse the `t=` value, compare it to the current Unix timestamp, and return 400 if the difference exceeds 300 seconds. Do this check before the HMAC comparison so you fail fast.

## Secret Rotation Without Downtime

Most providers support two active secrets simultaneously during rotation. The procedure:

1. Generate a new secret in the provider's dashboard.
2. Update your code to accept signatures from either the old or the new secret.
3. Update the provider to use the new secret.
4. Verify traffic is flowing with the new secret.
5. Remove the old secret from your code and the provider's dashboard.

Never rotate secrets by simply swapping them — you will have a window where valid deliveries fail.

## What Can Go Wrong

- Comparing parsed JSON instead of raw bytes — signatures will never match.
- Using string equality (`===`) instead of a timing-safe function — timing oracle vulnerability.
- Storing the secret in source code or committing it to git — rotate immediately if this happens.
- Skipping timestamp validation — replay attacks become trivial.
- Using `express.json()` before `express.raw()` on the same route — the body is already consumed.

Security and reliability in third-party integrations require attention to details like these. If you are building webhook infrastructure for a product that handles payments, compliance data, or customer records, [Clixo can help you get it right](https://clixo.sh/#contact).

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
