Understanding and Preventing XSS Attacks in JavaScript Applications

Understanding and Preventing XSS Attacks in JavaScript Applications
Understanding and Preventing XSS Attacks in JavaScript Applications

Understand the risks of Cross-Site Scripting (XSS) and learn how to secure your applications against these dangerous client-side threats.

One malicious string in the wrong text box, and an attacker is running code inside your users’ browsers, on your domain, with their session. That’s Cross-Site Scripting (XSS), and it’s still one of the most common ways JavaScript apps get owned. The good news: the defenses are well understood, and they mostly come down to a few habits. Here’s how XSS works, the three flavors you’ll run into, and what actually stops it.

Table of Contents

What is Cross-Site Scripting (XSS)?

XSS is a client-side injection attack. An attacker gets their JavaScript to run in another user’s browser, in the context of your site. Because the browser thinks that code came from your page, it can read cookies, grab session tokens, rewrite the DOM, and act as the logged-in user. It happens when untrusted input reaches the page without being escaped or sanitized first.

Types of XSS Attacks

There are three types worth knowing: stored, reflected, and DOM-based. They differ in where the payload lives and how it gets to the victim.

1. Stored XSS

Stored XSS is the nastiest of the three. The payload gets saved on the server (a comment, a profile bio, a product review) and then handed to everyone who loads the page. Submit it once, and every visitor runs it.

Example:

HTML
<!-- A vulnerable comment section that stores malicious JavaScript in the database -->
<form method="post" action="/submit-comment">
  <input type="text" name="comment" placeholder="Enter your comment">
  <button type="submit">Submit</button>
</form>
<!-- On rendering comments, unescaped content is displayed -->
<div class="comments">
  <p>User comment: <script>alert('XSS Attack');</script></p>
</div>

Here an attacker submits a comment like this:

JS
<script>alert('XSS Attack!');</script>

If that comment is stored and later rendered without escaping, every browser that loads the page runs the script.

2. Reflected XSS

Reflected XSS doesn’t stick around. The payload rides in the request, usually a URL parameter, and the server (or the page’s own JavaScript) echoes it straight back into the response. The attacker sends a crafted link over email or chat and waits for a click.

Example:

JS
// A simple example of reflected XSS via URL parameters
const query = new URLSearchParams(window.location.search);
document.write(query.get('message'));

Now craft a URL like this:

HTML
https://vulnerable.com/page?message=<script>alert('XSS')</script>

The bug here is document.write. It’s a dangerous sink: it parses whatever you hand it as HTML, so the message parameter runs as a script the moment the victim opens the link.

3. DOM-Based XSS

DOM-based XSS never touches the server. The vulnerable code is your own JavaScript, reading something like location.hash or a form field and writing it straight into the page. Server-side validation won’t catch it, because the payload never leaves the browser.

Example:

JS
// Example of DOM-based XSS
const userInput = document.getElementById('userInput').value;
document.getElementById('output').innerHTML = userInput;

innerHTML is the sink this time. Feed it a <script> or, more realistically, an <img onerror=...>, and the markup executes as it’s parsed into the DOM.

How XSS Attacks Work

Every XSS attack abuses the same trust: the browser runs whatever your page tells it to. The steps look like this:

  1. Find an entry point: an input, URL parameter, or field that reaches the page without being sanitized or escaped.
  2. Inject the script: through that input, a link, or an API response the page renders.
  3. Run in the victim’s browser: when they load the page, the script executes with their session, reading cookies and tokens.
  4. Do damage: impersonate the user, exfiltrate data, or spread the payload further.

Real-World XSS Attacks

XSS isn’t theoretical. A couple of well-documented cases show what it can do at scale:

  • The Samy worm (MySpace, 2005): Samy Kamkar used a stored XSS hole to build a self-propagating worm. Anyone who viewed an infected profile got Samy added as a friend and had the payload copied to their own profile. It hit over a million accounts in under a day, one of the fastest-spreading worms on record.
  • Yahoo Mail (2013): an XSS flaw let attackers steal session cookies through a crafted email. Opening the message ran the attacker’s JavaScript and handed over access to the victim’s inbox.

The pattern is the same in both: untrusted content rendered as code, then abused to hijack sessions.

Preventing XSS in JavaScript

No single trick fixes XSS. You layer a few defenses so a mistake in one spot doesn’t hand over the page. In rough order of importance:

1. Avoid the dangerous sinks

This is the big one. Most DOM-based XSS comes from a handful of APIs that parse strings as HTML or code: innerHTML, outerHTML, document.write, and eval. When you’re inserting untrusted text, reach for textContent instead, and use setAttribute for attributes. Text goes in as text, and nothing executes.

JS
// Safer way of inserting user data into the DOM
const userComment = "<script>alert('XSS');</script>";
document.getElementById('comment').textContent = userComment; // This safely renders the text without executing it.
2. Encode output for its context

When you do render user data into HTML, encode it so the browser treats it as content, not markup. HTML-entity encoding turns characters like < and > into harmless equivalents:

JS
// Example of escaping characters to prevent XSS
function escapeHTML(str) {
  return str.replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#039;");
}
const userInput = "<script>alert('XSS Attack');</script>";
const safeOutput = escapeHTML(userInput);
console.log(safeOutput); // &lt;script&gt;alert('XSS Attack')&lt;/script&gt;

One caveat: encoding is context-specific. The function above is for HTML body content. Data going into a URL needs encodeURIComponent, and data going into a JavaScript string, a CSS value, or an attribute each needs its own encoding. Don’t assume one escaper covers every spot.

3. Sanitize HTML you actually need to render

Sometimes you have to accept real HTML, like a rich-text comment. Don’t write your own filter for that, you’ll miss a case. Run it through DOMPurify, a well-tested sanitizer that strips scripts and dangerous attributes while keeping safe markup. Then it’s safe to hand the result to innerHTML.

4. Let your framework escape for you

Modern frameworks escape by default, which quietly kills most XSS. React escapes anything you put in {}, Angular and Vue escape their template bindings the same way. The catch is the escape hatches: React’s dangerouslySetInnerHTML, Vue’s v-html, and Angular’s bypassSecurityTrust* methods all turn escaping off. Only feed those sanitized (DOMPurify’d) HTML, never raw user input.

5. Add a Content Security Policy

A Content Security Policy (CSP) is defense-in-depth. It tells the browser which sources are allowed to load scripts, so even if a payload slips through, an inline or off-origin script may be blocked from running. It’s a safety net, not a substitute for the steps above.

HTML
<!-- Example of a basic Content Security Policy -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self';">

This policy only allows scripts from your own origin ('self'), which blocks scripts injected from elsewhere. In production you’d usually send it as an HTTP header rather than a meta tag.

6. Keep session cookies out of JavaScript’s reach

Set the HttpOnly and Secure flags on session cookies. HttpOnly means document.cookie can’t read them, so even a successful XSS can’t steal the session. Secure keeps them off plain HTTP.

JS
// Setting a cookie with HTTPOnly and Secure flags
document.cookie = "sessionToken=abc123; HttpOnly; Secure";

Important correction: you cannot actually set HttpOnly from JavaScript. Browsers ignore the flag when a cookie is written through document.cookie, and that’s the whole point, JavaScript isn’t allowed to touch an HttpOnly cookie. It has to be set by the server in the Set-Cookie response header. Treat the snippet above as what not to rely on, and configure the flag server-side.

Best Practices for XSS Prevention

Pulling it together, the habits that keep XSS out over the long run:

  • Treat all input as untrusted: from users, third-party APIs, or your own database. Validate and encode at every boundary.
  • Prefer safe sinks: textContent and setAttribute over innerHTML, document.write, and eval.
  • Encode for the right context: HTML, attribute, URL, and JavaScript contexts each need their own encoding.
  • Sanitize rich HTML with a real library: DOMPurify, not a hand-rolled regex.
  • Lean on framework escaping: and be deliberate every time you reach for an escape hatch like dangerouslySetInnerHTML.
  • Ship a CSP and HttpOnly cookies: so a single slip doesn’t become a full account takeover.
Conclusion

XSS sticks around because it only takes one unescaped value to hand an attacker your users’ sessions. But the fix isn’t exotic. Avoid the dangerous sinks, encode output for its context, sanitize any HTML you have to render, let your framework do its job, and back it all with a CSP and HttpOnly cookies.

Bake those into how you write code, not into a checklist you run at the end, and XSS stops being the vulnerability that quietly ships to production.

Leave a Comment

Your email address will not be published. Required fields are marked *


Scroll to Top