Typeform is mostly a hosted form product, which makes people assume XSS is “their problem.” That’s only half true.

If you embed Typeform into your app, pass user-controlled values into hidden fields, render Typeform responses in your own admin panel, or glue it together with custom JavaScript, XSS becomes your problem fast. I’ve seen teams lock down their main app and then casually inject Typeform data into dashboards with innerHTML. That’s how you end up with a boring form turning into a stored XSS source.

This guide is the practical version: where XSS shows up around Typeform, what safe code looks like, and what to stop doing.

Where XSS happens with Typeform

Typeform itself renders the hosted form UI, but developers usually introduce risk in these places:

  • Embedding Typeform and surrounding it with unsafe custom HTML
  • Passing untrusted values into hidden fields or query params
  • Using custom thank-you redirects based on user input
  • Rendering submitted answers in internal tools, CRMs, or support dashboards
  • Consuming webhooks and displaying payload data without escaping
  • Building custom integrations with the Typeform API

The key rule is simple:

Treat all data coming from or going into Typeform as untrusted unless you generated it yourself and validated it.

1. Safe Typeform embeds

A basic Typeform embed is usually fine if you paste the official snippet and leave it alone. The problem starts when developers dynamically construct embed markup with string concatenation.

Bad

<div id="form-container"></div>
<script>
  const formId = new URLSearchParams(location.search).get('form');
  document.getElementById('form-container').innerHTML =
    `<iframe src="https://form.typeform.com/to/${formId}" width="100%" height="500"></iframe>`;
</script>

If formId is attacker-controlled, you’ve created an HTML injection sink. Maybe it becomes XSS directly, maybe it turns into some nasty attribute injection later. Either way, this is sloppy.

Better

<div id="form-container"></div>
<script>
  const formId = new URLSearchParams(location.search).get('form');

  // Strict allowlist: Typeform IDs are predictable, your app should know valid ones.
  const allowedForms = new Set([
    'abc123',
    'def456'
  ]);

  if (!allowedForms.has(formId)) {
    throw new Error('Invalid form ID');
  }

  const iframe = document.createElement('iframe');
  iframe.src = `https://form.typeform.com/to/${encodeURIComponent(formId)}`;
  iframe.width = '100%';
  iframe.height = '500';
  iframe.setAttribute('frameborder', '0');
  iframe.setAttribute('allow', 'camera; microphone; autoplay; encrypted-media;');

  document.getElementById('form-container').appendChild(iframe);
</script>

If you can avoid dynamic form selection entirely, do that.

2. Hidden fields are not trusted input

Typeform supports hidden fields via URL params. Teams use them for account IDs, plan names, campaign tags, and email addresses. That’s convenient, but hidden fields are just input. Users can tamper with them.

Example embed URL

<a href="https://example.typeform.com/to/abc123#user_id=42&plan=pro">Open form</a>

If you later trust plan or user_id without validation, you’ve got a security bug. If you display those values in an admin UI with unsafe rendering, you’ve got XSS too.

Bad server-side rendering of Typeform answers

app.get('/admin/submissions/:id', async (req, res) => {
  const submission = await db.getSubmission(req.params.id);

  res.send(`
    <h1>Submission</h1>
    <p>Name: ${submission.answers.name}</p>
    <p>Plan: ${submission.hidden.plan}</p>
  `);
});

A malicious value in hidden.plan can become stored XSS in your admin panel.

Better

Use templating with auto-escaping:

app.get('/admin/submissions/:id', async (req, res) => {
  const submission = await db.getSubmission(req.params.id);

  res.render('submission', {
    name: submission.answers.name,
    plan: submission.hidden.plan
  });
});

If you’re in React, render as text, not HTML:

export function SubmissionCard({ submission }) {
  return (
    <div>
      <h1>Submission</h1>
      <p>Name: {submission.answers.name}</p>
      <p>Plan: {submission.hidden.plan}</p>
    </div>
  );
}

And validate hidden fields on the server:

function validatePlan(plan) {
  const allowed = new Set(['free', 'pro', 'enterprise']);
  return allowed.has(plan) ? plan : 'free';
}

3. Never render Typeform data with innerHTML

This is the most common bug in Typeform integrations.

You fetch responses through the API or webhook, then dump them into a dashboard:

Bad

fetch('/api/typeform/responses')
  .then(r => r.json())
  .then(data => {
    const list = document.getElementById('responses');

    data.items.forEach(item => {
      list.innerHTML += `
        <li>
          <strong>${item.answers[0].text}</strong>
          <div>${item.hidden.comment}</div>
        </li>
      `;
    });
  });

If someone submits <img src=x onerror=alert(1)>, you’ve just executed it in your own app.

Better

