Command Palette

Search for a command to run...

Hectal
PHASE 15Advanced ~29 min· topic 4 of 8

Topic 15.4

Web Security for JavaScript Developers

In one line

Most front-end security bugs come from treating untrusted text as code: prevent XSS by keeping data as text (textContent, safe DOM APIs, escaping, URL checks), limit the damage with a Content Security Policy, block CSRF with SameSite cookies and tokens, and treat every third-party script as code with full access to your page.

Think of it like this

A shop notice board. Customers may pin notes, and staff read them out loud. A careless reader treats every note as an instruction ("Note says: open the till") and obeys it. A careful reader treats notes as text to read out, never as orders. Web security is mostly that rule: text that came from someone else is data, and your page must never let it become instructions (code).

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Untrusted input
Any data your code didn't create itself: form fields, URLs, API data from users, storage.
XSS
Cross-Site Scripting: an attack where someone's text ends up running as code inside your page.
Sink
An API that can turn a string into HTML or code, such as innerHTML or eval.
Escaping
Replacing special characters like < with safe codes like &lt; so the browser shows them instead of obeying them.
Sanitizer
A tool that cleans HTML by keeping only allowed tags and attributes, such as DOMPurify.
CSP
Content Security Policy: a header that tells the browser which scripts and resources a page may load and run.
Nonce
A random value, new for each page response, that marks which scripts are allowed to run under CSP.
CSRF
Cross-Site Request Forgery: another site making the user's browser send an unwanted request to your site with their cookies.
SameSite
A cookie setting that stops the browser sending the cookie on requests started by other sites.
Subresource Integrity
An integrity hash on a script tag; the browser refuses the file if its contents don't match.

Step by step

01Find where untrusted data enters

List the sources in your feature: form fields, location.search and location.hash, API responses containing user content, postMessage, storage. Each one is text you didn't write.

02Follow it to where it is used

Search the code for dangerous sinks: innerHTML, insertAdjacentHTML, outerHTML, document.write, eval, new Function, string timers, and href/src assignments. Any path from a source to a sink is a potential XSS bug.

Follow it to where it is useddiagram
Rendering diagram…

03Switch to a safe API

Most of the time the fix is a one-word change: textContent instead of innerHTML. When you need structure, build it with createElement and put user text into it with textContent.

render.jswhole filejavascript
// Risky: el.innerHTML = "<strong>" + user.name + "</strong>";
const strong = document.createElement("strong");
strong.textContent = user.name;
el.replaceChildren(strong);

04Validate every user-supplied URL

Parse it with new URL() and keep it only if the protocol is on your allowlist. Anything that fails to parse is rejected.

05Add a Content Security Policy

The server sends a CSP header. Start with Content-Security-Policy-Report-Only so nothing breaks, read the violation reports, fix them, then switch to the enforcing header.

response headerswhole filehttp
Content-Security-Policy: script-src 'nonce-R4nd0mPerResponse' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'

06Protect state-changing requests from CSRF

Session cookie: Set-Cookie: session=...; Secure; HttpOnly; SameSite=Lax. Every POST/PUT/DELETE also sends a token the server issued, which another site can't read or guess.

api.jswhole filejavascript
const token = document.querySelector('meta[name="csrf-token"]').content;
await fetch("/api/profile", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-CSRF-Token": token },
  body: JSON.stringify({ bio }),
});

07Verify the defences

In DevTools Network, click the document request and read the response headers: is the CSP there? Are cookies HttpOnly, Secure and SameSite (Application > Cookies)? Type harmless markup such as <b>test</b> into every input: it must appear as literal text. Add automated tests that render tricky strings and assert no new elements appear, and run a scanner (such as OWASP ZAP) in CI against a test environment.

