Topics
Security

XSS and Content Security Policy

Why injected script gets your origin’s full powers, the three kinds of XSS and their sinks, encoding per context, and how a nonce CSP stops what slips through.

Intermediate·14 min read·Updated Oct 6, 2026

XSS (cross-site scripting) happens when text an attacker controls reaches the page as markup or code instead of as text, so the browser runs the attacker's script inside your origin. Because the browser cannot tell that script from your own, it can read the page, call your API with the user's cookies, and act as the user. The fix is to encode every value for the exact context it lands in; a CSP (Content Security Policy) with per-response nonces is the second line that stops injected script from running when an encoding mistake slips through.

Context

The name dates from 2000, when Microsoft engineers described attacks that injected script into pages served by a different site, hence "cross-site". It became famous in 2005 with the Samy worm, a few lines of script in a MySpace profile that added its author as a friend of everyone who viewed it and copied itself onto their profiles, reaching over a million accounts in under 20 hours. Browsers answered with defences layered on top of correct escaping: HttpOnly cookies (Internet Explorer 6 SP1, 2002), Content Security Policy (proposed at Mozilla in 2009, shipped in Firefox 4 in 2011, a W3C standard since 2012), and Trusted Types in Chromium (version 83, 2020).

You have met it as React's deliberately scary prop name dangerouslySetInnerHTML, as a DevTools error starting with "Refused to execute inline script because it violates the following Content Security Policy directive", and in its simplest form as a search page that echoes the query back:

search.js
app.get('/search', (req, res) => {
  // the query string is pasted into HTML as-is
  res.send(`<h1>Results for ${req.query.q}</h1>`)
})

// /search?q=<script>steal()</script>
// -> the browser parses a script element and runs steal()
//    on your origin, with your user's session
Origin
Scheme + host + port. The same-origin policy lets a script read and act on everything that belongs to its own origin.
Source
Where attacker-controlled data enters: a form field, a URL parameter, location.hash, a stored comment, an API response.
Sink
An API that turns a string into markup or code: innerHTML, document.write, eval, setTimeout with a string, an href or src attribute.
Payload
The attacker’s string, crafted to break out of the context it is placed in, for example an img tag whose onerror handler runs script.
Nonce
A random value, new for every response, that a CSP header and the page’s own script tags share. Injected script cannot know it.

Why it matters

The same-origin policy is what keeps another site from reading your users' data, and XSS is the way around it: the attacker's code is not on another site, it is on yours. It can read anything the page shows, including CSRF (cross-site request forgery) tokens and data from API calls, change what the user sees, and send any request the user could send, with their cookies attached. A single unescaped field in an admin panel turns into an attacker acting as an administrator. It is one of the most reported vulnerability classes in bug bounty programs year after year, and every framework migration or new rich-text feature creates new chances to reintroduce it.

How injected script takes over a page

Every XSS has the same shape: attacker-controlled text travels from a source to a sink without being turned into inert text on the way. The three named kinds differ only in where the text is stored and who builds the page. In stored XSS the server saves the payload and serves it to everyone who views it, which is why it spreads.

Attacker1 · posts commentApp + DB2 · serves pageVictim browser3 · runs itInjected script4 · sends data outAttacker serverThe browser cannot tell injected script from your own code.HttpOnly hides the cookie; the script still calls your API as the user.
Stored XSS. The payload is saved once and runs in every victim's browser as part of your page, so it has your origin's access to the DOM and to your API.
KindWhere the payload livesTypical sink
StoredIn your database: a comment, profile field, file name, support ticketServer-rendered template or client code that renders the stored value as HTML
ReflectedIn the request: a URL parameter or form field echoed in the responseSearch pages, error messages, redirect pages that print the input back
DOM-basedNever reaches the server: location.hash, postMessage, local storageinnerHTML, document.write, eval, an href set from the URL

Encode for the context, at the point of output

The root fix is making the value inert where it is used. "Inert" depends on the context: inside HTML text, < must become &lt;; inside a quoted attribute the quote character matters too; inside a URL the scheme matters, because javascript: URLs execute without any angle brackets at all. That is why encoding belongs at output, where the context is known, and why one "sanitize everything on input" function is never enough.

