n8n is great at moving data between systems fast. That also makes it great at moving attacker-controlled HTML and JavaScript straight into places you did not expect.

If you build internal tools, approval flows, chat integrations, or webhook-driven automations with n8n, you are already handling untrusted input. The problem starts when that input gets rendered in a browser, embedded into HTML emails, dropped into dashboard widgets, or passed into a frontend without output encoding.

A lot of teams think, “It’s just automation glue.” That mindset is how XSS lands in production.

Where XSS shows up in n8n

n8n itself is usually the transport layer. The XSS risk appears when workflow data is later rendered by something else.

Common paths:

  • Webhook input collected from users
  • Form submissions
  • Slack, Teams, Discord, or chat messages
  • Ticket titles and descriptions from Jira or Zendesk
  • CRM fields like name, company, notes
  • Markdown or HTML content from CMS tools
  • HTTP Request node responses from third-party APIs
  • Data passed into frontend apps from n8n webhooks
  • HTML emails generated from workflow expressions

Here’s a simple example. A webhook accepts a comment field:

{
  "name": "Eve",
  "comment": "<img src=x onerror=alert('xss')>"
}

That data moves through n8n, gets stored somewhere, then a dashboard renders it with innerHTML. n8n didn’t “execute” the payload, but the workflow absolutely enabled the bug.

The dangerous assumption: “n8n escaped it for me”

It usually didn’t. n8n workflows pass data around as strings, JSON, and expressions. If you inject untrusted data into HTML templates, email bodies, or frontend responses, you own the escaping and sanitization.

For example, this expression in an HTML template is risky:

<div>
  Customer comment: {{ $json.comment }}
</div>

If comment contains HTML, it will be inserted as-is unless the downstream renderer escapes it.

That may be fine if you later treat it as plain text. It is not fine if it ends up in:

  • innerHTML
  • a template engine rendering raw HTML
  • rich text email content
  • an admin panel preview
  • a custom frontend consuming webhook output

A realistic vulnerable workflow

Say you have this flow:

  1. Webhook receives support request
  2. Set node builds an HTML fragment
  3. Send Email node sends it to admins
  4. Same payload also gets stored and shown in an internal review UI

The vulnerable Set node might build this:

<h2>New support request</h2>
<p><strong>Name:</strong> {{ $json.name }}</p>
<p><strong>Message:</strong> {{ $json.message }}</p>

If message is:

<script>alert('owned')</script>

you now have script inside generated HTML. Some email clients strip it. Some internal preview pages won’t. If that content is later loaded into a browser-based admin tool, game over.

The first fix: context-aware output encoding

If data should be text, encode it before rendering.

In a Code node, you can escape HTML like this:

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

return items.map(item => {
  const name = escapeHtml(item.json.name);
  const message = escapeHtml(item.json.message);

  item.json.safeHtml = `
    <h2>New support request</h2>
    <p><strong>Name:</strong> ${name}</p>
    <p><strong>Message:</strong> ${message}</p>
  `;

  return item;
});
```text

Now attacker input renders as text, not markup.

This is the default approach I recommend for n8n workflows: if you do not explicitly need HTML, encode everything.

## When you actually need HTML

Sometimes you really do want formatting. Maybe users can submit rich text from a trusted editor. In that case, escaping everything breaks the feature. You need sanitization, not just encoding.

The rule is simple:

- **Encoding** for plain text output
- **Sanitization** for allowed HTML

A sanitizer should remove things like:

- `<script>`
- event handlers like `onclick`
- dangerous URLs like `javascript:alert(1)`
- risky tags such as `iframe`, `object`, `embed`
- inline CSS if you do not trust it

If you have a service layer between n8n and the browser, sanitize there with a proper HTML sanitizer library. That is usually the cleanest architecture.

If you must do something in a Code node, keep it conservative. Regex-based sanitization is weak, but a strict “strip almost everything” approach can reduce risk for simple cases:

```javascript
function naiveSanitizeHtml(input) {
  return String(input)
    .replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
    .replace(/\son\w+="[^"]*"/gi, '')
    .replace(/\son\w+='[^']*'/gi, '')
    .replace(/javascript:/gi, '')
    .replace(/<iframe[\s\S]*?>[\s\S]*?<\/iframe>/gi, '');
}

return items.map(item => {
  item.json.sanitizedMessage = naiveSanitizeHtml(item.json.message);
  return item;
});

I would not trust this for hostile, public input. It is better than nothing, but only barely. Real sanitization belongs in a proper library in the app that renders the content.

