RAKSUL TechBlog

RAKSULグループのエンジニアが技術トピックを発信するブログです

Slack App -> n8n のWebhookリクエストを署名検証する

本記事はラクスル Advent Calendar 2025 15 日目の記事です。

TL;DR

  • Slack から n8n ワークフローを呼び出すケースが増えている
  • n8n ワークフローの URL はグローバルに公開されるため、セキュリティの観点からリクエスト元の検証を行う必要がある
  • 本記事では、Slack からのリクエストを署名検証する実装について紹介する

背景

Slack -> n8n 連携における署名検証について

ラクスルの AI オペレーショングループに所属している Junmori と申します。 ラクスルでは全社的に AI 活用の推進を進めており、AI オペレーショングループは各部署が AI を活用するためのサポート・ナレッジシェアの役割を担っています。

AI 活用・自動化の文脈において、最近はワークフロー構築ツールである n8n*1を利用することが多いです。 特に、普段利用しているコミュニケーションツールである Slack を経由して n8n ワークフローを呼び出すというケースが社内では増えています。

これは便利である一方、その n8n ワークフローの webhook URL を知っている者は誰でもリクエストを送れてしまうことから、セキュリティ上のリスクが存在します。

幸いなことに、Slack からのリクエストを署名検証する方法についてのドキュメントが公式から公開されています。

docs.slack.dev

AI オペレーショングループでは、上記ドキュメントを参考にして Slack App からのリクエストを署名検証する共通ワークフローを実装しました。 本記事では、その実装およびポイントを紹介します。

Q. n8nが標準で提供している Slack Trigger node*2じゃだめなの?

A. Slack Trigger Node でやりたいことが実現できる場合、そちらで問題ないです。

n8n は Slack Trigger node というノードを標準で提供しています。 この Slack Trigger node では、 Slack App へのメンションやチャンネルへのメッセージ投稿などをトリガーにしてワークフローを起動することが出来ます。

こちらのノードを利用した場合、内部で自動的に署名検証が行われるように実装されている*3ため、本記事で言及しているセキュリティリスクは解決されます。

n8nのSlack Trigger Node

ただし、私が調べた限りでは Slack Trigger node はスラッシュコマンドには対応していませんでした。 今回我々はスラッシュコマンドでの n8n ワークフロー呼び出しを行いたかった*4ため、本記事の実装が必要になったという経緯になります。

そのため、スラッシュコマンドではなく App Mention などで事足りる場合は、 Slack Trigger Node を使用するのが良いと思われます。

実装

今回実装を行ったワークフローの全体像を以下のキャプチャに示します。

Slack Appからのリクエストを検証するn8nワークフロー

n8n 上で再利用出来るように、JSON コードも以下に添付します。

n8nワークフローのJSON(クリックして展開) CryptノードのSecretについてはご利用のSlack AppのSigning Secretを設定してください。

