Securing Incoming Webhooks with HMAC Signature Verification

Securing Incoming Webhooks with HMAC Signature Verification

What You’ll Need


Table of Contents


How HMAC Webhook Verification Works

Exposing HTTP endpoint URLs to the public internet opens your infrastructure to potential security risks. Unprotected endpoints can be targeted by arbitrary POST requests, forged payloads, or Denial of Service attacks. Standard HTTP Basic Authentication or API keys offer basic protection, but they fail to verify whether the payload body was altered in transit.

Hash-based Message Authentication Code (HMAC) verification resolves this vulnerability. HMAC combines a cryptographic hash function (such as SHA-256) with a secret key shared exclusively between the webhook sender and the recipient.

+-----------------------------------------------------------------------+
|                             SENDER                                    |
| 1. Draft JSON Payload: {"event": "payment_success", "amount": 99}     |
| 2. Create HMAC: HMAC-SHA256(Payload, SecretKey) -> Signature          |
| 3. Send Request with Header: X-Signature: <Signature>                 |
+-----------------------------------------------------------------------+
                                   |
                                   | HTTP POST Request
                                   v
+-----------------------------------------------------------------------+
|                            RECEIVER                                   |
| 1. Read Raw Payload Body & X-Signature Header                         |
| 2. Recalculate HMAC: HMAC-SHA256(RawBody, SecretKey)                  |
| 3. Compare Signatures using Timing-Safe String Comparison             |
| 4. Process Request if Match, Reject with 401/403 if Mismatch          |
+-----------------------------------------------------------------------+

The process relies on three key mechanisms:

  1. Shared Secret: Both parties store a high-entropy string (e.g., 32-byte hex string). This key is never transmitted across the network.
  2. Payload Hashing: Before dispatching an HTTP POST request, the sender hashes the raw HTTP body string using the shared secret and sha256. The resulting digest is sent in an HTTP header (e.g., X-Signature or X-Hub-Signature-256).
  3. Verification: The recipient receives the raw bytes, calculates the exact same hash using its local copy of the secret key, and compares the generated digest against the header.

If an attacker intercepts the transmission and modifies a single character in the JSON body (for instance, changing an invoice amount from $10.00 to $0.00), the calculated hash changes entirely. Without access to the secret key, the attacker cannot calculate a valid matching signature.


Step 1: Generating and Sending Signed Webhook Payloads

To build a secure signature mechanism, you must ensure the payload is signed after stringification and that key ordering remains identical.

Here is a complete Node.js script using Native crypto and axios to generate and send an HMAC SHA-256 signed payload. If you are dispatching events to external tools, learning this pattern helps integrate custom services with your workflow engines like n8n Cloud .

Create a file named webhook-sender.js:

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

const WEBHOOK_URL = 'https://webhook.yourdomain.com/webhook/secure-endpoint';
const SHARED_SECRET = 'c83f910a2d4e8b7123456789abcdef0123456789abcdef0123456789abcdef01';

const payload = {
  eventId: 'evt_9982401',
  eventType: 'user.billing.upgraded',
  timestamp: Math.floor(Date.now() / 1000),
  data: {
    userId: 'usr_4412',
    plan: 'enterprise',
    amountPaid: 49900,
    currency: 'usd'
  }
};

function generateHmacSignature(secret, rawBody) {
  return crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');
}

async function sendSignedWebhook() {
  const jsonStringPayload = JSON.stringify(payload);
  const signature = generateHmacSignature(SHARED_SECRET, jsonStringPayload);

  console.log('Generated Signature:', signature);

  try {
    const response = await axios.post(WEBHOOK_URL, jsonStringPayload, {
      headers: {
        'Content-Type': 'application/json',
        'X-Signature-256': signature,
        'X-Signature-Timestamp': payload.timestamp
      }
    });

    console.log('Webhook delivered successfully!');
    console.log('HTTP Status:', response.status);
    console.log('Response Body:', response.data);
  } catch (error) {
    if (error.response) {
      console.error('Delivery failed with status code:', error.response.status);
      console.error('Response data:', error.response.data);
    } else {
      console.error('Network or system error:', error.message);
    }
  }
}

