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
curlfor 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-alertsand everything else to#security-log. - I ran n8n locally with Docker on port
5678and tested with sample JSON before touching real CI webhooks. - I exported the workflow JSON, attached Slack credentials, activated it, and verified with two
curlcalls. - My next step: replace the test webhook with GitHub
repository_vulnerability_alertevents 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
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"
}
| 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
I opened http://localhost:5678 and created my owner account on first launch.
Verification: docker compose ps shows the n8n container running and port 5678 mapped.
Step 2 — I created a Slack app and bot token
- api.slack.com/apps → Create New App → From scratch
- Under OAuth & Permissions, I added bot scopes:
chat:write,chat:write.public -
Install to Workspace → copied the Bot User OAuth Token (
xoxb-...) - Created channels
#security-alertsand#security-log - Invited the bot:
/invite @YourBotNamein each channel
In n8n: Credentials → New → Slack 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)
- In n8n, Workflows → Import from File
- 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
}
}
- Open each Slack node and attach the Slack credential
- 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).
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.
Node 3: High or Critical? (IF node)
- Combinator: OR
-
Condition 1:
{{ $json.severity }}equalsHIGH -
Condition 2:
{{ $json.severity }}equalsCRITICAL
True branch → urgent Slack. False branch → log Slack.
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 }}
Node 4b: Slack Log (#security-log)
:information_source: *{{ $json.severity }}* finding logged from *{{ $json.source }}*
*Repo:* {{ $json.repo }}
*Title:* {{ $json.title }}
*Link:* {{ $json.url }}
Node 5: Respond to Webhook
- Respond With: JSON
-
Response Code:
200 - Body:
{
"status": "accepted",
"severity": "{{ $('Normalize Finding').item.json.severity }}",
"routed": "urgent-or-log"
}
I wired both Slack nodes into this responder.
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
Expected HTTP response:
{"status":"accepted","severity":"HIGH","routed":"urgent"}
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
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:
- Webhook → Options → Authentication → Header Auth
- Header name:
X-Security-Webhook-Token - Value: generated with
openssl rand -hex 32
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
Verification: A request without the header returns 401 or a workflow error; a request with the header succeeds.
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
- Repo → Settings → Webhooks → Add webhook
- Payload URL: n8n production webhook URL (tunnel like ngrok for local dev)
- Content type:
application/json - Events: Dependabot alerts or Code scanning alerts
- 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 }}"
}'
Webhook URL and token stay in GitHub Actions secrets, never in the workflow file.
How I verified it worked
My done checklist:
-
HIGH test posted to
#security-alertswithin seconds. -
LOW test posted only to
#security-log. - n8n Executions showed green runs with the correct branch taken.
- Webhook returned
200JSON withstatus: accepted. - Unauthorized requests (no token) were rejected after I enabled header auth.
- I could explain the flow from the canvas screenshot alone.
When this breaks down
- 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.
- 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.
-
Local webhooks: GitHub cannot reach
localhost. I would use n8n Cloud, a VPS, or a tunnel for real integrations. - 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.
- 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.













Top comments (0)