Netlify Forms are convenient, but they create a classic security trap: teams treat form submissions like harmless content, then render them in dashboards, emails, thank-you pages, or admin tools without thinking about XSS.

That’s where things go sideways.

Netlify handles submission collection. It does not magically make user input safe to render as HTML. If someone submits <img src=x onerror=alert(1)>, that payload is still just attacker-controlled input. The XSS happens later, when your code inserts that input into the DOM unsafely.

This guide is the practical version: where XSS shows up with Netlify Forms, what safe code looks like, and what to paste into real projects.

The core rule

Treat every Netlify form field as untrusted input.

That includes:

  • name
  • email
  • message
  • hidden fields
  • query params copied into forms
  • form submissions fetched from APIs or webhooks
  • data shown in CMS previews or internal admin panels

Netlify Forms don’t execute scripts by themselves. Your frontend or backend rendering logic does.

Where XSS usually happens

I keep seeing the same mistakes:

  1. Rendering form fields with innerHTML
  2. Building email templates with raw HTML from submissions
  3. Injecting submission data into thank-you pages
  4. Displaying submissions in custom dashboards
  5. Using markdown or rich text rendering on user input without sanitization

Here’s the dangerous pattern:

<div id="submission-preview"></div>

<script>
  const submission = {
    name: '<img src=x onerror=alert("xss")>',
    message: '<script>alert("owned")</script>'
  };

  document.getElementById('submission-preview').innerHTML = `
    <h2>${submission.name}</h2>
    <p>${submission.message}</p>
  `;
</script>

If that data came from a Netlify Form submission, you just created XSS.

Safe rendering: use text, not HTML

If you only need to display user input as text, use textContent.

<div>
  <h2 id="name"></h2>
  <p id="message"></p>
</div>

<script>
  const submission = {
    name: '<img src=x onerror=alert("xss")>',
    message: '<script>alert("owned")</script>'
  };

  document.getElementById('name').textContent = submission.name;
  document.getElementById('message').textContent = submission.message;
</script>

That turns payloads into harmless text on the page.

If you’re creating elements dynamically, do it explicitly:

const submission = {
  name: '<svg onload=alert(1)>',
  message: 'Hello <b>world</b>'
};

const container = document.getElementById('submission-preview');

const title = document.createElement('h2');
title.textContent = submission.name;

const body = document.createElement('p');
body.textContent = submission.message;

container.replaceChildren(title, body);

This is boring code. Good. Boring code blocks XSS.

A safe Netlify form

Here’s a standard Netlify form with basic client-side validation. Validation helps with quality, not trust. You still must encode output later.

<form name="contact" method="POST" data-netlify="true" netlify-honeypot="bot-field">
  <input type="hidden" name="form-name" value="contact">
  <p hidden>
    <label>
      Don’t fill this out:
      <input name="bot-field">
    </label>
  </p>

  <label for="name">Name</label>
  <input id="name" name="name" type="text" maxlength="100" required>

  <label for="email">Email</label>
  <input id="email" name="email" type="email" maxlength="254" required>

  <label for="message">Message</label>
  <textarea id="message" name="message" maxlength="5000" required></textarea>

  <button type="submit">Send</button>
</form>

Client-side cleanup can reduce junk:

<script>
  const form = document.querySelector('form[name="contact"]');

  form.addEventListener('submit', (event) => {
    const name = form.elements.name;
    const email = form.elements.email;
    const message = form.elements.message;

    name.value = name.value.trim();
    email.value = email.value.trim().toLowerCase();
    message.value = message.value.trim();

    if (name.value.length === 0 || message.value.length === 0) {
      event.preventDefault();
      alert('Please fill out all required fields.');
    }
  });
</script>

That’s useful UX. It is not XSS protection.

Thank-you pages: a common footgun

A lot of sites redirect users to a thank-you page and echo back their name from the query string. That’s a classic DOM XSS bug.

Bad:

<h1 id="thanks"></h1>

<script>
  const params = new URLSearchParams(location.search);
  const name = params.get('name') || 'friend';

  document.getElementById('thanks').innerHTML = `Thanks, ${name}!`;
</script>

Safe:

<h1 id="thanks"></h1>

<script>
  const params = new URLSearchParams(location.search);
  const name = params.get('name') || 'friend';

  document.getElementById('thanks').textContent = `Thanks, ${name}!`;
</script>

If your Netlify form redirects with user-supplied values in the URL, assume they are hostile.

Rendering submissions from a function or API

