Building Secure Webhook Endpoints With HMAC Verification

Building Secure Webhook Endpoints With HMAC Verification

What You’ll Need

  • Hetzner VPS or DigitalOcean instance running Ubuntu 22.04 LTS
  • Namecheap domain configured with valid SSL/TLS certificates
  • n8n Cloud or a self-hosted n8n instance for routing automated events
  • Node.js (v18 or higher) or Python (3.10 or higher) runtime installed on your target server

Table of Contents

Why Webhook Security Requires HMAC Verification

When your system exposes a public HTTP endpoint to receive incoming webhook payloads, anyone on the internet can send POST requests to that URL. Relying solely on obscure endpoint URLs or standard basic authentication is insufficient for high-security applications. Malicious actors can scan web servers, intercept endpoints, or craft forged payloads to execute unauthorised backend actions.

While HTTPS guarantees transport layer security between the provider and your server, it does not guarantee payload authenticity. If you want to know how to properly encrypt traffic in transit before reaching your application layer, check out our guide on How to Secure Nginx Wildcard SSL Certificates.

Hash-based Message Authentication Code (HMAC) solves the authenticity problem at the application layer. HMAC relies on a secret key shared exclusively between the webhook sender (such as Stripe, GitHub, or Shopify) and your receiving application. The sender generates a cryptographic hash using the raw payload body and the shared secret, then attaches this signature to an HTTP header (such as X-Hub-Signature-256 or X-Signature).

When your endpoint receives the request, it recalculates the signature using the exact raw payload body and the shared secret key. If the calculated signature matches the incoming header signature, you can guarantee two things:

  1. The request was sent by a party that possesses the shared secret key.
  2. The payload was not altered in transit by an attacker.

A critical detail developer teams often miss is raw body parsing. Standard application body parsers parse incoming JSON into object representations. Re-serializing a parsed JSON object back into a string often changes key ordering, escapes characters, or adjusts whitespace formatting. This subtle modification changes the cryptographic output completely, causing signature validation to fail. To perform HMAC validation accurately, you must capture the exact, unparsed byte buffer sent across the network.

Step 1: Building a Node.js Express Endpoint with Raw Body HMAC Validation

To deploy a secure Node.js service on your Hetzner VPS or DigitalOcean instance, you need to configure Express to preserve the raw request buffer before JSON parsing occurs.

We will use the native Node.js crypto module to handle hashing and timing-safe comparisons. Using crypto.timingSafeEqual is mandatory because standard string equality comparisons (===) exit early on the first non-matching character. Attackers can exploit string comparison timing differences to guess valid signatures byte by byte.

Here is the complete production-grade application code written in Node.js:

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

const app = express();
const PORT = process.env.PORT || 3000;
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'super-secret-shared-key-32-bytes';

app.use(express.json({
  verify: (req, res, buf, encoding) => {
    req.rawBody = buf;
  }
}));

function verifyHmacSignature(req, res, next) {
  const signatureHeader = req.headers['x-signature-256'];

  if (!signatureHeader) {
    return res.status(401).json({
      status: 'error',
      message: 'Missing signature header.'
    });
  }

  if (!req.rawBody) {
    return res.status(400).json({
      status: 'error',
      message: 'Raw body buffer missing. Check body parser middleware.'
    });
  }

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

  const signatureBuffer = Buffer.from(signatureHeader, 'utf8');
  const computedBuffer = Buffer.from(computedSignature, 'utf8');

  if (signatureBuffer.length !== computedBuffer.length) {
    return res.status(401).json({
      status: 'error',
      message: 'Invalid signature length.'
    });
  }

  const isValid = crypto.timingSafeEqual(signatureBuffer, computedBuffer);

  if (!isValid) {
    return res.status(401).json({
      status: 'error',
      message: 'Invalid cryptographic signature.'
    });
  }

  next();
}

app.post('/api/v1/webhooks', verifyHmacSignature, (req, res) => {
  const payload = req.body;
  
  console.log('Webhook validated successfully!');
  console.log('Event Name:', payload.event);
  console.log('Data:', payload.data);

  res.status(200).json({
    status: 'success',
    message: 'Webhook processed successfully.'
  });
});

app.listen(PORT, () => {
  console.log(`Server running securely on port ${PORT}`);
});

When webhooks fail authentication due to network hiccups or transient signature errors, proper backend retry mechanisms are essential. Review our tutorial on Handling Webhook Retries Using Exponential Backoff Strategies to learn how to handle failed deliveries gracefully without duplicate state executions.

💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.

Step 2: Implementing Python FastAPI HMAC Middleware

If your stack runs on Python, FastAPI offers clean request handling and asynchronous processing. Much like Node.js, FastAPI’s body parsing reads streams sequentially. To intercept raw request bytes before they reach endpoint handlers, you can implement a custom verification function using Python’s built-in hmac and hashlib standard library modules.

Once an incoming payload is authenticated in FastAPI, you can send downstream data off for automated analysis. For instance, see our post on Parsing Inbound Support Tickets with OpenAI to process support payload content dynamically.

Here is the full Python FastAPI application code with complete HMAC verification logic:

import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException, status, Depends
from fastapi.responses import JSONResponse

app = FastAPI(title="Secure Webhook Receiver")

WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "super-secret-shared-key-32-bytes").encode("utf-8")

async def verify_hmac_header(request: Request) -> bytes:
    signature_header = request.headers.get("X-Signature-256")
    
    if not signature_header:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Missing signature header."
        )
    
    raw_body = await request.body()
    
    if not raw_body:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Empty body payload."
        )

    computed_hmac = hmac.new(
        key=WEBHOOK_SECRET,
        msg=raw_body,
        digestmod=hashlib.sha256
    ).hexdigest()
    
    expected_signature = f"sha256={computed_hmac}"
    
    if not hmac.compare_digest(signature_header, expected_signature):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid cryptographic signature."
        )

    return raw_body