ContextExampleWhat makes it safe
HTML text<p>{name}</p>HTML-encode & < > " ' (what textContent and template escaping do)
Attribute<input value="{name}">Always quote the attribute and encode the quote; never put input in event handler attributes
URL<a href="{link}">Parse it and allow only http: and https:, then encodeURIComponent any parts you build
JavaScript<script>var x = {data}</script>Avoid: put JSON in a data attribute or escape < as \u003c so </script> cannot close the tag
CSSstyle="color: {c}"Allowlist the values (a fixed set of colours) instead of encoding
vulnerable.js
// comment.body = '<img src=x onerror="steal()">'
const li = document.createElement('li')
li.innerHTML = comment.author + ': ' + comment.body
list.append(li)
// the browser parses an <img>, the load fails,
// and the onerror handler runs steal()
fixed.js
const li = document.createElement('li')
li.textContent = comment.author + ': ' + comment.body
list.append(li)              // rendered as text, nothing runs

// user HTML on purpose (rich text)? sanitize it first
import DOMPurify from 'dompurify'
li.innerHTML = DOMPurify.sanitize(comment.bodyHtml)

What frameworks do, and the escape hatches

Modern templating escapes by default: React, Vue, Angular, Svelte and server-side engines like Jinja or ERB encode interpolated values as HTML text. That removes most classic XSS, and it is why the remaining bugs cluster around the places where a framework is told to stop escaping, or where a value is used in a context the escaping does not cover, such as a URL.

FrameworkEscapes by defaultEscape hatch to review
React{value}dangerouslySetInnerHTML; an href from user input
Vue{{ value }}v-html
AngularInterpolation, and sanitizes [innerHTML]bypassSecurityTrustHtml
Svelte{value}{@html value}
Jinja / Django{{ value }}|safe, mark_safe
Profile.tsx
// safe: React encodes text children
<p>{user.bio}</p>

// sink: React renders this string as HTML
<div dangerouslySetInnerHTML={{__html: user.bioHtml}} />

// sink: escaping does not help, the scheme is the problem
<a href={user.website}>Website</a>
// user.website = 'javascript:steal()' runs on click

// fix: allow only web URLs
const safe = /^https?:\/\//i.test(user.website) ? user.website : '#'

CSP: stop injected script from running

A Content Security Policy is a response header that tells the browser which scripts the page is allowed to run. The robust form is a strict CSP: every legitimate script tag carries a nonce attribute that matches a random value in the header, generated fresh for each response. An injected script tag or inline event handler has no matching nonce, so the browser refuses to run it even though the HTML was already injected. 'strict-dynamic' lets a nonced script load further scripts, so bundlers and tag managers keep working.

Older policies listed allowed hosts instead (script-src cdn.example.com). A 2016 Google study of real-world policies found about 95% of them could be bypassed, mostly because an allowed host also served something an attacker could abuse, such as a JSONP endpoint or an old AngularJS copy. That paper is where nonces plus 'strict-dynamic' came from.

response-headers.http
Content-Security-Policy:
  script-src 'nonce-R4nd0mPerResp0nse' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  report-to csp
Reporting-Endpoints: csp="https://example.com/csp-reports"
proxy.ts
// Next.js 16 calls this file proxy.ts (middleware.ts before);
// any server framework does the same per response.
import {NextResponse, type NextRequest} from 'next/server'

export function proxy(req: NextRequest) {
  const nonce = btoa(crypto.randomUUID())   // new for every response
  const csp = [
    `script-src 'nonce-${nonce}' 'strict-dynamic'`,
    "object-src 'none'",
    "base-uri 'none'",
  ].join('; ')

  const headers = new Headers(req.headers)
  headers.set('x-nonce', nonce)             // the layout reads it
  const res = NextResponse.next({request: {headers}})
  res.headers.set('Content-Security-Policy', csp)
  return res
}

Rolling it out without breaking the site

  1. 1
    Ship the policy as Content-Security-Policy-Report-Only. The browser runs everything as before but reports each script the real policy would have blocked.
  2. 2
    Fix what the reports show: add the nonce to your own script tags, move inline onclick= handlers into addEventListener calls, and replace javascript: links with buttons.
  3. 3
    When reports are down to noise (browser extensions inject scripts too), switch the header name to Content-Security-Policy and keep report-to so new violations still surface.
  4. 4
    Optionally add require-trusted-types-for 'script'. With Trusted Types, assigning a plain string to innerHTML and similar DOM sinks throws, so the only way to write HTML is through a named policy, usually wrapping DOMPurify. That closes DOM XSS at the sink. Supported in Chromium since 83; check other engines before relying on it.