If you fetch form submissions in a Netlify Function or an internal dashboard, keep the API response as data and escape on output.

Bad server-generated HTML:

export default async (req, res) => {
  const submission = {
    name: '<img src=x onerror=alert(1)>',
    message: '<script>alert(1)</script>'
  };

  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.end(`
    <h1>${submission.name}</h1>
    <div>${submission.message}</div>
  `);
};

Safer approach: return JSON.

export default async (req, res) => {
  const submission = {
    name: '<img src=x onerror=alert(1)>',
    message: '<script>alert(1)</script>'
  };

  res.setHeader('Content-Type', 'application/json; charset=utf-8');
  res.end(JSON.stringify(submission));
};

Then render safely on the client:

<div>
  <h2 id="submission-name"></h2>
  <pre id="submission-message"></pre>
</div>

<script>
  async function loadSubmission() {
    const response = await fetch('/.netlify/functions/submission');
    const submission = await response.json();

    document.getElementById('submission-name').textContent = submission.name;
    document.getElementById('submission-message').textContent = submission.message;
  }

  loadSubmission();
</script>

If you absolutely must allow HTML

My advice: don’t allow HTML in Netlify Forms unless you really need it.

If you’re building a support system or CMS-like workflow and want basic formatting, sanitize before rendering. Do not try to invent your own regex filter. I’ve seen people remove <script> and think they’re done. They are not done.

Bad fake sanitizer:

function sanitizeHtml(input) {
  return input.replace(/<script.*?>.*?<\/script>/gi, '');
}

That misses event handlers, SVG payloads, malformed tags, javascript: URLs, and a pile of browser quirks.

Use a real HTML sanitizer with a strict allowlist, then back it up with CSP. For CSP implementation patterns, https://csp-guide.com is a solid reference. Official browser and platform docs are also worth keeping open while testing.

Content Security Policy for damage control

CSP won’t fix unsafe rendering, but it can turn a full exploit into a blocked payload. I treat it as backup brakes, not the steering wheel.

A reasonable starting policy for a static site with forms:

<meta
  http-equiv="Content-Security-Policy"
  content="
    default-src 'self';
    script-src 'self';
    object-src 'none';
    base-uri 'self';
    frame-ancestors 'none';
    img-src 'self' data:;
    style-src 'self' 'unsafe-inline';
    form-action 'self';
    upgrade-insecure-requests;
  ">

If you can avoid inline styles too, even better.

For production, I prefer setting CSP in headers instead of a meta tag. With Netlify, that usually means a _headers file:

/*
  Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; form-action 'self'; upgrade-insecure-requests
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin

Test this carefully if you use analytics, third-party widgets, or external assets.

Encode by output context

This is where a lot of teams mess up. “Escape HTML” is not one universal fix. The right defense depends on where the data goes.

HTML element text

Use textContent.

el.textContent = submission.message;

HTML attributes

Use DOM APIs, not string concatenation.

Bad:

link.outerHTML = `<a href="/user/${username}">Profile</a>`;

Better:

const link = document.createElement('a');
link.href = `/user/${encodeURIComponent(username)}`;
link.textContent = 'Profile';

URLs

Validate allowed schemes and hosts if the user controls any part of the URL.

function safeUrl(input) {
  try {
    const url = new URL(input, location.origin);
    if (url.protocol === 'http:' || url.protocol === 'https:') {
      return url.href;
    }
  } catch {}
  return 'about:blank';
}

JavaScript contexts

Don’t inject untrusted data into inline scripts. Just don’t.

Bad:

<script>
  const name = "{{ userInput }}";
</script>

Pass data through JSON or data attributes and read it safely.

Quick review checklist

Before shipping anything that uses Netlify Forms, check these:

  • Are form submissions ever rendered with innerHTML?
  • Do thank-you pages reflect user input from query params?
  • Do admin dashboards display raw submission content?
  • Are email templates inserting user input into HTML?
  • Are you allowing rich text without a real sanitizer?
  • Do you have a CSP in place?
  • Are you using DOM APIs instead of string-built markup?

If the answer to the first four is “yes”, I’d stop and fix that before doing anything else.

Good default pattern

If you want one sane rule for Netlify Forms, use this:

  • accept input as plain text
  • store it as plain text
  • render it with textContent
  • never trust hidden fields or query params
  • add CSP as backup

That covers most real-world XSS risk around Netlify Forms.

For official platform details, check Netlify’s documentation for Forms and headers configuration. For CSP rollout details and examples, https://csp-guide.com is useful when you need to tighten a policy without breaking the app.