Skip to content
FLAVIO COPES
flaviocopes.com
2026

Cross Site Scripting (XSS) tutorial

By

Learn how cross-site scripting happens, the stored, reflected, and DOM forms, and how safe sinks, encoding, sanitization, and CSP prevent it.

~~~

Cross-site scripting, or XSS, happens when an application lets untrusted data become executable content in a page.

The browser then runs the attacker’s code with the affected site’s origin and the current user’s access.

The fix is not one universal escape() function. You must handle data according to where it enters the document.

Why XSS is dangerous

An XSS payload runs as code from your site.

It can:

An HttpOnly cookie cannot be read with JavaScript. This blocks direct theft of that cookie, but injected code can still perform actions through the user’s session.

XSS can affect server-rendered sites, single-page applications, and static sites that place URL or third-party data into the DOM.

The three common forms of XSS

Stored XSS

With stored XSS, an application saves malicious input and later includes it in a page.

A comment system is a typical example. The server stores a comment, then renders it for every visitor.

If the template treats the comment as HTML instead of text, active content can run for each reader.

Reflected XSS

With reflected XSS, data from a request appears immediately in the response.

Imagine a search page that reads q from this URL:

/search?q=canvas

It then shows:

You searched for canvas

The application becomes vulnerable if it inserts q into HTML without the encoding required for that exact output context.

DOM-based XSS

With DOM-based XSS, client-side JavaScript reads untrusted data and passes it to a dangerous DOM API.

This is unsafe:

const message = new URLSearchParams(location.search).get('message')
document.querySelector('#output').innerHTML = message

The query-string value reaches innerHTML, which parses it as HTML.

Use a safe text sink instead:

const message = new URLSearchParams(location.search).get('message')
document.querySelector('#output').textContent = message

textContent creates text. It does not parse HTML.

Stored and reflected data can also reach a DOM-based sink. These categories describe how the data reaches execution, and can overlap in a real application.

Start with framework escaping

Modern template systems and frontend frameworks escape text values by default.

Keep that protection enabled.

Be careful with escape hatches such as:

Do not use an HTML sink when you only need text.

Framework escaping is not enough when you bypass it, build URLs with unsafe schemes, use vulnerable plugins, or insert data into a JavaScript or CSS context.

Encode for the output context

Browsers parse HTML, attributes, URLs, CSS, and JavaScript differently. Each context needs the matching encoding rules.

For normal text in the DOM, use textContent:

element.textContent = untrustedText

For a form field, assign value:

input.value = untrustedText

When server-rendering HTML, use the template engine’s normal escaped interpolation. Do not concatenate raw strings into markup.

URL encoding is not HTML encoding. encodeURIComponent() only prepares a value for one component of a URL:

const url = `/search?q=${encodeURIComponent(searchTerm)}`

You still need normal HTML attribute encoding when that URL appears in server-rendered HTML.

Avoid placing untrusted data directly inside:

Moving data into JSON or a quoted string without the correct context-specific encoding is not enough.

Treat attributes and URLs carefully

setAttribute() is safe only when the attribute name is fixed and harmless:

image.setAttribute('alt', untrustedText)

It is not safe to put untrusted data into an event handler:

button.setAttribute('onclick', untrustedText)

URL attributes need scheme validation. An attacker-controlled URL can use a dangerous scheme even when its characters are encoded.

Parse the URL and allow only the protocols your feature needs:

function getSafeLink(value) {
  const url = new URL(value, location.origin)

  if (!['http:', 'https:'].includes(url.protocol)) {
    throw new Error('Unsupported URL protocol')
  }

  return url.href
}

Then assign the validated result:

link.href = getSafeLink(untrustedUrl)

Do not send untrusted strings to eval(), Function(), or string forms of setTimeout() and setInterval().

Sanitize HTML when HTML is required

Sometimes users need to author formatted HTML. Encoding it would display the tags instead of formatting the content.

In that case, use a maintained HTML sanitizer with a strict allowlist. OWASP recommends DOMPurify:

const cleanHtml = DOMPurify.sanitize(untrustedHtml)
element.innerHTML = cleanHtml

Keep the sanitizer updated. Browser behavior changes and sanitizer bypasses are discovered over time.

Do not write an HTML sanitizer with regular expressions. HTML parsing has too many contexts and edge cases.

Do not modify sanitized markup with unsafe string operations afterward. That can make it unsafe again.

Validate input, but do not rely on it alone

Input validation can reject values that do not fit a feature.

For example, an age field should accept a sensible number, and a username can have a documented character policy.

Validation does not replace output encoding. Valid data can still contain characters that matter in a different output context.

Allowlisting is safer than trying to list every dangerous string. Attackers can encode and combine input in many ways.

Add Content Security Policy

A strong Content Security Policy, or CSP, can limit which scripts run.

Modern policies commonly use a unique nonce or hash for allowed scripts and avoid unsafe-inline. They also restrict objects, base URLs, frames, and network destinations according to the application.

CSP is defense in depth. It is easy to weaken with broad source lists or unsafe directives, and it does not replace safe sinks, encoding, or sanitization.

You can use the CSP generator to create a starting policy, then test it in report-only mode before enforcement.

Trusted Types can add another boundary around DOM XSS sinks in supporting browsers. It forces dangerous assignments through approved policies, often backed by a sanitizer.

Reduce the impact

These controls do not prevent XSS, but they reduce what a successful payload can do:

Fix the unsafe data flow first. A cookie flag, web application firewall, or CSP header cannot make an unsafe sink correct.

For authoritative guidance, see the OWASP Cross Site Scripting Prevention Cheat Sheet, DOM-based XSS Prevention Cheat Sheet, and Content Security Policy Cheat Sheet.