Tips > Ops & Security

Implement Webhook HMAC Signature Verification

Major platforms (Stripe, GitHub, Shopify, Twilio) sign webhook payloads with HMAC-SHA256 using a shared secret.

TipIntermediate3 min read

Major platforms (Stripe, GitHub, Shopify, Twilio) sign webhook payloads with HMAC-SHA256 using a shared secret. Verifying this signature in your workflow proves the request genuinely came from the platform and has not been tampered with. Without verification, anyone who discovers your webhook URL can send forged events.

Real-world example: A Stripe webhook processes subscription cancellations by deactivating user accounts. An attacker discovers the webhook URL and sends forged customer.subscription.deleted events, deactivating legitimate customers.

// Code node: Verify Stripe webhook signature
// Mode: Run Once for All Items
// Place this as the FIRST node after the Webhook node

const crypto = require('crypto');

const webhookSecret = 'whsec_your_stripe_webhook_secret'; // Store in credential
const payload = $input.first().json.rawBody; // Raw body string
const signatureHeader = $input.first().json.headers['stripe-signature'];

function verifyStripeSignature(payload, header, secret) {
  const elements = header.split(',');
  const details = {};

  for (const element of elements) {
    const [key, value] = element.split('=');
    details[key] = value;
  }

  const timestamp = details['t'];
  const signature = details['v1'];

  if (!timestamp || !signature) {
    return { valid: false, error: 'Missing timestamp or signature' };
  }

  // Reject events older than 5 minutes (replay attack protection)
  const ageSeconds = Math.floor(Date.now() / 1000) - parseInt(timestamp);
  if (ageSeconds > 300) {
    return { valid: false, error: `Event too old: ${ageSeconds}s` };
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // Constant-time comparison to prevent timing attacks
  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature, 'utf8'),
    Buffer.from(expectedSignature, 'utf8')
  );

  return { valid: isValid, error: isValid ? null : 'Signature mismatch' };
}

const result = verifyStripeSignature(payload, signatureHeader, webhookSecret);

if (!result.valid) {
  // Return error -- workflow stops here for forged requests
  return [{
    json: {
      verified: false,
      error: result.error,
      action: 'rejected',
    }
  }];
}

// Signature valid -- pass the parsed event to downstream nodes
return [{
  json: {
    verified: true,
    event: JSON.parse(payload),
  }
}];
```text
> **Info: Accessing the Raw Body**
>
> Stripe HMAC verification requires the **raw** request body (before JSON parsing). In the Webhook node, set **Options > Raw Body** to `true` to receive the unparsed body string. The raw body is available as `$json.rawBody` or via `$input.first().json.rawBody` depending on your Webhook node version.

For GitHub webhooks, the signature format is different:

```javascript
// GitHub uses X-Hub-Signature-256 header with sha256=<hex> format
const crypto = require('crypto');

const secret = 'your_github_webhook_secret';
const payload = $input.first().json.rawBody;
const signature = $input.first().json.headers['x-hub-signature-256'];

const expected = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(payload, 'utf8')
  .digest('hex');

const isValid = crypto.timingSafeEqual(
  Buffer.from(signature, 'utf8'),
  Buffer.from(expected, 'utf8')
);

if (!isValid) {
  return [{ json: { error: 'Invalid GitHub signature', rejected: true } }];
}

return [{ json: { verified: true, event: JSON.parse(payload) } }];
```text
| Platform | Header Name | Algorithm | Format |
|:---------|:-----------|:----------|:-------|
| Stripe | `stripe-signature` | HMAC-SHA256 | `t=timestamp,v1=hex` |
| GitHub | `x-hub-signature-256` | HMAC-SHA256 | `sha256=hex` |
| Shopify | `x-shopify-hmac-sha256` | HMAC-SHA256 | Base64 |
| Twilio | `x-twilio-signature` | HMAC-SHA1 | Base64 |
| Slack | `x-slack-signature` | HMAC-SHA256 | `v0=hex` |

Always implement signature verification for production webhook endpoints that trigger sensitive actions.

**Related:** [Use Docker Compose with Health Checks for n8n and PostgreSQL](../self-hosting-operations/01-use-docker-compose-with-health-checks-for-n8n-and-postgresql.md) | [Use Path Parameters in Webhook URLs for Dynamic Routing](../webhook-mastery/01-use-path-parameters-in-webhook-urls-for-dynamic-routing.md)

Want this running in your stack?

I build production n8n and Cloudflare automation for teams — the same engineering behind HarperFlow. Fixed-price, escrow-protected, US-based.