sendSignedWebhook();

Run this script directly in Node.js:

node webhook-sender.js

💡 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: Verifying Webhooks in an Express.js Application

A common failure mode in webhook signature verification occurs when parsing the raw JSON payload body. If middleware automatically parses the JSON stream into an object using express.json(), key ordering or whitespace formatting can shift, altering the cryptographic hash during signature evaluation.

To correctly verify an HMAC digest, capture the raw Buffer before body parsing occurs. If you plan to deploy custom microservices on a infrastructure like a Hetzner VPS or Contabo VPS , use this implementation to handle raw body verification reliably.

If you are setting up VPS hosting, check out our guide on How to Deploy n8n with Docker on Any VPS (2026 Guide) for step-by-step instructions.

Create a file named server.js:

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

const app = express();
const PORT = 3000;
const SHARED_SECRET = 'c83f910a2d4e8b7123456789abcdef0123456789abcdef0123456789abcdef01';

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

function verifySignature(req) {
  const receivedSignature = req.headers['x-signature-256'];
  
  if (!receivedSignature) {
    return false;
  }

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

  const trustedBuffer = Buffer.from(computedSignature, 'utf8');
  const untrustedBuffer = Buffer.from(receivedSignature, 'utf8');

  if (trustedBuffer.length !== untrustedBuffer.length) {
    return false;
  }

  return crypto.timingSafeEqual(trustedBuffer, untrustedBuffer);
}

app.post('/webhook/secure-endpoint', (req, res) => {
  const isValid = verifySignature(req);

  if (!isValid) {
    console.warn('Unauthorized webhook attempt detected. Invalid signature.');
    return res.status(401).json({
      error: 'Unauthorized',
      message: 'HMAC signature verification failed.'
    });
  }

  console.log('Webhook signature successfully verified.');
  console.log('Received Payload:', req.body);

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

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

To run this backend receiver:

npm install express
node server.js

Step 3: Implementing HMAC Verification inside n8n Workflows

Self-hosted n8n instances process thousands of mission-critical triggers. Secure verification protects sensitive downstream tasks from malicious execution. If you use automated workflows to manage SaaS tasks, learning to secure endpoints with HMAC can help protect against unauthorized runs. Check out our guide on 5 n8n Workflows That Replace $200/Month in SaaS Tools for workflow automation ideas.

In n8n, secure incoming calls by connecting a Webhook Node to a custom Code Node that uses Node.js’s built-in crypto library.

+------------------+      +-------------------+      +-------------------+
|                  |      |                   |      |                   |
|   Webhook Node   | ---> |     Code Node     | ---> |    If Node /      |
|  (Raw Payload)   |      | (HMAC Validation) |      | Core Logic Node   |
|                  |      |                   |      |                   |
+------------------+      +-------------------+      +-------------------+

1. Webhook Node Setup

Set the configuration settings on your Webhook Node in n8n:

  • HTTP Method: POST
  • Path: secure-incoming-event
  • Response Mode: When Last Node Finishes
  • Options: Enable “Include Headers in Output” and set “Raw Body” to true.

2. Code Node JavaScript Logic

Connect a Code Node directly after the Webhook Node. Set the language mode to “Run Once for Each Item” and paste this code:

const crypto = require('crypto');

const SHARED_SECRET = 'c83f910a2d4e8b7123456789abcdef0123456789abcdef0123456789abcdef01';

const headers = $input.item.json.headers;
const rawBody = $input.item.json.rawBody;

const receivedSignature = headers['x-signature-256'] || headers['X-Signature-256'];

if (!receivedSignature) {
  throw new Error('Verification Failed: Missing X-Signature-256 header.');
}

if (!rawBody) {
  throw new Error('Verification Failed: Raw body is missing. Ensure Webhook Node option "Raw Body" is checked.');
}

const computedSignature = crypto
  .createHmac('sha256', SHARED_SECRET)
  .update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody), 'utf8')
  .digest('hex');

