MENU
XSS Attacks
Cross-Site Scripting (XSS) is what happens when an attacker gets their own JavaScript to run in another user's browser, in the security context of your site — able to read cookies not marked httpOnly, make requests as the logged-in user, or rewrite the page entirely. The classic vector is stored or reflected input: a comment, a profile field, a query parameter, anything user-controlled that ends up rendered back as live HTML instead of inert text.
Why JSX mitigates most of this by default
When you write {someValue} in JSX, React does not insert it into the DOM as HTML. It's set as text content, and any characters that would otherwise open a tag or attribute (<, >, &, quotes) are escaped automatically. So a comment containing <script>alert(1)</script> rendered via <p>{comment.body}</p> shows up on the page as the literal text of a script tag — it is never parsed as markup. This is true in both Server Components and Client Components, and it's why, in an ordinary Next.js app that only ever interpolates values through JSX, most user input is already safe by construction.
Where the risk still exists
- dangerouslySetInnerHTML — the explicit escape hatch that tells React to insert a raw HTML string into the DOM, bypassing the escaping described above entirely. It's needed for legitimate cases (rendering markdown or CMS content that's already valid HTML), but any user-influenced string passed through it is a direct XSS vector unless it's been sanitized first.
- Route Handlers returning HTML by hand — a Route Handler that builds a response with a template literal (e.g. new Response(`<h1>${query}</h1>`)) is doing its own string concatenation into HTML, with none of JSX's automatic escaping. Anything interpolated from the request — a query parameter, a header, a body field — needs to be escaped manually before it goes anywhere near markup this way.
- Metadata built from user or CMS content — the metadata object and generateMetadata() are escaped like any other React-rendered output when Next.js writes them into <head>, but if a title or description is pulled from unmoderated user input, consider what a very long or malformed value could do to the surrounding markup, and validate/truncate content coming from a CMS or user profile before it's used as metadata.
- javascript: URLs — a user-supplied URL rendered into an href or src can itself be javascript:alert(1). Validate that user-provided URLs use an expected protocol (https:, mailto:) before rendering them as links.
Mitigations
- Sanitize before using dangerouslySetInnerHTML: run untrusted HTML through a sanitizer such as DOMPurify that strips scripts, event handler attributes, and other dangerous constructs, and only render the result.
- Prefer a markdown renderer over raw HTML where possible — letting users write markdown that you render to React elements avoids handling arbitrary HTML at all.
- Set a Content-Security-Policy header: a CSP tells the browser which script sources are allowed to execute, so even if an injection slips through, an inline or externally-hosted malicious script can be blocked outright. In Next.js this is set via the headers() function in next.config.js, or per-response from Middleware.
- Keep session cookies httpOnly (see Session Management) so that even a successful injection can't read the session token directly via document.cookie.
components,ArticleBody.tsx:
import DOMPurify from 'isomorphic-dompurify'
export function ArticleBody({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html)
return <div dangerouslySetInnerHTML={{ __html: clean }} />
}next.config.ts:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self';",
},
],
},
]
},
}
export default nextConfig