fetch('/api/typeform/responses')
  .then(r => r.json())
  .then(data => {
    const list = document.getElementById('responses');

    data.items.forEach(item => {
      const li = document.createElement('li');
      const strong = document.createElement('strong');
      const div = document.createElement('div');

      strong.textContent = item.answers?.[0]?.text || '';
      div.textContent = item.hidden?.comment || '';

      li.appendChild(strong);
      li.appendChild(div);
      list.appendChild(li);
    });
  });

If you absolutely must support rich text, sanitize it with a proven library like DOMPurify. Don’t build your own sanitizer. That road ends badly.

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(untrustedHtml, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
  ALLOWED_ATTR: ['href']
});
container.innerHTML = clean;

4. Webhooks can carry XSS payloads too

A Typeform webhook payload is just user input in JSON form. Store it, process it, but don’t trust it.

Bad webhook consumer

app.post('/webhooks/typeform', express.json(), async (req, res) => {
  await db.saveSubmission(req.body);
  res.sendStatus(200);
});

app.get('/admin/latest', async (req, res) => {
  const latest = await db.getLatestSubmission();
  res.send(`<pre>${JSON.stringify(latest, null, 2)}</pre>`);
});

Dumping raw JSON into HTML is not safe if you don’t escape it.

Better

app.get('/admin/latest', async (req, res) => {
  const latest = await db.getLatestSubmission();
  res.type('text/plain').send(JSON.stringify(latest, null, 2));
});

Or render it in a template with escaping. If it’s meant for debugging, text/plain is the easiest safe default.

5. Redirects after Typeform completion

A lot of teams take a form answer or hidden field and use it to decide where to send the user next. That creates open redirect bugs, and sometimes DOM XSS if you inject the destination into script or markup.

Bad

const next = new URLSearchParams(location.search).get('next');
window.location = next;

Better

const next = new URLSearchParams(location.search).get('next');

const allowedPaths = new Set([
  '/thanks',
  '/pricing',
  '/demo-booked'
]);

window.location = allowedPaths.has(next) ? next : '/thanks';

If you need external redirects, use an allowlist of exact origins.

6. CSP helps, but it won’t save bad rendering

If your Typeform-related UI lives on your site, use Content Security Policy to reduce the blast radius of XSS. A decent CSP won’t fix innerHTML, but it can stop a lot of payloads from executing.

A starter CSP for a site embedding Typeform might look like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://embed.typeform.com;
  frame-src https://form.typeform.com https://*.typeform.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.typeform.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self';

You’ll need to tune this for your actual setup. For the mechanics and tradeoffs, CSP Guide is a solid reference.

Also verify your headers in a real browser-facing way. I usually check them with HeaderTest after rollout because misconfigured CSP headers are incredibly common.

7. Safe patterns for common Typeform workflows

Safe: pass internal identifiers only

Good hidden field usage:

<a href="https://example.typeform.com/to/abc123#account_id=8f4c2d10">Start survey</a>

Then resolve server-side:

const account = await db.accounts.findById(hidden.account_id);

Don’t pass display HTML, role flags, pricing decisions, or redirect targets through hidden fields.

Safe: normalize and encode before storing

function normalizeSubmissionText(value) {
  if (typeof value !== 'string') return '';
  return value.trim().slice(0, 5000);
}

This is not output escaping. It just keeps your data sane. You still escape on output.

Safe: encode in the right context

For HTML text nodes, use escaping or textContent.

For URL params:

const url = `/search?q=${encodeURIComponent(userInput)}`;

For attributes, use DOM APIs:

link.setAttribute('href', safeUrl);

Context matters. “Sanitized once” is usually a lie developers tell themselves before getting popped.

8. What to audit in an existing Typeform integration

If I were reviewing a codebase, I’d grep for these first:

grep -R "innerHTML\|outerHTML\|insertAdjacentHTML\|dangerouslySetInnerHTML" .
grep -R "typeform\|form.typeform.com\|api.typeform.com" .
grep -R "location =\|window.location\|document.write" .

Then I’d inspect:

  • Any admin page showing Typeform answers
  • Webhook consumers
  • API sync jobs importing responses
  • Embed code built from query params
  • Thank-you pages using response data
  • CRM/support tooling that displays submissions

That’s where the real bugs usually live.

9. Fast checklist

Use this when shipping anything Typeform-related:

  • Only use official embed patterns or DOM APIs
  • Never build HTML with untrusted Typeform data
  • Treat hidden fields as attacker-controlled
  • Validate IDs, enums, and redirect targets server-side
  • Render answers with auto-escaping or textContent
  • Sanitize only when you truly need limited HTML
  • Lock down CSP for your embedding page
  • Review admin panels, not just public pages
  • Store raw submissions if needed, but escape on every render

Typeform doesn’t magically create XSS. Developers do, usually in the integration layer around it. If you keep untrusted data out of HTML sinks and stop trusting hidden fields, most of the risk disappears.