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.
// 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>;
}
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.
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"],
});
}
/** @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;