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.
| Type | Data path | Fix lives in |
|---|---|---|
| Stored (persistent) | Input saved server side, served to other users later | Output encoding at render time |
| Reflected | Input echoed straight back in the response to the same request | Output encoding at render time |
| DOM-based | Input never reaches the server, client JS writes it into the DOM | Safe 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.
| Context | Encode as | Notes |
|---|---|---|
HTML text (<p>HERE</p>) | HTML entities for & < > " ' | The common case |
Attribute value (title="HERE") | HTML entities, and always quote the attribute | An unquoted attribute can be escaped with a space |
Inside <script> | JavaScript string escaping, or better: do not | Serialize 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 value | Validate the scheme, then encode | Reject anything that is not http:, https:, mailto: |
| CSS value | CSS escaping, or do not allow it | Prefer 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.
| Stack | Safe by default | The 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.
| Avoid | Use instead |
|---|---|
el.innerHTML = x | el.textContent = x |
el.outerHTML = x | Build 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 = x | Validate the scheme first |
location = x, location.href = x | Validate 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'
| Piece | Why 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.