DEV Community

Cover image for My First DevSecOps Automation in n8n: Route Security Alerts to Slack
Ilyas Rufai
Ilyas Rufai

Posted on

My First DevSecOps Automation in n8n: Route Security Alerts to Slack

I recently watched a 20-minute n8n intro video on the n8n official page and wanted to implement it in a real DevSecOps project. Then I remember one issue I've been seeing on a small team, over time: a CI scanner finds a secret, Dependabot opens a low-severity advisory, and by Friday, nobody knows which channel is the source of truth for security signals.

Meanwhile, security tools are only useful when findings reach humans fast, consistently, and at the right urgency. I did not need a full security orchestration platform on day one. I needed a small automation layer that normalizes alerts and routes them by severity.

In this article, I document how I built my first DevSecOps workflow in n8n: a security alert router that accepts a webhook payload, filters by severity, and posts to the correct Slack channel. I tested it with curl first and left a clear path to swap the webhook source for GitHub Actions or a scanner later.

Who might find this useful: Engineers new to n8n who want a practical DevSecOps starter project with a clear path to more advanced automations.

What I built: A six-node workflow; Webhook → Normalize → IF (severity) → Slack (urgent or log) → Respond.

What I used:

  • Docker Compose to run n8n locally
  • A Slack workspace with a bot and two channels (#security-alerts, #security-log)
  • Sample JSON payloads and curl for testing

Related work: I hardened the repo side first with Secret Scanning in CI: Three Layers with Gitleaks. This n8n workflow is the notification layer on top.

TL;DR

  • I defined a standard JSON payload for security findings so any tool can POST to one webhook.
  • I used n8n to normalize fields, then route HIGH/CRITICAL to #security-alerts and everything else to #security-log.
  • I ran n8n locally with Docker on port 5678 and tested with sample JSON before touching real CI webhooks.
  • I exported the workflow JSON, attached Slack credentials, activated it, and verified with two curl calls.
  • My next step: replace the test webhook with GitHub repository_vulnerability_alert events or a Gitleaks CI callback.

Why I did not stop at "check the tool dashboard"

Approach What works What breaks
Each engineer watches their own scanner UI Fine for solo projects Findings get missed during on-call handoffs
Email alerts from every tool Central inbox Alert fatigue; no severity routing
One Slack channel for everything Simple setup LOW noise hides CRITICAL incidents
Custom script per integration Full control Unmaintained scripts; no visual debugging

Key idea: A thin automation layer with one inbound contract and severity-based routing gives observability without building a full SOAR (security orchestration, automation, and response) platform in week one.

What I built

Security tool / curl test
        |
        | POST JSON (webhook)
        v
   n8n: Normalize Finding
        |
        v
   n8n: HIGH or CRITICAL?
      /        \
     v          v
Slack Urgent   Slack Log
      \        /
       v      v
   Respond 200 OK
Enter fullscreen mode Exit fullscreen mode

Security alert router workflow architecture in n8n

Standard finding payload

I picked one JSON shape every source should send Gitleaks, Dependabot, Trivy, or a manual curl test:

{
  "source": "gitleaks",
  "repo": "acme/webapp",
  "severity": "HIGH",
  "title": "AWS Access Key detected",
  "detail": "file: src/config.py line 42",
  "url": "https://github.com/acme/webapp/actions/runs/123456"
}
Enter fullscreen mode Exit fullscreen mode
Field Purpose
source Which tool raised the finding
repo Repository or service name
severity CRITICAL, HIGH, MEDIUM, LOW, or INFO
title Short human-readable summary
detail Extra context (file path, CVE, rule ID)
url Link to PR, workflow run, or ticket

Step 1 — I ran n8n locally with Docker

cd articles/assets/n8n-security-alert-router-slack
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

I opened http://localhost:5678 and created my owner account on first launch.

n8n welcome

Verification: docker compose ps shows the n8n container running and port 5678 mapped.

Step 2 — I created a Slack app and bot token

  1. api.slack.com/appsCreate New AppFrom scratch
  2. Under OAuth & Permissions, I added bot scopes: chat:write, chat:write.public
  3. Install to Workspace → copied the Bot User OAuth Token (xoxb-...)
  4. Created channels #security-alerts and #security-log
  5. Invited the bot: /invite @YourBotName in each channel

In n8n: CredentialsNewSlack API → pasted the bot token.

Step 3 — I built the workflow

I started by importing the JSON below, then tweaked channel names and expressions until executions went green.

Option A: Import my workflow (fastest)

  1. In n8n, WorkflowsImport from File
  2. Copy this and save it into a file on your pc; example.json
{
  "name": "Security Alert Router",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "security-finding",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "a1b2c3d4-0001-4000-8000-000000000001",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [0, 300],
      "webhookId": "security-finding-webhook"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "f1",
              "name": "source",
              "value": "={{ $json.body.source ?? $json.source }}",
              "type": "string"
            },
            {
              "id": "f2",
              "name": "repo",
              "value": "={{ $json.body.repo ?? $json.repo }}",
              "type": "string"
            },
            {
              "id": "f3",
              "name": "severity",
              "value": "={{ ($json.body.severity ?? $json.severity ?? 'UNKNOWN').toUpperCase() }}",
              "type": "string"
            },
            {
              "id": "f4",
              "name": "title",
              "value": "={{ $json.body.title ?? $json.title }}",
              "type": "string"
            },
            {
              "id": "f5",
              "name": "detail",
              "value": "={{ $json.body.detail ?? $json.detail ?? 'No detail provided' }}",
              "type": "string"
            },
            {
              "id": "f6",
              "name": "url",
              "value": "={{ $json.body.url ?? $json.url ?? '' }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "a1b2c3d4-0002-4000-8000-000000000002",
      "name": "Normalize Finding",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [240, 300]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.severity }}",
              "rightValue": "HIGH",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            },
            {
              "id": "c2",
              "leftValue": "={{ $json.severity }}",
              "rightValue": "CRITICAL",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "or"
        },
        "options": {}
      },
      "id": "a1b2c3d4-0003-4000-8000-000000000003",
      "name": "High or Critical?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [480, 300]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#security-alerts"
        },
        "text": "=:rotating_light: *{{ $json.severity }}* finding from *{{ $json.source }}*\n*Repo:* {{ $json.repo }}\n*Title:* {{ $json.title }}\n*Detail:* {{ $json.detail }}\n*Link:* {{ $json.url }}",
        "otherOptions": {}
      },
      "id": "a1b2c3d4-0004-4000-8000-000000000004",
      "name": "Slack Urgent",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [760, 180],
      "credentials": {
        "slackApi": {
          "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
          "name": "Slack account"
        }
      }
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#security-log"
        },
        "text": "=:information_source: *{{ $json.severity }}* finding logged from *{{ $json.source }}*\n*Repo:* {{ $json.repo }}\n*Title:* {{ $json.title }}\n*Link:* {{ $json.url }}",
        "otherOptions": {}
      },
      "id": "a1b2c3d4-0005-4000-8000-000000000005",
      "name": "Slack Log",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [760, 420],
      "credentials": {
        "slackApi": {
          "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
          "name": "Slack account"
        }
      }
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={\n  \"status\": \"accepted\",\n  \"severity\": \"{{ $('Normalize Finding').item.json.severity }}\",\n  \"routed\": \"{{ $('High or Critical?').item.json.severity === 'HIGH' || $('High or Critical?').item.json.severity === 'CRITICAL' ? 'urgent' : 'log' }}\"\n}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "a1b2c3d4-0006-4000-8000-000000000006",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [1040, 300]
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Normalize Finding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Finding": {
      "main": [
        [
          {
            "node": "High or Critical?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "High or Critical?": {
      "main": [
        [
          {
            "node": "Slack Urgent",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack Urgent": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack Log": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "staticData": null,
  "tags": [],
  "triggerCount": 1,
  "meta": {
    "templateCredsSetupCompleted": false
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Open each Slack node and attach the Slack credential
  2. Confirm channel names match the workspace (#security-alerts, #security-log)

Option B: Build node by node (how I learned n8n)

Node 1: Webhook

  • HTTP Method: POST
  • Path: security-finding
  • Respond: Using Respond to Webhook node

I copied the Test URL, n8n shows (production URL after activation).

webhook-node-config

Node 2: Normalize Finding (Set node)

I mapped incoming body fields to a flat structure:

Field Expression
source {{ $json.body.source ?? $json.source }}
repo {{ $json.body.repo ?? $json.repo }}
severity {{ ($json.body.severity ?? $json.severity ?? 'UNKNOWN').toUpperCase() }}
title {{ $json.body.title ?? $json.title }}
detail {{ $json.body.detail ?? $json.detail ?? 'No detail provided' }}
url {{ $json.body.url ?? $json.url ?? '' }}

The ?? fallbacks let me test with raw JSON (curl) or wrapped webhook bodies.

set-normalize-node

Node 3: High or Critical? (IF node)

  • Combinator: OR
  • Condition 1: {{ $json.severity }} equals HIGH
  • Condition 2: {{ $json.severity }} equals CRITICAL

True branch → urgent Slack. False branch → log Slack.

if-severity-node

Node 4a: Slack Urgent (#security-alerts)

:rotating_light: *{{ $json.severity }}* finding from *{{ $json.source }}*
*Repo:* {{ $json.repo }}
*Title:* {{ $json.title }}
*Detail:* {{ $json.detail }}
*Link:* {{ $json.url }}
Enter fullscreen mode Exit fullscreen mode

Node 4b: Slack Log (#security-log)

:information_source: *{{ $json.severity }}* finding logged from *{{ $json.source }}*
*Repo:* {{ $json.repo }}
*Title:* {{ $json.title }}
*Link:* {{ $json.url }}
Enter fullscreen mode Exit fullscreen mode

slack-urgent-message

Node 5: Respond to Webhook

  • Respond With: JSON
  • Response Code: 200
  • Body:
{
  "status": "accepted",
  "severity": "{{ $('Normalize Finding').item.json.severity }}",
  "routed": "urgent-or-log"
}
Enter fullscreen mode Exit fullscreen mode

I wired both Slack nodes into this responder.

full-workflow-canvas

Step 4 — I activated and tested with curl

I toggled the workflow Active and used the Production Webhook URL.

HIGH test (expected: #security-alerts)

WEBHOOK_URL="http://localhost:5678/webhook/security-finding"

curl -sS -X POST "$WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d @articles/assets/n8n-security-alert-router-slack/samples/high-severity.json
Enter fullscreen mode Exit fullscreen mode

Expected HTTP response:

{"status":"accepted","severity":"HIGH","routed":"urgent"}
Enter fullscreen mode Exit fullscreen mode

LOW test (expected: #security-log)

curl -sS -X POST "$WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d @articles/assets/n8n-security-alert-router-slack/samples/low-severity.json
Enter fullscreen mode Exit fullscreen mode

slack-urgent-alert-received

slack-log-alert-received

n8n-low-execution-success

n8n-high-execution-success

Step 5 — I added a shared secret (before calling it production-ready)

I did not want a public webhook sitting open. I used Webhook Header Auth:

  1. Webhook → OptionsAuthenticationHeader Auth
  2. Header name: X-Security-Webhook-Token
  3. Value: generated with openssl rand -hex 32

Webhook-Token

CI sender pattern I plan to use:

curl -sS -X POST "$WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -H "X-Security-Webhook-Token: YOUR_TOKEN_HERE" \
  -d @finding.json
Enter fullscreen mode Exit fullscreen mode

Verification: A request without the header returns 401 or a workflow error; a request with the header succeeds.

request

Step 6 — What I plan to connect next

Once routing was proven, I sketched how to replace the curl test with a real sender.

GitHub Dependabot / code scanning

  1. Repo → SettingsWebhooksAdd webhook
  2. Payload URL: n8n production webhook URL (tunnel like ngrok for local dev)
  3. Content type: application/json
  4. Events: Dependabot alerts or Code scanning alerts
  5. Add a Code or Set node to map GitHub's payload into my six-field contract

GitHub's raw payload differs from my contract; the mapping step is intentional and is what makes the router reusable.

GitHub Actions after Gitleaks

At the end of a scan job, POST only on failure:

- name: Notify security router
  if: failure()
  run: |
    curl -sS -X POST "${{ secrets.N8N_SECURITY_WEBHOOK }}" \
      -H "Content-Type: application/json" \
      -H "X-Security-Webhook-Token: ${{ secrets.N8N_WEBHOOK_TOKEN }}" \
      -d '{
        "source": "gitleaks",
        "repo": "${{ github.repository }}",
        "severity": "HIGH",
        "title": "Gitleaks findings in CI",
        "detail": "Workflow ${{ github.workflow }} run ${{ github.run_id }}",
        "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'
Enter fullscreen mode Exit fullscreen mode

Webhook URL and token stay in GitHub Actions secrets, never in the workflow file.

How I verified it worked

My done checklist:

  1. HIGH test posted to #security-alerts within seconds.
  2. LOW test posted only to #security-log.
  3. n8n Executions showed green runs with the correct branch taken.
  4. Webhook returned 200 JSON with status: accepted.
  5. Unauthorized requests (no token) were rejected after I enabled header auth.
  6. I could explain the flow from the canvas screenshot alone.

When this breaks down

  1. Alert storms: One repo with hundreds of findings can flood Slack. I would add deduplication (n8n Remove Duplicates node) or aggregate daily digests before urgent routing.
  2. Payload drift: Each tool sends different JSON. Without normalization, the IF node silently misroutes. I would keep one mapping node per source or enforce the standard contract at the sender.
  3. Local webhooks: GitHub cannot reach localhost. I would use n8n Cloud, a VPS, or a tunnel for real integrations.
  4. Slack as SOAR: This notifies humans; it does not replace ticketing, on-call paging, or automated remediation. My follow-up would pair this with PagerDuty or Jira.
  5. Secrets in workflows: I export workflows without credentials; tokens live in n8n Credentials or a secret manager.

Frequently asked questions

Q: n8n Cloud or self-hosted?

I used self-hosted Docker for learning. n8n Cloud makes sense when I need managed webhooks without exposing a laptop via a tunnel.

Q: Can I use Microsoft Teams instead of Slack?

Yes. Swap the Slack nodes for the Microsoft Teams node. The normalization and IF logic stay the same.

Q: Do I need coding experience?

Not for this workflow. Expressions are one-liners. JSON comfort and patience reading execution logs matter more than writing code.

Q: Is this "real" DevSecOps?

Yes, this is detection-to-notification plumbing, a standard first layer in security automation maturity. The mature version adds deduplication, ownership mapping, and ticket creation.

What I learned building this

  • Normalize first, route second. I almost fought different payload shapes inside the IF node, that is how beginners quit n8n.
  • Two Slack channels beat one. Separating urgent from log traffic was the cheapest alert-tuning win I found.
  • curl is the best first integration. I proved the workflow before touching GitHub webhooks or scanner plugins.
  • A 20-minute intro video was enough to start. The rest was one small project, two test payloads, and reading execution logs when something failed.

References

Top comments (0)