You are currently viewing Cross-Site Scripting: How It Happens and How to Prevent It

Cross-Site Scripting: How It Happens and How to Prevent It

A comment can look harmless in a form field and still cause trouble when the application displays it later. If the app inserts that comment into a page as HTML rather than text, the browser may treat user-supplied content as part of the page. That crossed trust boundary is the core of cross-site scripting, or XSS.

XSS is not a flaw in JavaScript itself. It happens when untrusted data reaches a browser context where it can change what the page does. The data might come from a form, a URL parameter, a database record, or another service. Any resulting code runs in the context of the affected website, so a user may encounter it on a page they trust.

What the browser is being asked to trust

A web application combines developer-written markup, server-generated values, and content supplied by users or external systems. The browser parses the finished page according to HTML, JavaScript, and URL rules. It cannot tell which characters came from a profile name and which a developer intended as markup.

Suppose a server builds a greeting with <div>Hello, USERNAME</div>. If it inserts the username directly into HTML, characters with special meaning to the HTML parser may change the document. If it handles the username as text in that position, the browser displays a name instead. The same issue can arise in product reviews, search results, error messages, and administrative dashboards.

The effect depends on the application. An XSS flaw may let someone change visible content, perform actions available to the current user, or access data that page scripts are allowed to read. Browser protections can limit particular outcomes, but they do not make injected script harmless. An HttpOnly session cookie, for example, cannot be read by JavaScript; script running in the user's browser may still be able to send authenticated requests to the affected site.

User content crosses into browser-rendered page

Three common paths to XSS

Stored XSS

Stored XSS starts when an application saves untrusted content and later displays it without handling it safely. A discussion post, support ticket, or account profile can hold the value. The person who supplied it need not be present when another user opens the page. One stored value may also appear in several places, each with its own output context and defenses.

Reflected XSS

Reflected XSS occurs when a request value is immediately included in a response. A search page, for instance, might show “Results for” followed by the submitted query. If the app inserts that query into HTML unsafely, the response can contain attacker-controlled markup. The value usually is not saved; the unsafe response appears when someone visits a request containing it.

DOM-based XSS

DOM-based XSS occurs in client-side code that reads untrusted data and writes it through a dangerous browser API. A page might read a value from the URL fragment and use innerHTML to update a panel. The server's HTML response may be unchanged: the unsafe flow is between the URL value and the browser-side DOM operation.

These labels describe where the unsafe flow occurs, not separate sets of security rules. A stored record can also be handled unsafely by client-side code. To investigate a suspected issue, follow the data from its source to the point where the browser interprets it.

Context determines the correct defense

“Escape user input” sounds simple, but no single escape operation works for every destination. Text between HTML tags needs different treatment from a value inside an HTML attribute, a URL, CSS, or JavaScript source code. When possible, avoid putting untrusted values into executable contexts at all.

  • HTML text: Use your template engine's automatic HTML escaping or a DOM API such as textContent. Characters that could start markup then appear as text.
  • HTML attributes: Use a template engine or DOM API that handles attribute values safely. Quote attributes, avoid event-handler attributes, and do not assemble markup by concatenating strings.
  • URLs: Validate the allowed destination and scheme, and encode the value for its location. HTML escaping alone does not make an unsafe URL safe.
  • JavaScript code: Do not paste untrusted values into an inline script. Put data into a safely serialized data structure or retrieve it through an API, then handle it as data.
  • HTML you intentionally allow: Use a maintained HTML sanitizer with a restrictive policy. Plain HTML escaping would display the markup rather than render it; sanitization is for cases where limited formatting is a requirement.

Encode for the output context, usually where the value is rendered. Encoding everything on input can corrupt legitimate data and cause confusion when the same value is used in different contexts. Input validation still helps: a numeric field should accept numbers, and a controlled status field should accept known values. But validation does not replace output encoding. Even expected text can contain characters that mean something to a browser.

