Web Security Cheatsheet

Cross-Site Scripting

Use this Web Security reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

The three flavors

XSS is untrusted input rendered into a page such that the browser executes it as script. It sits inside Injection (A05:2025). The three variants differ in where the untrusted data travels, which changes where you fix it.

TypeData pathFix lives in
Stored (persistent)Input saved server side, served to other users laterOutput encoding at render time
ReflectedInput echoed straight back in the response to the same requestOutput encoding at render time
DOM-basedInput never reaches the server, client JS writes it into the DOMSafe DOM APIs in the client code

Stored is the most severe, because one payload reaches every viewer. DOM-based is the easiest to miss, because server-side templating is correct and the bug is in a .js file.

// DOM-based: location.hash flows into an HTML sink
document.getElementById('out').innerHTML = location.hash.slice(1)

Contextual output encoding

There is no single "escape function". The correct encoding depends on where the value lands in the document, and using the wrong one leaves the hole open.

ContextEncode asNotes
HTML text (<p>HERE</p>)HTML entities for & < > " 'The common case
Attribute value (title="HERE")HTML entities, and always quote the attributeAn unquoted attribute can be escaped with a space
Inside <script>JavaScript string escaping, or better: do notSerialize as JSON in a <script type="application/json"> and parse it
URL parameter (?q=HERE)Percent-encoding (encodeURIComponent)Encoding alone does not vet the scheme
Whole href/src valueValidate the scheme, then encodeReject anything that is not http:, https:, mailto:
CSS valueCSS escaping, or do not allow itPrefer a class name from a fixed set
// Reject dangerous schemes before a URL reaches an href
function safeHref(raw) {
  try {
    const u = new URL(raw, window.location.origin)
    return ['http:', 'https:', 'mailto:'].includes(u.protocol) ? u.href : '#'
  } catch { return '#' }
}

Never put untrusted data in a <script> block, an event handler attribute, a style block, or a comment. Those contexts have no reliable encoding.

Let the framework do it

Modern template engines auto-escape HTML text context. The bug is almost always an explicit opt-out, so learn the name of the escape hatch in your stack.

StackSafe by defaultThe dangerous opt-out
React / Preact{value}dangerouslySetInnerHTML
Vue{{ value }}v-html
Angular{{ value }}bypassSecurityTrustHtml
Svelte{value}{@html value}
Jinja2 / Flask{{ value }}| safe, Markup(), autoescape false
Django{{ value }}| safe, mark_safe(), autoescape off
Go html/template{{ . }}template.HTML, and using text/template
ERB / Rails<%= value %>raw, html_safe, <%== %>
Handlebars{{ value }}{{{ value }}}
// Safe: React escapes text children
<p>{comment.body}</p>

// Unsafe unless `body` was sanitized by a real HTML sanitizer
<p dangerouslySetInnerHTML={{ __html: comment.body }} />

Safe DOM APIs

For DOM XSS the fix is choosing a sink that cannot parse HTML. Assigning text is always safe, assigning markup never is.

AvoidUse instead
el.innerHTML = xel.textContent = x
el.outerHTML = xBuild nodes with createElement and append
document.write(x)Nothing, remove it
el.insertAdjacentHTML(p, x)el.insertAdjacentText(p, x)
eval(x), new Function(x)JSON.parse(x)
setTimeout('code')setTimeout(() => {...})
el.setAttribute('onclick', x)el.addEventListener('click', fn)
a.href = xValidate the scheme first
location = x, location.href = xValidate against an allowlist of paths
// Safe by construction
const li = document.createElement('li')
li.textContent = user.name
list.append(li)

When you genuinely must render user-authored HTML, such as a rich-text comment, use a maintained sanitizer with an allowlist. Do not write your own, and do not sanitize with a regex.

import DOMPurify from 'dompurify'
el.innerHTML = DOMPurify.sanitize(userHtml)  // still pair with CSP

A CSP that actually mitigates XSS

A host-allowlist CSP is largely decorative, because a single permitted CDN with a JSONP endpoint or an old framework reintroduces execution. Use a nonce-based strict CSP, which is what Google's and the W3C's current guidance recommends.

Content-Security-Policy:
  script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';
  require-trusted-types-for 'script';
  frame-ancestors 'self'
PieceWhy it is there
'nonce-{RANDOM}'A fresh, unguessable, per-response value. Only your own tags carry it.
'strict-dynamic'Lets a trusted script load more scripts, so bundlers keep working
https: 'unsafe-inline'Ignored by browsers that honor the nonce, they exist as fallbacks for old ones
object-src 'none'Kills <object>/<embed> plugin execution
base-uri 'none'Stops <base> injection from rewriting every relative URL
require-trusted-types-for 'script'Turns DOM XSS sinks into runtime errors
frame-ancestors 'self'Clickjacking control, replaces X-Frame-Options

The nonce must be regenerated per response from a CSPRNG and never reused. A static nonce is no protection at all.

// Express, per-response nonce
app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64')
  res.locals.nonce = nonce
  res.setHeader('Content-Security-Policy',
    `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
    `object-src 'none'; base-uri 'none'`)
  next()
})

'unsafe-inline' without a nonce, and 'unsafe-eval', both defeat the policy. Roll out with Content-Security-Policy-Report-Only plus a report-to endpoint first, read the violations, then enforce.

Trusted Types

Trusted Types moves DOM XSS from a code-review problem to a runtime-enforced one. With require-trusted-types-for 'script' set, assigning a plain string to innerHTML throws instead of parsing.

// Content-Security-Policy: require-trusted-types-for 'script';
//                          trusted-types app-policy
const policy = trustedTypes.createPolicy('app-policy', {
  createHTML: (s) => DOMPurify.sanitize(s),
})
el.innerHTML = policy.createHTML(userHtml)   // allowed
el.innerHTML = userHtml                      // throws

Every unsafe sink now funnels through one auditable policy object, which is far easier to review than every innerHTML in a codebase.

Recognizing it in review

grep -rn "dangerouslySetInnerHTML\|v-html\|{@html\|{{{" src/
grep -rn "innerHTML\s*=\|outerHTML\s*=\|insertAdjacentHTML\|document.write" src/
grep -rnE "\|\s*safe\b|mark_safe\(|html_safe|raw\(" templates/ app/
grep -rn "unsafe-inline\|unsafe-eval" .

Checklist:

- Auto-escaping is on, and every opt-out has a sanitizer and a comment explaining it. - Every attribute in a template is quoted. - No untrusted value lands inside <script>, style, or an on* attribute. - URLs in href/src are scheme-validated, not just encoded. - Server-rendered JSON is delivered via <script type="application/json"> and JSON.parse, not interpolated into executable JS. - Responses carry a correct Content-Type plus X-Content-Type-Options: nosniff, so a JSON or text response is never sniffed as HTML. - User-uploaded files are served from a separate origin with Content-Disposition: attachment. - A nonce-based CSP is enforced, and the nonce is per response. - Session cookies are HttpOnly, which limits what a successful XSS can steal.

Further reading: the OWASP XSS Prevention Cheat Sheet and MDN CSP.