{
  "name": "Verify HMAC webhook signatures and reject replays with Webhook and Code",
  "nodes": [
    {
      "parameters": {
        "content": "## Verify HMAC webhook signatures and reject replays\n\nChecks that an incoming webhook really came from the sender it claims, and answers 401 before doing any work.\n\n### Who's it for\nAnyone exposing an n8n webhook to the public internet and receiving from Stripe, GitHub, Shopify, Slack or any provider that signs its payloads. A public webhook URL with no signature check is an open endpoint.\n\n### How it works\n- **Receive the webhook** takes the request with the raw body preserved, because a signature is computed over the exact bytes sent, not over reserialised JSON.\n- **Verify the signature** recomputes the HMAC with your shared secret and compares it in constant time, so the comparison itself cannot leak the answer.\n- It also checks the timestamp against a five-minute window, which stops a captured request being replayed later.\n- **Signature valid?** routes good requests to **Do the real work** and everything else to a 401.\n- Rejections are logged with a reason, so a misconfigured sender is visible rather than silent.\n\n### Setup\n1. Open **Receive the webhook** and set your path. Leave raw body enabled.\n2. Open **Verify the signature** and set the header name your provider uses and the secret it signs with. Never paste the secret into the node. Use a credential or an environment variable.\n3. Replace **Do the real work** with what you actually want to happen.\n\n### Requirements\nA shared signing secret from the sending provider. No community nodes.\n\n### How to customize\nProviders differ on what they sign. Some sign the body alone, others a timestamp and body joined by a separator. Match the payload construction to your provider's docs.\n\nFull walkthrough: https://www.theagentecosystem.com/blog/n8n-webhook-security",
        "height": 1080,
        "width": 760,
        "color": 1
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -730
      ],
      "name": "Note: Overview"
    },
    {
      "parameters": {
        "content": "## 1. Receive with the raw body\nA signature is computed over the exact bytes sent, not over reserialised JSON.",
        "height": 260,
        "width": 720,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        570
      ],
      "name": "Note: Receive with the raw body"
    },
    {
      "parameters": {
        "content": "## 2. Accept, or reject loudly\nNothing expensive runs until signature and replay window both pass. A failure is logged with its reason, then answered 401.",
        "height": 460,
        "width": 480,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        680,
        470
      ],
      "name": "Note: Accept, or reject loudly"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "secure-hook",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "d1e2f3a4-0000-4000-8000-000000000003",
      "name": "Receive the webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        720
      ]
    },
    {
      "parameters": {
        "jsCode": "// Verify the sender's HMAC signature over the RAW request body.\n//\n// Requires NODE_FUNCTION_ALLOW_BUILTIN=crypto on the instance. Without it the\n// next line throws, and the error does not explain why.\nconst crypto = require('crypto');\n\n// ---- configure for your sender ----\nconst SIGNATURE_HEADER = 'x-hub-signature-256'; // GitHub. Stripe: stripe-signature. Slack: x-slack-signature.\nconst TIMESTAMP_HEADER = 'x-request-timestamp';\nconst ALGO = 'sha256';\nconst PREFIX = 'sha256='; // GitHub prefixes the digest. Set '' if yours does not.\nconst MAX_AGE_SECONDS = 300; // 0 disables the replay window\n// -----------------------------------\n\nconst secret = $env.WEBHOOK_SIGNING_SECRET; // needs N8N_BLOCK_ENV_ACCESS_IN_NODE=false\nconst item = $input.first();\nconst headers = item.json.headers || {};\n\nfunction fail(reason) {\n  return [{ json: { valid: false, reason } }];\n}\n\nif (!secret) return fail('WEBHOOK_SIGNING_SECRET is not set on this instance');\n\n// The raw bytes, exactly as they arrived. Never rebuild this from parsed JSON:\n// re-serializing can reorder keys or change escaping, and the digest changes\n// with it even though the payload is identical.\nlet raw;\nif (item.binary && item.binary.data && item.binary.data.data) {\n  raw = Buffer.from(item.binary.data.data, 'base64');\n} else if (typeof item.json.rawBody === 'string') {\n  raw = Buffer.from(item.json.rawBody, 'utf8');\n} else {\n  return fail('raw body not available — switch on the rawBody option in the Webhook node');\n}\n\nconst received = headers[SIGNATURE_HEADER];\nif (!received) return fail(`missing ${SIGNATURE_HEADER} header`);\n\n// Replay window. A valid signature is valid forever unless you bound it.\nif (MAX_AGE_SECONDS > 0) {\n  const ts = Number(headers[TIMESTAMP_HEADER]);\n  if (!ts) return fail(`missing or unparseable ${TIMESTAMP_HEADER} header`);\n  const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - ts);\n  if (ageSeconds > MAX_AGE_SECONDS) return fail(`request is ${ageSeconds}s old, limit is ${MAX_AGE_SECONDS}s`);\n}\n\n// SIGNED_PAYLOAD: what your sender actually signs. Most sign the raw body;\n// Slack signs `v0:timestamp:body`, Stripe signs `timestamp.body`. Signing the\n// wrong string looks identical to using the wrong secret.\nconst SIGNED_PAYLOAD = raw;\n\nconst expected = PREFIX + crypto.createHmac(ALGO, secret).update(SIGNED_PAYLOAD).digest('hex');\n\n// Constant-time compare. A plain === leaks how much of the signature matched\n// through response timing, which is enough to forge one given patience.\nconst a = Buffer.from(expected, 'utf8');\nconst b = Buffer.from(String(received), 'utf8');\nconst valid = a.length === b.length && crypto.timingSafeEqual(a, b);\n\nreturn [{ json: { valid, reason: valid ? 'signature ok' : 'signature mismatch' } }];"
      },
      "id": "d1e2f3a4-0000-4000-8000-000000000004",
      "name": "Verify the signature",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        720
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "d1e2f3a4-0000-4000-8000-000000000010",
              "leftValue": "={{ $json.valid }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "d1e2f3a4-0000-4000-8000-000000000005",
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        480,
        720
      ]
    },
    {
      "parameters": {
        "jsCode": "// Signature checked out. Put the real work here, or call a sub-workflow.\n//\n// Answer fast and do the heavy lifting elsewhere: senders like GitHub and\n// Stripe time out in seconds and will retry, and a slow handler turns one\n// event into several.\nreturn [{ json: { accepted: true, receivedAt: new Date().toISOString() } }];"
      },
      "id": "d1e2f3a4-0000-4000-8000-000000000006",
      "name": "Do the real work",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        720,
        620
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "d1e2f3a4-0000-4000-8000-000000000007",
      "name": "Accept (200)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        960,
        620
      ]
    },
    {
      "parameters": {
        "jsCode": "// Rejected. Log the reason on your side; never return it to the caller.\n// Telling an attacker whether they got the secret wrong, the timestamp wrong,\n// or the header missing is free reconnaissance.\nconsole.log('[webhook] rejected:', $json.reason);\nreturn [{ json: { rejected: true } }];"
      },
      "id": "d1e2f3a4-0000-4000-8000-000000000008",
      "name": "Log the rejection",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        720,
        820
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ error: 'unauthorized' }) }}",
        "options": {
          "responseCode": 401
        }
      },
      "id": "d1e2f3a4-0000-4000-8000-000000000009",
      "name": "Reject (401)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        960,
        820
      ]
    }
  ],
  "connections": {
    "Receive the webhook": {
      "main": [
        [
          {
            "node": "Verify the signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify the signature": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Do the real work",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log the rejection",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Do the real work": {
      "main": [
        [
          {
            "node": "Accept (200)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log the rejection": {
      "main": [
        [
          {
            "node": "Reject (401)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false,
  "tags": []
}
