Cross-site scripting is still one of the easiest ways to turn a small rendering mistake into a full account takeover. If you build donation flows, event pages, admin dashboards, checkout forms, or email template editors like the kinds of features you’d expect around Humanitix, you’re handling user-controlled content constantly. That’s exactly where XSS shows up.

The boring version of XSS advice is “escape output.” True, but too shallow to be useful. Real apps have rich text, markdown, embedded widgets, analytics snippets, query-string state, and legacy code that still pokes the DOM directly. That’s where teams get burned.

Here’s how I’d approach XSS prevention in a Humanitix-style application.

Where XSS actually appears

Think about the data you render:

  • Event titles and descriptions
  • Organizer profile fields
  • Venue names and custom instructions
  • Coupon codes or referral labels
  • Support chat messages
  • Admin notes
  • Query parameters like ?returnTo= or ?message=
  • HTML from WYSIWYG editors
  • Markdown previews
  • Third-party embed content

If any of that reaches the page unsafely, an attacker can inject script, steal sessions, perform actions as the victim, or skim payment-related data from the browser.

A classic example:

<div id="event-description"></div>
<script>
  const params = new URLSearchParams(location.search);
  const description = params.get('description');
  document.getElementById('event-description').innerHTML = description;
</script>

Now this payload executes:

?description=<img src=x onerror=alert(document.domain)>

That’s reflected XSS. Stored XSS is worse: attacker saves the payload in event content, then every viewer gets hit.

Rule 1: Prefer text, not HTML

If content is supposed to be plain text, render it as text. Not “sanitized HTML.” Not “trusted because it came from our API.” Plain text.

Bad

element.innerHTML = event.title;

Good

element.textContent = event.title;

Server-side example in Node

If you render templates, use escaping by default.

import express from 'express';
const app = express();

function escapeHtml(str = '') {
  return str
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

app.get('/event/:slug', async (req, res) => {
  const event = await getEventBySlug(req.params.slug);

  res.send(`
    <h1>${escapeHtml(event.title)}</h1>
    <p>${escapeHtml(event.venueName)}</p>
  `);
});

If your template engine supports auto-escaping, keep it on. Don’t disable it because one field needs rich text. Solve that field separately.

Rule 2: If you must allow HTML, sanitize it hard

Event platforms often need formatted descriptions. Organizers want headings, links, lists, maybe images. That means you need sanitization, not regex hacks.

If you accept rich text, define a strict allowlist of tags and attributes.

Example with DOMPurify in the browser

<script type="module">
  import DOMPurify from '/static/vendor/dompurify.es.mjs';

  const dirtyHtml = await fetch('/api/events/123').then(r => r.text());

  const cleanHtml = DOMPurify.sanitize(dirtyHtml, {
    ALLOWED_TAGS: [
      'p', 'br', 'strong', 'em', 'ul', 'ol', 'li',
      'h2', 'h3', 'blockquote', 'a'
    ],
    ALLOWED_ATTR: ['href', 'title'],
    ALLOW_DATA_ATTR: false
  });

  document.getElementById('description').innerHTML = cleanHtml;
</script>

Better: sanitize on write and on render

I don’t trust one layer here. Sanitize when content is saved, and sanitize again when rendering. That protects you if rules change or old content sneaks through.

Node example

import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';

const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

export function sanitizeRichText(html) {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: [
      'p', 'br', 'strong', 'em', 'ul', 'ol', 'li',
      'h2', 'h3', 'blockquote', 'a'
    ],
    ALLOWED_ATTR: ['href', 'title'],
    FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed'],
    FORBID_ATTR: ['onerror', 'onclick', 'onload', 'style']
  });
}

Then:

app.post('/api/events/:id/description', async (req, res) => {
  const clean = sanitizeRichText(req.body.descriptionHtml);
  await saveEventDescription(req.params.id, clean);
  res.json({ ok: true });
});

Rule 3: Never build HTML with string concatenation

This still shows up in admin panels and legacy widgets.

Bad

results.innerHTML += `
  <li>
    <a href="${event.url}">${event.title}</a>
  </li>
`;

This is vulnerable if event.url contains javascript:alert(1) or event.title contains HTML.

Good

const li = document.createElement('li');
const a = document.createElement('a');

a.textContent = event.title;

const url = new URL(event.url, location.origin);
if (url.protocol === 'http:' || url.protocol === 'https:') {
  a.href = url.toString();
} else {
  a.href = '/';
}

li.appendChild(a);
results.appendChild(li);

Two wins here: no HTML parsing, and URL validation.

Rule 4: Frameworks help, until you bypass them

React, Vue, Angular, Svelte — all of them escape text by default. That’s great. The trouble starts when someone wants “just a little HTML.”

React

Safe by default:

export function EventCard({ event }) {
  return <h2>{event.title}</h2>;
}

Dangerous:

export function EventDescription({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

If you use dangerouslySetInnerHTML, sanitize first:

import DOMPurify from 'dompurify';

export function EventDescription({ html }) {
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'ul', 'li', 'br'],
    ALLOWED_ATTR: ['href']
  });

  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Vue

Safe:

<template>
  <h2>{{ event.title }}</h2>
</template>

Risky:

<template>
  <div v-html="event.descriptionHtml"></div>
</template>

Same fix: sanitize before assigning to v-html.

Rule 5: Validate URLs, not just HTML

A lot of XSS bugs in event systems come through links: ticket URLs, social links, return URLs, CTA buttons.

Don’t accept arbitrary schemes.

function isSafeUrl(input) {
  try {
    const url = new URL(input, 'https://example.com');
    return ['http:', 'https:'].includes(url.protocol);
  } catch {
    return false;
  }
}

Use it before rendering:

const href = isSafeUrl(organizer.website) ? organizer.website : '/';
link.setAttribute('href', href);

If links open in a new tab, add rel too:

link.target = '_blank';
link.rel = 'noopener noreferrer';

Rule 6: Treat query strings and postMessage as hostile

I’ve seen teams sanitize database content but trust URL params because “it’s only used for UI state.” Bad assumption.

Bad

banner.innerHTML = new URLSearchParams(location.search).get('msg');

Good

banner.textContent = new URLSearchParams(location.search).get('msg') || '';

Same for postMessage data from embedded checkout flows or widgets. Verify origin and treat payload fields as untrusted.

window.addEventListener('message', (event) => {
  if (event.origin !== 'https://checkout.example.com') return;

  const message = typeof event.data?.message === 'string' ? event.data.message : '';
  statusEl.textContent = message;
});

Rule 7: Lock things down with CSP

Content Security Policy won’t fix unsafe rendering, but it turns many XSS bugs from catastrophic into noisy failures. I consider CSP mandatory for any app handling accounts, payments, or admin features.

A solid starting point:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m123';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  object-src 'none';
  form-action 'self';
  require-trusted-types-for 'script';

If you need implementation patterns and rollout advice, https://csp-guide.com is useful.

Nonce example in Express

import crypto from 'crypto';

app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString('base64');

  res.setHeader(
    'Content-Security-Policy',
    `default-src 'self'; ` +
    `script-src 'self' 'nonce-${res.locals.cspNonce}'; ` +
    `object-src 'none'; base-uri 'self'; frame-ancestors 'none'`
  );

  next();
});

app.get('/', (req, res) => {
  res.send(`
    <h1>Events</h1>
    <script nonce="${res.locals.cspNonce}">
      console.log('allowed script');
    </script>
  `);
});

Avoid 'unsafe-inline' in script-src. If that’s still required, you probably have cleanup work to do.

Rule 8: Trusted Types are worth it

If your frontend is large and has multiple contributors, Trusted Types are one of the best guardrails you can add. They stop unsafe DOM sinks like innerHTML unless the value comes from an approved policy.

Browser support and rollout take effort, but for bigger apps it’s absolutely worth it.

if (window.trustedTypes) {
  const policy = trustedTypes.createPolicy('default', {
    createHTML: (input) => DOMPurify.sanitize(input)
  });

  const clean = policy.createHTML(userSuppliedHtml);
  document.getElementById('description').innerHTML = clean;
}

Pair this with CSP’s require-trusted-types-for 'script'.

Rule 9: Test the dangerous paths

I’d specifically test:

  • Event description rendering
  • Organizer bios
  • Custom email previews
  • Search results
  • Toasts and flash messages
  • Query-param-driven UI
  • Embedded widgets
  • Admin moderation tools

Payloads worth trying:

<script>alert(1)</script>
<img src=x onerror=alert(1)>
<a href="javascript:alert(1)">click</a>
<svg onload=alert(1)>
"><img src=x onerror=alert(1)>

Also test encoded variants and markdown-to-HTML conversions.

A safe pattern for Humanitix-style content

My default architecture is simple:

  1. Store plain text for fields that don’t need formatting.
  2. For rich text, sanitize on write.
  3. Sanitize again on render.
  4. Render text with textContent or framework interpolation.
  5. Avoid innerHTML, v-html, and dangerouslySetInnerHTML unless there’s no alternative.
  6. Validate URLs separately.
  7. Enforce CSP.
  8. Add Trusted Types for large frontends.

That stack prevents most XSS bugs before they become incident-response problems.

The biggest mistake teams make is assuming XSS is only about <script> tags. It isn’t. It’s about every place the browser interprets attacker-controlled input as code, markup, or executable URL data. Once you start thinking in those terms, the vulnerable spots become obvious fast.