XSS via webhook responses

A very common n8n pattern is using a Webhook node to receive data and a Respond to Webhook node to send back HTML or JSON.

Here is the risky version:

<html>
  <body>
    <h1>Thanks, {{ $json.name }}</h1>
    <div>{{ $json.comment }}</div>
  </body>
</html>

If the response content type is HTML, the browser will parse it. Any unsanitized markup can execute.

Safer version: escape before response generation.

Code node:

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

return items.map(item => {
  item.json.nameSafe = escapeHtml(item.json.name);
  item.json.commentSafe = escapeHtml(item.json.comment);
  return item;
});
```text

HTML response:

Thanks, {{ $json.nameSafe }}

{{ $json.commentSafe }}
```text

Using <pre> or plain text containers helps reinforce the intent: this is text, not HTML.

JSON is not automatically safe either

Teams often return JSON from n8n and assume that means no XSS. Not true. JSON becomes dangerous when a frontend inserts values into the DOM unsafely.

n8n response:

{ } ` F ` f ` r ` e " ` o ` t . . } t t n j c t t ) i e t a h h h d ; t x e v ( e e o l t n a ' n n c e d s / ( ( u " c w r d m : b r e a e u i b = t n " g p h > a t < : t o . i o r = g m k . > e g j t p s { E s o o l r s n e c t ( m = / ) e x 1 ) n 2 t o 3 B n ' y e ) I r d r ( o ' r t = i a t l l e e r ' t ) ( . 1 i ) n > n " e r H T M L = d a t a . t i t l e ;

That is still XSS. The browser is where execution happens, but the workflow delivered the payload.

Safe frontend pattern:

fetch('/webhook/post/123')
  .then(r => r.json())
  .then(data => {
    document.getElementById('title').textContent = data.title;
  });
```text

If your n8n workflow feeds a frontend, document the trust boundary clearly: fields are untrusted unless explicitly sanitized.

## Email HTML deserves the same paranoia

n8n is often used to generate HTML emails. People get sloppy here because email clients behave inconsistently. Some strip scripts, some rewrite markup, some still allow nasty rendering tricks.

Unsafe email template:

New note from {{ $json.author }}

{{ $json.note }}
```text

Safer approach:

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

return items.map(item => {
  item.json.authorSafe = escapeHtml(item.json.author);
  item.json.noteSafe = escapeHtml(item.json.note);
  return item;
});

Then render:

<p>New note from {{ $json.authorSafe }}</p>
<div>{{ $json.noteSafe }}</div>

If you support rich text notes, sanitize them in the app layer before they ever reach the workflow.

Don’t forget stored XSS

The nastiest n8n-related XSS bugs are often stored XSS:

  1. Attacker submits payload through a webhook
  2. Workflow stores it in Airtable, Notion, Postgres, or a CRM
  3. Internal admin page later displays it
  4. Admin session gets popped

That is why “internal only” is not a defense. Internal tools are prime XSS targets because they often have privileged sessions and weaker frontend hygiene.

Add CSP where browsers render workflow output

Content Security Policy will not fix unsafe HTML rendering, but it can reduce blast radius when something slips through. If your n8n workflow feeds a custom web app or webhook response rendered in a browser, deploy CSP there.

A decent starting point:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

If you need help designing a stricter policy, the implementation guidance at https://csp-guide.com is useful. For platform-specific behavior, check official docs for your server or framework.

Practical rules I use for n8n

  1. Treat every field from a webhook, API, chat app, or database as untrusted.
  2. If content should be text, HTML-encode it before building HTML.
  3. If content should allow markup, sanitize it with a real sanitizer outside n8n.
  4. Never assume JSON output is safe once a frontend touches it.
  5. Avoid building HTML in workflows unless you have to.
  6. Use textContent, not innerHTML, in consuming frontends.
  7. Add CSP on any browser-facing app that renders workflow data.
  8. Review stored data paths, not just immediate responses.

A safer pattern for n8n architecture

The clean setup looks like this:

  • n8n collects and transforms raw data
  • n8n stores raw input and maybe a plain-text version
  • your application layer sanitizes or encodes based on render context
  • frontend uses safe DOM APIs
  • CSP backs up the browser side

That split keeps the workflow simple and puts rendering security where it belongs.

n8n is not uniquely bad here. It just makes data move fast, and fast-moving untrusted data becomes XSS fast too. If your workflow ever turns user input into HTML, email markup, or browser-rendered responses, assume an attacker will test it. Build the escaping and sanitization now, before they do.