Try it yourself

  1. 1

    See the difference yourself

    In the first example, change the comment to "Hi <u>there</u>". Predict what each line on the page shows and the first two console lines, then run it.

  2. 2

    Test more URLs

    In the second example, add "HTTPS://Hectal.in" and "//evil.example/x" to inputs. Predict both results. (Hint: the second is a protocol-relative URL: it takes the base's protocol but a different host.) Should your app allow links to other hosts at all?

  3. 3

    Inspect real headers

    Open any big site, then DevTools > Network > the first document request > Response Headers. Find content-security-policy (if present) and read its script-src. Then check Application > Cookies for the HttpOnly and SameSite columns.

Code & diagrams

Text versus markup: innerHTML parses, textContent doesn't New tab

The user's text created real `<b>` and `<i>` elements through `innerHTML`. Bold text is harmless, but it proves the browser obeyed the user's markup, and the same path could carry code. With `textContent` or escaping, it stays visible text. `&` is escaped first so the other entities aren't double-escaped.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

elements from innerHTML: 2
elements from textContent: 0
Great post! &lt;b&gt;Loved&lt;/b&gt; it &lt;i&gt;a lot&lt;/i&gt;
elements after escaping: 1
Allowlist the protocol of user-supplied links New tab

The URL parser strips leading spaces and lower-cases the scheme, so `" JavaScript:..."` is recognised and blocked; a naive `startsWith("javascript:")` check would miss it. Unparseable input throws and is rejected.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

https://hectal.in/javascript
https://shop.example/profile
mailto:help@hectal.in
null
null
null
null
Render user content safely with the DOM New tab

The second author's markup shows as literal text and its `javascript:` link was dropped. `append` with a string adds a text node, never HTML. `ugc` tells search engines the link is user-generated.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

0 1
noopener noreferrer nofollow ugc
Defence in depth: CSP, Trusted Types and Subresource Integrityhtml

Not live: CSP comes from response headers, and this preview runs its own inline scripts. The nonce must be a new unguessable value for every response.

<!-- Header sent by the server (preferred over a meta tag) -->
<!-- Content-Security-Policy: script-src 'nonce-R4nd0mPerResponse' 'strict-dynamic'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script' -->

<!-- Only scripts carrying this response's nonce run -->
<script nonce="R4nd0mPerResponse" src="/app.js" defer></script>

<!-- A third-party file from a CDN: the browser checks its hash before running it -->
<script src="https://cdn.example/lib@4.2.1/lib.min.js"
        integrity="sha384-(hash of the exact file)"
        crossorigin="anonymous"></script>

<script nonce="R4nd0mPerResponse">
  // With Trusted Types enforced, el.innerHTML = someString throws a TypeError.
  // Only values made by a named policy are accepted, so every HTML sink goes through one reviewed place:
  const policy = trustedTypes.createPolicy("sanitize", {
    createHTML: (input) => DOMPurify.sanitize(input),
  });
  // el.innerHTML = policy.createHTML(userHtml);

  // Verify: listen for violations (and configure report-to on the server)
  document.addEventListener("securitypolicyviolation", (e) => {
    console.warn("CSP blocked", e.blockedURI, "because of", e.violatedDirective);
  });
</script>

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Render a name with innerHTML

In the third example, replace name.textContent = c.author; with name.innerHTML = c.author;.

terminal
$ Console
── what you'll see ──
1 1
noopener noreferrer nofollow ugc

Break #2

Check the scheme with a string test

Replace safeHref with (s) => (s.startsWith("javascript:") ? null : s).

terminal
$ Console
── what you'll see ──
The " JavaScript:void(0)" input is now allowed, and so is the data: URL.

Myth vs fact

Myth

Escaping < and > makes any string safe anywhere.

Fact

Safety depends on context. HTML escaping protects text between tags; it does not make a javascript: URL safe in href, or a value safe inside a <script> or a CSS string.

Myth

Validating the form in JavaScript protects the server.

Fact

Anyone can send requests without your page (with curl or DevTools). Client-side checks are for user experience; the server must validate and authorise every request.

Myth

A CSP means XSS bugs no longer matter.

Fact

CSP limits the damage and blocks many attacks, but loose policies ('unsafe-inline', wide host lists) are easy to bypass. Fix the bug and keep CSP as a second wall.

Myth

Storing the login token in localStorage is fine because other sites can't read it.

Fact

Other sites can't, but any script running on your page can, including injected or third-party ones. HttpOnly cookies can't be read by JavaScript at all (see Topic 11.3).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Strict CSP with nonces plus 'strict-dynamic' lets nonce-trusted scripts load further scripts, which makes it work with bundlers' dynamic chunks, and makes host allowlists unnecessary. Google's research found most host-allowlist policies were bypassable.

  • ▸

    DOM-based XSS often hides in routing code: reading location.hash or URLSearchParams and writing it to the DOM. Trusted Types turns every such sink into a runtime error, which makes these bugs visible in tests.

  • ▸

    SameSite=Lax still sends cookies on top-level GET navigations from other sites, which is why GET must never change state. "Same-site" means same registrable domain, so a compromised subdomain is not blocked by SameSite.

  • ▸

    A sandboxed <iframe sandbox="allow-scripts"> without allow-same-origin runs content in an opaque origin: it can't read your cookies, storage or DOM. Hectal's own live previews run this way.

Remember this

  1. 1

    Untrusted input is anything your code didn't write itself: form fields, URL parameters (location.search, location.hash), data from your own API that came from users (names, comments), postMessage data, localStorage. Treat it as hostile until handled. Note that validating input in the browser is for user experience only; an attacker can skip your JavaScript entirely, so the server must validate everything again (see /system-design).

  2. 2

    XSS (Cross-Site Scripting) happens when untrusted text gets interpreted as code in your page: as HTML that the browser parses, as an attribute, a URL, or JavaScript. Injected code runs with your page's full power: it can read what the user types, call your APIs as them, and change what they see. Stored XSS comes from saved data (a comment), reflected XSS from the URL of one request, and DOM-based XSS happens entirely in front-end code that copies a source (like location.hash) into a dangerous sink (like innerHTML).

  3. 3

    Prevention: use safe sinks. A sink is an API that can turn a string into markup or code. Dangerous sinks: innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, new Function, setTimeout("string"), and href/src attributes set from user input. Safe choices: textContent (always plain text), createElement plus append, setAttribute for non-URL attributes, and classList. If you must insert user-provided HTML (a rich-text editor), pass it through a well-maintained sanitizer such as DOMPurify with an allowlist, never a home-made regex. When building HTML strings, escape &, <, >, " and ' into entities. Frameworks (React, Vue, Angular, Lit) escape text by default; their escape hatches (dangerouslySetInnerHTML, v-html) bring the risk back.

  4. 4

    URLs are a separate trap. Escaping does not make a link safe: a javascript: URL in an href runs code when clicked, and data: URLs can carry documents. Parse user URLs with new URL(input, base) and allowlist the protocol (https:, http:, maybe mailto:). For links that open a new tab, browsers now imply rel="noopener" with target="_blank", but setting rel="noopener noreferrer" explicitly is still good practice; it stops the new page controlling yours through window.opener.

  5. 5

    CSP (Content Security Policy) is a response header that tells the browser which scripts may run, a second wall in case an XSS bug slips through. A strict CSP allows only scripts carrying a per-response random nonce or a hash, blocks inline event handlers (onclick="...") and eval, and sets object-src 'none' and base-uri 'none'. Trusted Types (require-trusted-types-for 'script', shipped first in Chromium; check current support in other engines) goes further: dangerous sinks refuse plain strings. Roll a policy out with Content-Security-Policy-Report-Only first and watch the reports; verify by checking the header in DevTools Network and reading console violation messages.

  6. 6

    CSRF (Cross-Site Request Forgery) tricks a logged-in user's browser into sending a request to your site from another site, with the user's cookies attached automatically. Defences: set session cookies with SameSite=Lax (the default in Chromium when unset) or Strict, plus Secure and HttpOnly (so JavaScript can't read them); require a CSRF token (a random value the server checks) or a custom header like X-CSRF-Token on state-changing requests; check the Origin header on the server; and never change data on GET. Third-party scripts (analytics, chat widgets, ads) run with the same power as your own code: load as few as possible, pin versions, use Subresource Integrity (integrity="sha384-...") for files from CDNs, list allowed hosts in your CSP, and isolate untrusted widgets in a sandboxed <iframe>.

Explain it without notes

01

What is XSS, and what are the main ways to prevent it in front-end code?

02

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

03

What does a Content Security Policy do, and how would you roll one out safely?

04

What is CSRF and how do SameSite cookies and tokens stop it?

05

How do you reduce the risk of third-party scripts?

Practice

01

Rewrite this safely: card.innerHTML = "<h3>" + user.name + "</h3><p>" + user.bio + "</p>";.

02

Write isSafeImageUrl(input) that accepts only https: URLs on your own host img.hectal.example.

03

Write the response headers for a session cookie and a strict CSP for a page that loads only /app.js with a nonce.

Trade-offs

  • ↔

    A strict nonce-based CSP blocks most XSS but needs server support (a new nonce per response) and work to remove inline handlers and some third-party tags.

  • ↔

    Allowing user HTML (rich text) gives users more expression but means maintaining a sanitizer, an allowlist and CSP; plain text or Markdown rendered by a safe library is much easier to secure.

Done when you can

  • I can list dangerous DOM sinks and their safe replacements.

  • I can explain stored, reflected and DOM-based XSS and prevent each.

  • I can validate user URLs with new URL and a protocol allowlist.

  • I can describe a strict CSP and how to roll it out and verify it.

  • I can explain CSRF and the cookie settings and tokens that stop it.

  • I can reduce third-party script risk with SRI, CSP and sandboxed iframes.