Express.js Webhook Receiver with Signature Verification

Webhooks are a powerful mechanism for real-time communication between services. Instead of constantly polling an API for updates, you can configure a service to "push" data to your application whenever a specific event occurs. This event-driven architecture is efficient and responsive, powering everything from payment notifications (Stripe, PayPal) to code repository events (GitHub, GitLab) and content updates (CMS platforms).

However, with this convenience comes a critical security challenge: how do you verify that an incoming webhook request genuinely originated from the expected sender and hasn't been tampered with? Without proper verification, your application is vulnerable to spoofing, data corruption, and potentially malicious attacks.

This article will guide you through building a robust Express.js webhook receiver that incorporates signature verification. We'll cover the core concepts, demonstrate a practical implementation, highlight common pitfalls, and discuss how to handle real-world scenarios.

The Problem: Trusting Webhook Payloads

Imagine your application processes orders, and a payment gateway sends a webhook to confirm a successful transaction. If an attacker could forge such a webhook, they might trick your system into fulfilling an order that was never paid for. Similarly, if a malicious actor could intercept and alter the payload, they might change the transaction amount or customer details, leading to data integrity issues.

A simple API key in the URL (https://your-app.com/webhook?api_key=YOUR_SECRET) is insufficient. While it authenticates the request, it doesn't protect against payload tampering if the request is intercepted. Anyone with the API key can send arbitrary data. What you need is a mechanism that simultaneously verifies:

  • Authenticity: The request truly came from the expected sender.
  • Integrity: The payload has not been altered since it left the sender.

This is where signature verification comes in.

How Signature Verification Works

Signature verification relies on a shared secret key known only to your application and the webhook sender. Here's the general process:

  1. Sender Side:

    • The sender takes the raw request body (payload) and a timestamp (if applicable).
    • It combines this data with the shared secret key.
    • It computes a cryptographic hash (e.g., HMAC-SHA256) of this combined string.
    • The resulting hash, or "signature," is then sent along with the payload, usually in a dedicated HTTP header (e.g., X-Hub-Signature-256, Stripe-Signature).
  2. Receiver Side (Your Express.js App):

    • Your application receives the webhook request.
    • It extracts the signature from the HTTP header.
    • It takes the exact same raw request body that the sender used.
    • Using your copy of the same shared secret key, you compute your own cryptographic hash of the raw body (and timestamp, if applicable).
    • Finally, you compare your computed hash with the signature provided in the header. If they match, you can be confident that the request is authentic and the payload hasn't been tampered with.

The critical piece here is the shared secret. If an attacker doesn't know the secret, they cannot generate a valid signature for a forged or altered payload.

Setting Up Your Express.js Webhook Receiver

Before we dive into signature verification, you need a basic Express.js server that can receive POST requests. The most crucial detail for webhooks is accessing the raw request body.

By default, Express.js middleware like express.json() or express.urlencoded() parses the incoming body and replaces the raw buffer with a JavaScript object. However, signature verification requires the original, raw body buffer to compute the hash correctly. Any slight modification, even whitespace changes, will result in a different hash.

To get around this, you can configure express.json() to also make the raw body available, or use express.raw().

Here's how to set up your Express app:

const express = require('express');
const crypto = require('crypto'); // Node.js built-in crypto module
const app = express();
const port = 3000;

// IMPORTANT: Store your webhook secret securely, e.g., in environment variables.
// For demonstration, we'll hardcode it. NEVER do this in production.
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'super_secret_key_123';

// Middleware to parse JSON bodies and make the raw body available
// This is crucial for signature verification.
app.use(express.json({
    verify: (req, res, buf) => {
        req.rawBody = buf; // Store the raw body buffer on the request object
    }
}));

// If you expect other body types, you might need additional parsers,
// but ensure they also preserve the raw body or use a specific route
// for webhook processing with 'express.raw()'
// app.use(express.raw({ type: 'application/json', verify: (req, res, buf) => { req.rawBody = buf; } }));

app.post('/webhook', (req, res) => {
    // We'll add verification logic here
    console.log('Received webhook payload:', req.body);
    res.status(200).send('Webhook received successfully!');
});

app.listen(port, () => {
    console.log(`Webhook receiver listening at http://localhost:${port}`);
});

With req.rawBody available, you're ready for verification.

Implementing Signature Verification in Express.js

Let's integrate the signature verification logic into our /webhook endpoint. We'll implement a generic verification function that you can adapt to different services.

Many services, like Stripe and GitHub, use HMAC-SHA256 for their signatures. The exact format of the signature header and the string used to compute the hash can vary.

Example 1: Generic HMAC-SHA256 Verification

First, a utility function to verify the signature:

// Utility function for signature verification
function verifySignature(rawBody, signatureHeader, secret) {
    if (!rawBody || !signatureHeader || !secret) {
        console.error('Missing rawBody, signatureHeader, or secret for verification.');
        return false;
    }

    // Common pattern: signature is prefixed, e.g., "sha256="
    // You might need to parse this depending on the service.
    // For a generic example, let's assume the header directly contains the hex hash.
    // E.g., GitHub's X-Hub-Signature-256 is "sha256=HEX_DIGEST"
    let expectedSignature = signatureHeader;
    if (signatureHeader.startsWith('sha256=')) {
        expectedSignature = signatureHeader.substring(7); // Remove "sha256="
    }
    // For other services, you might need different parsing.

    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(rawBody);
    const computedSignature = hmac.digest('hex');

    // Use timingSafeEqual to prevent timing attacks
    // This function is crucial for comparing cryptographic signatures securely.
    return crypto.timingSafeEqual(Buffer.from(computedSignature, 'hex'), Buffer.from(expectedSignature, 'hex'));
}

Now, integrate this into your Express route:

```javascript // ... (previous setup code) ...

app.post('/webhook', (req, res) => { const signatureHeader = req.headers['x-hub-signature-256'] || req.headers['stripe-signature']; // Adjust header name as per service

if (!signatureHeader) {
    console.warn('Webhook received without signature header.');
    return res.status(401).send('Unauthorized: Signature missing.');
}

try {
    const isVerified = verifySignature(req.rawBody, signatureHeader, WEBHOOK_SECRET);

    if (!isVerified) {
        console.warn('Signature verification failed for incoming webhook.');
        return res.status(401).send('Unauthorized: Invalid signature.');
    }

    console.log('Webhook signature verified successfully!');
    console.log('Payload:', req.body);
    // Process the verified webhook payload here
    res.status(200).send('Webhook received and verified!');