Pitfalls

  • Sanitizing input instead of encoding output

    At input time you do not know where a value will be used: an HTML page, an attribute, a URL, a CSV export, an email. Stripping characters on the way in mangles legitimate data and still misses contexts like javascript: URLs. Store what the user typed, and encode for the context at each place it is output.

  • Assuming the framework covers everything

    Auto-escaping only applies to interpolated text. The escape hatches, URLs in href and src, JSON embedded in script tags and any direct DOM code all bypass it. Grep for the escape-hatch names in review and treat each one as a security decision.

  • An allowlist CSP with unsafe-inline

    'unsafe-inline' allows exactly the inline script and event handlers that injected payloads use, and host allowlists are usually bypassable through something else on an allowed host. Such a policy looks protective in a scanner and stops very little. Use nonces with 'strict-dynamic'.

  • Reusing a nonce

    A nonce only works if the attacker cannot predict it. A constant nonce, or an HTML page with its nonce cached by a CDN and served to everyone, lets an attacker copy the value into the payload. Generate it per response, and do not cache HTML that embeds one, or use hashes for static pages instead.

  • Believing HttpOnly prevents XSS

    HttpOnly stops script from reading the session cookie, so the attacker cannot take it away and reuse it later. But the injected script runs in the user's browser, where the cookie is attached to every request automatically, so it can still change the email address, create an API key or transfer money while the page is open.

Interview questions

Q1What is XSS and why is it so damaging?

XSS is attacker-controlled text being interpreted as markup or script in your page, so the attacker's code runs on your origin. The same-origin policy then works for the attacker: the script can read everything on the page and send any request the user could, with their cookies. In practice that means account takeover, data theft and actions taken as the user, including admins.

Q2What is the difference between stored, reflected and DOM-based XSS?

They differ in where the payload lives. Stored XSS is saved on the server, such as in a comment, and hits everyone who views it. Reflected XSS comes in the request, usually a URL parameter, and is echoed back, so the attacker has to get the victim to open a link. DOM-based XSS never touches the server: client code takes a value from the URL or a message and writes it into a sink like innerHTML.

Q3React escapes everything, so we are safe from XSS, right?

No. React escapes text children, which removes the classic case, but dangerouslySetInnerHTML renders raw HTML, an href set from user input can be a javascript: URL, data serialized into a script tag can break out, and any direct DOM code bypasses React. Those are exactly where XSS shows up in React apps, so each needs validation, sanitizing or a CSP behind it.

Q4Walk me through rolling out a strict CSP on an existing app.

I generate a random nonce per response, add it to every script tag the app renders, and send a policy of script-src with that nonce and strict-dynamic, object-src none and base-uri none, first as Report-Only. Reports show inline handlers and scripts without the nonce, which I move into code or tag properly. Once reports are only extension noise, I switch to enforcing, keep reporting on, and make sure HTML containing a nonce is never cached and shared.

Q5What happens when an attacker injects a script tag into a page with a nonce-based CSP?

The HTML is injected, but the browser refuses to execute the script because it does not carry the nonce from this response's header, and it sends a violation report if reporting is on. Inline event handlers like onerror are blocked the same way. The bug still exists, so I would fix the encoding, but CSP turned it from account takeover into a harmless markup glitch.

Q6Our session cookie is HttpOnly. What can XSS still do?

Everything the user can do while the page is open. HttpOnly prevents reading and exfiltrating the cookie, but the browser still attaches it to every request the injected script makes, so the script can call the API to change the email, read private data from responses or create credentials it can use later.

Q7Should you sanitize on input or encode on output?

Encode on output, because only the output point knows the context: HTML text, an attribute, a URL or JSON. Input validation is still useful for business rules, like an email format, but it is not the XSS defence. Sanitizing is a separate tool for when you deliberately accept user HTML, and it should use a parser-based library such as DOMPurify.

Q8How is CSP different from CORS?

They protect in opposite directions. CORS is set by a server to say which other origins may read its responses from the browser. CSP is set on your page to say which scripts and resources that page may load and run. A site can have perfect CORS and still be wide open to XSS, and the reverse.

Key takeaways
  • XSS is attacker text interpreted as markup or script on your origin, so it can read the page and act as the user.
  • Stored, reflected and DOM XSS differ only in where the payload lives; all of them end in a sink like innerHTML, eval or an href.
  • Encode at output for the exact context; sanitize with a real parser only when you deliberately accept HTML.
  • Framework auto-escaping covers text, not escape hatches, URLs or JSON in script tags. Review those as security decisions.
  • A nonce-based CSP with strict-dynamic stops injected script from running; roll it out Report-Only first and never cache a nonce.
  • HttpOnly stops cookie theft, not XSS: the injected script still makes requests with the cookie attached.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free