On the client side, compare label.textContent = value with label.innerHTML = value. The first creates text. The second tells the browser to parse the value as HTML. If markup is not a product requirement, textContent makes the intended behavior clear.

Why apparently safe code can become unsafe

Framework protections have boundaries

Modern template engines and UI frameworks commonly escape values by default when rendering text. Features that insert raw HTML, bypass sanitization, or manipulate the DOM directly can remove that protection. During a review, look closely at code that opts out of normal rendering rules. A value handled safely in one component can become unsafe if another passes it to a raw-HTML API.

Server-side rendering and client-side updates also need consistent treatment. A page may escape a database value correctly on the first load, then fetch the same value through an API and assign it to innerHTML. Tests that check only the initial page would miss the later update.

Filtering suspicious strings is brittle

Removing a few words or tags from input is not a reliable primary defense. Browsers parse values in different contexts, and an application may alter a value more than once before displaying it. A filter that catches one spelling of a suspicious string says little about what the browser will finally parse. Safe APIs and context-appropriate output handling address the trust boundary directly.

Blocking angle brackets, for example, might appear to protect plain HTML text but says little about a value placed in an attribute, URL, or script. The useful review question is not “Does this input look dangerous?” but “What will parse this value after the application has processed it?”

Code review focused on untrusted browser input

Defense in depth without false confidence

A Content Security Policy (CSP) can restrict which scripts a browser executes and reduce the impact of some XSS mistakes. It is an extra control, not permission to render untrusted HTML. A policy that allows broad inline execution or too many script sources offers less protection than a carefully scoped one. Test CSP against the application's real features, too, so a broken page does not prompt developers to weaken the policy without understanding why.

Cookie settings serve other purposes. HttpOnly limits JavaScript access to a cookie; Secure restricts its transmission to HTTPS; and SameSite affects when it is sent in cross-site requests. None fixes an XSS sink. HTTPS protects traffic in transit, but it cannot distinguish safe page code from unsafe content delivered by the same site.

Isolation matters when an application accepts rich content. Rendering untrusted documents under a separate origin with carefully chosen restrictions can reduce their access to the main application. That design still calls for care around navigation, messages between frames, and the capabilities given to the isolated content. For ordinary comments and profile fields, plain text is often simpler and safer than arbitrary HTML.

A safe workflow for checking an application you control

You do not need to test someone else's website to learn about XSS. Use a local project, a training lab, or an application you have explicit permission to check. First, find where user-controlled text enters a page: query strings, form fields, API responses, imported files, and saved records. Then trace where each value is rendered, including updates after the initial page load.

  1. Map the source and destination. Record whether a value lands in HTML text, an attribute, a URL, or a DOM-writing operation. Note the template or component responsible.
  2. Check the intended behavior. For a plain-text field, enter harmless characters that have HTML meaning, such as <, >, and quotation marks. Confirm that they appear as text rather than changing the page structure.
  3. Inspect the rendering path. Review the code and browser DOM together. The original response may be escaped correctly even if a later client-side update is not.
  4. Fix the sink, then add a regression test. Replace unnecessary HTML parsing with text rendering, or use appropriate encoding or a sanitizer when rich text is justified. Test both the initial render and subsequent updates.

Use test accounts and non-sensitive content. If a test reveals executable behavior, stop after the minimum observation needed to confirm it; there is no need to access another user's data or demonstrate account abuse. A useful team report identifies the input source, affected page, rendering context, and expected safe behavior after the fix.

Review a small example end to end

Imagine a task board that displays a user-supplied task title in a card. The server stores the title as entered, and the browser receives it through a JSON API. Storing the raw title is not itself the XSS mistake. What matters is how the card displays it.

If the UI creates a title element and assigns titleElement.textContent = task.title, the browser displays the title as text. If the UI instead builds a string from <h3>, the title, and </h3>, then assigns that string through innerHTML, the browser parses the title as part of the document. A regression test can create a task titled Review <draft> and check that the card shows those exact characters without creating a draft element. That test catches a failure at the boundary where this particular mistake would occur.