const signatureBuffer = Buffer.from(computedSignature, 'utf8');
const headerBuffer = Buffer.from(receivedSignature, 'utf8');

let isSignatureValid = false;

if (signatureBuffer.length === headerBuffer.length) {
  isSignatureValid = crypto.timingSafeEqual(signatureBuffer, headerBuffer);
}

if (!isSignatureValid) {
  throw new Error('Verification Failed: HMAC signature mismatch.');
}

return {
  json: {
    verified: true,
    payload: typeof rawBody === 'string' ? JSON.parse(rawBody) : rawBody,
    receivedAt: new Date().toISOString()
  }
};

If the signature evaluation fails, the Code node throws an error, halting workflow execution before any downstream API nodes run.

To monitor these types of critical workflow failures, set up real-time operational alerts using Telegram. Read our step-by-step guide on Building Telegram Bots for Workflow Management to catch authorization failures automatically.


Step 4: Preventing Replay Attacks with Timestamps

Verifying payload signatures ensures data integrity, but it does not prevent a malicious party from capturing a valid request and replaying it against your endpoint repeatedly.

To defend against replay attacks, mandate a signed Unix timestamp in your request headers. Your receiver logic can then check this timestamp and reject payloads that fall outside an acceptable time window (e.g., 5 minutes).

Here is an updated, production-ready Express middleware verification script that validates both the HMAC signature and the request timestamp:

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

const app = express();
const SHARED_SECRET = 'c83f910a2d4e8b7123456789abcdef0123456789abcdef0123456789abcdef01';
const MAX_ALLOWED_AGE_SECONDS = 300;

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

function verifySecureWebhook(req) {
  const signature = req.headers['x-signature-256'];
  const timestampHeader = req.headers['x-signature-timestamp'];

  if (!signature || !timestampHeader) {
    return { valid: false, reason: 'Missing signature or timestamp header.' };
  }

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

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

  const age = Math.abs(currentTimestamp - requestTimestamp);
  if (age > MAX_ALLOWED_AGE_SECONDS) {
    return { valid: false, reason: `Payload timestamp is expired. Age: ${age}s exceeds limit of ${MAX_ALLOWED_AGE_SECONDS}s.` };
  }

  const signedPayload = `${timestampHeader}.${req.rawBody}`;

  const expectedSignature = crypto
    .createHmac('sha256', SHARED_SECRET)
    .update(signedPayload, 'utf8')
    .digest('hex');

  const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
  const actualBuffer = Buffer.from(signature, 'utf8');

  if (expectedBuffer.length !== actualBuffer.length) {
    return { valid: false, reason: 'Signature byte length mismatch.' };
  }

  const match = crypto.timingSafeEqual(expectedBuffer, actualBuffer);

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

  return { valid: true };
}

app.post('/webhook/replay-protected', (req, res) => {
  const result = verifySecureWebhook(req);

  if (!result.valid) {
    console.warn('Webhook security validation failed:', result.reason);
    return res.status(401).json({
      error: 'Unauthorized',
      details: result.reason
    });
  }

  console.log('Validated timestamp and signature match.');
  return res.status(200).json({ status: 'success' });
});

app.listen(3001, () => {
  console.log('Replay-protected webhook receiver listening on port 3001');
});

Getting Started

To implement security verification across your webhook architecture:

  1. Launch your automation engine on n8n Cloud or deploy it manually on a Hetzner VPS or DigitalOcean instance using Docker.
  2. Store your HMAC secrets as environment variables (HMAC_SHARED_SECRET) rather than hardcoding them into source files.
  3. Secure your custom domains and backend servers with SSL/TLS certificates using high-grade DNS providers like Namecheap .
  4. Wrap all verification logic in constant-time functions (crypto.timingSafeEqual) to mitigate side-channel timing attacks.

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