Cross-site scripting (XSS)
User content rendered as HTML runs in your origin. It can read that user's data and call your API as them.
Last updated
Shows up in: Next.js, Supabase, Vercel
What it is
React escapes text you put in JSX. That default is why a comment rendered as {comment.body} is usually safe. The bugs are the escapes from that default: dangerouslySetInnerHTML, a markdown renderer that emits raw HTML, setting innerHTML, or an href that starts with javascript:.
Stored XSS is the version that matters for a small app. One user saves a display name or a bio. Every other user who views it runs the script. The script is same-origin, so it can call your APIs with the victim's cookies.
A content security policy reduces how useful a bug is. It does not make dangerouslySetInnerHTML of user HTML acceptable. See missing security headers.
Model output is untrusted text too. If you render an LLM reply as HTML, you have the same bug, plus prompt injection.
How to check in 5 minutes
Search for
dangerouslySetInnerHTML,innerHTML,markdown, andrehype-raw. Open each and see whether the HTML can include a user's string or a model's reply.Search links built from user input. An
<a href={user.url}>is safe forhttpsand dangerous forjavascript:.On a staging account you own, save
<img src=x onerror=alert(1)>or a markdown image with an error handler as a profile field. View it as another user. If you see the dialog, or the image request fires, the field is rendered as HTML.Check email templates and PDF rendering if they interpolate names into HTML the same way.
How to fix
Next.js
Render user text as text. If you need formatting, use a markdown renderer that does not pass raw HTML through, or sanitize with a maintained HTML sanitizer and a strict allowlist (no script, no event handlers, no javascript: URLs). DOMPurify is a widely used sanitizer. Pin the version and keep it updated.
export function Bio({ bio }: { bio: string }) {
return <p>{bio}</p>;
}
For links, allow only https: after parsing the URL. Reject everything else.
Do not build a sanitizer with a regex. You will miss a browser quirk.
Supabase
The database can store the characters. That is fine. The bug is rendering them as HTML. You do not need to strip < on the way in if every view escapes on the way out. Do not reflect a stored error string into an admin page with dangerouslySetInnerHTML either.
Vercel
A header such as Content-Security-Policy on the deployment limits script sources. Set it in next.config or vercel.json. A CSP is a second layer. Remove the HTML sink first. See missing security headers.
Firebase
Same rendering rules. Security rules do not stop XSS. They stop the write, not the script in a field you chose to render.
Copy-paste prompt
Paste this into Cursor, Claude Code, or whichever agent is editing the repo. Read the diff before you merge it.
You are removing cross-site scripting sinks. Do not add features. Search for dangerouslySetInnerHTML, innerHTML, and markdown or HTML renderers. For each: 1. If the input can include user or model text, render it as text, or sanitize HTML with a maintained allowlist sanitizer. Do not write a regex sanitizer. 2. Disallow javascript: and data: URLs in links built from user input. Allow https only, unless I named another scheme. 3. Do not turn off React's escaping to make formatting work. Show each sink and what you did with it.
Questions
React escapes everything, so can I ignore this?
React escapes JSX text. It does not escape HTML you insert on purpose, HTML you generate from markdown, or URLs in href.
Should I strip angle brackets when saving to the database?
Escaping at render is the reliable fix, because a new screen can forget a strip-on-write rule. Stripping on write also corrupts legitimate text like "1 < 2".