{
  "nodes": [
    {
      "parameters": {},
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1152,
        656
      ],
      "id": "c88095cc-9ba2-4acc-b154-de858bf04586",
      "name": "ワークフロー本体を記述"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "{\"error\": \"Unauthorized\"}",
        "options": {
          "responseCode": 401
        }
      },
      "id": "c0230444-3a31-4ee0-95f8-49976e82c872",
      "name": "エラー応答",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        976,
        848
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "=v0:{{ $json.headers[\"x-slack-request-timestamp\"] }}:{{ $json.raw_body }}",
        "dataPropertyName": "calculated_hmac",
        "secret": "=SET_YOUR_SIGNING_SECRET_HERE"
      },
      "id": "2cb8d54f-537c-4464-8e5f-56af610f4faa",
      "name": "署名計算",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        336,
        768
      ],
      "notesInFlow": true,
      "notes": "ここにSigning Secretを設定してください"
    },
    {
      "parameters": {
        "jsCode": "// 検証とパースを実行\nfor (const item of items) {\n  try {\n    const headers = item.json.headers;\n    const timestamp = headers['x-slack-request-timestamp'];\n    const slackSignature = headers['x-slack-signature'];\n    \n    const myHmac = item.json.calculated_hmac; \n    const mySignature = `v0=${myHmac}`;\n\n    // 1. タイムスタンプ検証\n    const fiveMinutesAgo = Math.floor(Date.now() / 1000) - (60 * 5);\n    if (timestamp < fiveMinutesAgo) {\n      throw new Error('Request is too old.');\n    }\n\n    // 2. 署名の比較\n    if (slackSignature !== mySignature) {\n      throw new Error('Signature verification failed.');\n    }\n\n    // 3. Bodyのパース (URLSearchParamsを使わない手動パース)\n    const parsedBody = {};\n    const rawBody = item.json.raw_body || \"\";\n    \n    // '&' で分割してループ処理\n    rawBody.split('&').forEach(pair => {\n      const [key, value] = pair.split('=');\n      if (key) {\n        // '+' をスペースに置換してからデコード\n        const decodedKey = decodeURIComponent(key.replace(/\\+/g, ' '));\n        const decodedValue = value ? decodeURIComponent(value.replace(/\\+/g, ' ')) : '';\n        parsedBody[decodedKey] = decodedValue;\n      }\n    });\n\n    item.json = {\n      payload: parsedBody,\n      verified: true\n    };\n\n  } catch (error) {\n    item.json = {\n      error: error.message,\n      verified: false\n    };\n  }\n}\n\nreturn items;"
      },
      "id": "f0a878ea-80ac-4d60-a351-ed4485097112",
      "name": "署名検証",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        560,
        768
      ]
    },
    {
      "parameters": {
        "jsCode": "// バイナリデータを文字列(UTF-8)に変換してJSONプロパティに追加\nfor (const item of items) {\n  if (item.binary && item.binary.data) {\n    // n8nのバイナリデータはBase64なのでUTF-8に戻す\n    const rawBody = Buffer.from(item.binary.data.data, 'base64').toString('utf8');\n    item.json.raw_body = rawBody;\n  }\n}\n\nreturn items;"
      },
      "id": "b09b5aec-0f96-4131-a896-bd81434edb2d",
      "name": "リクエストボディを抽出",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        128,
        768
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "3a9aa929-3b98-400d-9fba-620b54f36c06",
              "leftValue": "={{ $json.verified }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        768,
        768
      ],
      "id": "85e76ab2-8c50-4560-ab21-294e90ab7289",
      "name": "正当な署名か?"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "{\n  \"message\": \"verification succeeded\"\n}",
        "options": {
          "responseCode": 204
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.4,
      "position": [
        976,
        656
      ],
      "id": "3606dcaa-1b19-420b-a021-4b8cc56c2207",
      "name": "正常応答"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "slack-command-secure",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -64,
        768
      ],
      "id": "6e7aa23f-d999-421e-a916-4081803484ff",
      "name": "Webhook",
      "webhookId": ""
    }
  ],
  "connections": {
    "ワークフロー本体を記述": {
      "main": [
        []
      ]
    },
    "署名計算": {
      "main": [
        [
          {
            "node": "署名検証",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "署名検証": {
      "main": [
        [
          {
            "node": "正当な署名か?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "リクエストボディを抽出": {
      "main": [
        [
          {
            "node": "署名計算",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "正当な署名か?": {
      "main": [
        [
          {
            "node": "正常応答",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "エラー応答",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "正常応答": {
      "main": [
        [
          {
            "node": "ワークフロー本体を記述",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook": {
      "main": [
        [
          {
            "node": "リクエストボディを抽出",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "meta": {
    "instanceId": ""
  }
}

実装ロジックそのものは前述した Slack ドキュメントに準拠しています。

今回の n8n ワークフローのポイントは 2 点あるので、それらについて説明を行います。

1. Webhookノードでの raw body 設定

今回の署名検証は、SHA256 を利用した HMAC 認証を利用しています。 このときに計算するハッシュ値の入力には timestamprequest body を使用します*5

ここで、n8n の Webhookノードにおいて Raw Bodyオプションを有効にしておく必要があります。

n8n は親切設計になっているため、Slack から送信されたデータが自動でパース・JSON 化されます。 一度 JSON になったパラメータから再度 request bodyを構築するのは面倒です。 そのため今回は Webhookノードの出力をバイナリにし、後続の Codeノードで UTF-8 文字列に変換するという実装を採用しています。

(改めて見てみるとどっちもどっちか...?)

2. 「署名計算」 Crypt ノードの使用

今回のワークフローでは、 Webhookトリガーノードに続いて以下のノードが連なっています。

  1. Codeノード(「リクエストボディを抽出」)
  2. Cryptノード(署名計算)
  3. Codeノード(タイムスタンプ確認・署名検証)

本来これらは単一の Code ノードでまとめて実装したいところでした。 しかしながら、n8n の制限により JavaScript のcryptoモジュールは利用ができなかったため、HMAC 計算は n8n の Cryptoノードを利用して行う実装になっています。

n8nのエラーメッセージ: Module 'crypto' is disallowed [line 1]

まとめ

本記事では、Slack からスラッシュコマンド経由で n8n ワークフローを呼び出す際の、署名検証について実装・解説を行いました。 本記事の内容が参考になれば幸いです(自分が実装したときには先行事例が見つからなかったので...)。

もし他に良い方法をご存じの方はコメントなどでごご教示いただけますと幸いです。

*1:https://n8n.io/

*2:https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.slacktrigger/

*3:https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.slacktrigger/#verify-the-webhook

*4:主な理由として、「スラッシュコマンドを使わないと n8n ワークフロー1 個につき Slack App 1 個が必要になり、Slack App が乱立・管理不全になるというのを避けたかった」というものがあります

*5:https://docs.slack.dev/authentication/verifying-requests-from-slack/#validating-a-request