Skip to main content
Published
XSSSecurityFrontend

Cross-site scripting in frontend apps

How XSS shows up in React and Next.js products, why dangerouslySetInnerHTML keeps biting teams, and practical hardening with sanitisation and CSP.

Cross-site scripting is still the frontend bug that turns your UI into an attacker's UI. Untrusted string becomes executable script in someone else's browser. Session tokens, personal data, and admin actions all sit behind that mistake.

In React and Next.js apps the framework helps, then people undo the help with dangerouslySetInnerHTML, unchecked markdown, CMS HTML, or reflecting search params into the DOM.

Where XSS shows up in frontend apps

Stored XSS: a note, comment, or CMS field saved once and rendered for every viewer.

Reflected XSS: a query string echoed into the page without encoding.

DOM XSS: client script reads location.hash or postMessage data and writes it into innerHTML.

bad/SearchHeading.tsxtsx
// Unsafe: attacker controls q
export function SearchHeading({ q }: { q: string }) {
  return (
    <h1 dangerouslySetInnerHTML={{ __html: `Results for ${q}` }} />
  );
}
 
// Safer default: let React escape
export function SafeSearchHeading({ q }: { q: string }) {
  return <h1>Results for {q}</h1>;
}
React text nodes are safe. Raw HTML is not.

Hardening the frontend

Prefer text rendering. Sanitize HTML at the boundary with a maintained library if rich text is required. Treat markdown and CMS fields as hostile until proven otherwise.

Tighten Content-Security-Policy so inline script and unexpected origins fail closed. XSS without script execution is a much smaller incident. In Next.js, start with headers in next.config, then move to nonces when you need stricter script-src.

  • Never put secrets in localStorage if XSS is in scope.
  • Validate postMessage origins.
  • Encode URLs before putting user input into href. Reject javascript: schemes.
lib/sanitize.tsts
import DOMPurify from "isomorphic-dompurify";
 
export function safeHtml(input: string) {
  return DOMPurify.sanitize(input, {
    USE_PROFILES: { html: true },
    FORBID_TAGS: ["style"],
    FORBID_ATTR: ["style", "onerror", "onload"],
  });
}
Sanitize before rich HTML ever reaches the tree.
next.config.mjsjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          {
            key: "Content-Security-Policy",
            value: [
              "default-src 'self'",
              "script-src 'self'",
              "object-src 'none'",
              "base-uri 'self'",
              "frame-ancestors 'none'",
            ].join("; "),
          },
          { key: "X-Content-Type-Options", value: "nosniff" },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
        ],
      },
    ];
  },
};
 
export default nextConfig;
Baseline CSP via Next.js headers. Tighten further with nonces in middleware when ready.

More writing

Availability

Open to opportunities

Open to senior product engineer roles with real ownership across the stack. Security-minded by default.

UK-based · Remote / hybrid · Permanent or contract