@app.post("/api/v1/webhooks")
async def handle_webhook(request: Request, raw_body: bytes = Depends(verify_hmac_header)):
    json_data = await request.json()
    
    return JSONResponse(
        status_code=status.HTTP_200_OK,
        content={
            "status": "success",
            "message": "Webhook verified and processed successfully.",
            "received_event": json_data.get("event", "unknown")
        }
    )

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Step 3: Validating HMAC Webhooks Inside n8n Workflows

When orchestrating services through n8n Cloud or self-hosted instances, your workflows often serve as entry endpoints for external integrations. While n8n features built-in authentication options on its Webhook Trigger node, validating arbitrary vendor HMAC signatures directly in workflow logic guarantees compatibility across any provider.

To execute custom cryptographic checks within n8n, capture the binary or raw string body inside the Webhook Trigger node settings, then connect a Code node executing native Node.js logic.

Webhook Node Configuration

  1. Set HTTP Method to POST.
  2. Set Path to webhook-receiver.
  3. Set Response Mode to Last Node.
  4. Enable Include Response Headers and Body under node options.

Code Node Implementation

Add a Code node directly following the Webhook node with the runtime set to Node.js:

const crypto = require('crypto');

const webhookSecret = 'super-secret-shared-key-32-bytes';
const inputItems = $input.all();
const result = [];

for (const item of inputItems) {
  const headers = item.json.headers || {};
  const signatureHeader = headers['x-signature-256'] || headers['X-Signature-256'];

  if (!signatureHeader) {
    throw new Error('Unauthorized: Missing X-Signature-256 header.');
  }

  const bodyData = item.json.body;
  const rawBodyString = typeof bodyData === 'string' ? bodyData : JSON.stringify(bodyData);

  const computedSignature = 'sha256=' + crypto
    .createHmac('sha256', webhookSecret)
    .update(rawBodyString)
    .digest('hex');

  const signatureBuffer = Buffer.from(signatureHeader, 'utf8');
  const computedBuffer = Buffer.from(computedSignature, 'utf8');

  if (signatureBuffer.length !== computedBuffer.length) {
    throw new Error('Unauthorized: Signature length mismatch.');
  }

  const isValid = crypto.timingSafeEqual(signatureBuffer, computedBuffer);

  if (!isValid) {
    throw new Error('Unauthorized: HMAC validation failed.');
  }

  result.push({
    json: {
      verified: true,
      timestamp: new Date().toISOString(),
      payload: item.json.body
    }
  });
}

return result;

Preventing Replay Attacks with Timestamp Verification

Validating HMAC signatures protects your server against payload tampering and unauthenticated traffic, but it does not completely prevent replay attacks. In a replay attack, an eavesdropper intercepts a valid payload and its corresponding valid HMAC header, then resends the exact request payload repeatedly to your endpoint.

Because the payload and secret key remain identical, the calculated HMAC signature remains valid. To block replay attacks, professional API providers attach a timestamp header alongside the signature (for example, X-Signature-Timestamp or contained inside X-Signature: t=1690000000,v1=hash).

To reject replayed payloads, verify that the request timestamp falls within a small acceptable tolerance window (typically 300 seconds) of your server’s current system time.

Here is a full Node.js module function demonstrating how to validate signature timing windows concurrently with HMAC digests:

const crypto = require('crypto');

function verifyTimestampAndSignature(rawBody, signatureHeader, timestampHeader, secret, toleranceInSeconds = 300) {
  if (!signatureHeader || !timestampHeader) {
    return { valid: false, reason: 'Missing required validation headers.' };
  }

  const currentTime = Math.floor(Date.now() / 1000);
  const requestTime = parseInt(timestampHeader, 10);

  if (isNaN(requestTime)) {
    return { valid: false, reason: 'Invalid timestamp format.' };
  }

  if (Math.abs(currentTime - requestTime) > toleranceInSeconds) {
    return { valid: false, reason: 'Timestamp outside acceptable window. Potential replay attack.' };
  }

  const payloadToSign = `${timestampHeader}.${rawBody.toString('utf8')}`;
  
  const computedSignature = crypto
    .createHmac('sha256', secret)
    .update(payloadToSign)
    .digest('hex');

  const signatureBuffer = Buffer.from(signatureHeader, 'utf8');
  const computedBuffer = Buffer.from(computedSignature, 'utf8');

  if (signatureBuffer.length !== computedBuffer.length) {
    return { valid: false, reason: 'Signature mismatch.' };
  }

  const isValid = crypto.timingSafeEqual(signatureBuffer, computedBuffer);

  if (!isValid) {
    return { valid: false, reason: 'Cryptographic signature mismatch.' };
  }

  return { valid: true, reason: 'Webhook payload authentic and fresh.' };
}

module.exports = { verifyTimestampAndSignature };

By enforcing raw body byte capture, constant-time signature comparisons, and tight timestamp validation windows, your backend architecture remains fully resilient against unauthorized data injection, payload forgery, and automated replay exploits.

Getting Started

Ready to deploy secure webhook receivers and robust backend pipelines?

  • Reserve high-performance compute instances using a Hetzner VPS or a DigitalOcean droplet.
  • Register your webhook endpoints under custom SSL domains with Namecheap.
  • Wire up downstream business logic and payload routing inside n8n Cloud.

Outsource Your Automation

Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-HUGO for priority. Or DM at chasebot.online.

Want to automate this yourself?

Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.

Want this engine running on your own VPS?

This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.

Get System 3
system online