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
innerHTMLoreval. - Escaping
- Replacing special characters like
<with safe codes like<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
integrityhash 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.
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.
// 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.
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.
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
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
Test more URLs
In the second example, add
"HTTPS://Hectal.in"and"//evil.example/x"toinputs. 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
Inspect real headers
Open any big site, then DevTools > Network > the first document request > Response Headers. Find
content-security-policy(if present) and read itsscript-src. Then check Application > Cookies for theHttpOnlyandSameSitecolumns.
Code & diagrams
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
Console
Nothing logged yet.Expected console output
elements from innerHTML: 2
elements from textContent: 0
Great post! <b>Loved</b> it <i>a lot</i>
elements after escaping: 1The 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
Console
Nothing logged yet.Expected console output
https://hectal.in/javascript
https://shop.example/profile
mailto:help@hectal.in
null
null
null
nullThe 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
Console
Nothing logged yet.Expected console output
0 1
noopener noreferrer nofollow ugcNot 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;.
Break #2
Check the scheme with a string test
Replace safeHref with (s) => (s.startsWith("javascript:") ? null : s).
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.hashorURLSearchParamsand writing it to the DOM. Trusted Types turns every such sink into a runtime error, which makes these bugs visible in tests. - ▸
SameSite=Laxstill sends cookies on top-levelGETnavigations from other sites, which is whyGETmust never change state. "Same-site" means same registrable domain, so a compromised subdomain is not blocked by SameSite. - ▸
A sandboxed
<iframe sandbox="allow-scripts">withoutallow-same-originruns 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
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),postMessagedata,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
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 (likeinnerHTML). - 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"), andhref/srcattributes set from user input. Safe choices:textContent(always plain text),createElementplusappend,setAttributefor non-URL attributes, andclassList. 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
URLs are a separate trap. Escaping does not make a link safe: a
javascript:URL in anhrefruns code when clicked, anddata:URLs can carry documents. Parse user URLs withnew URL(input, base)and allowlist the protocol (https:,http:, maybemailto:). For links that open a new tab, browsers now implyrel="noopener"withtarget="_blank", but settingrel="noopener noreferrer"explicitly is still good practice; it stops the new page controlling yours throughwindow.opener. - 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="...") andeval, and setsobject-src 'none'andbase-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 withContent-Security-Policy-Report-Onlyfirst and watch the reports; verify by checking the header in DevTools Network and reading console violation messages. - 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) orStrict, plusSecureandHttpOnly(so JavaScript can't read them); require a CSRF token (a random value the server checks) or a custom header likeX-CSRF-Tokenon state-changing requests; check theOriginheader on the server; and never change data onGET. 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
What is XSS, and what are the main ways to prevent it in front-end code?
What is the difference between stored, reflected and DOM-based XSS?
What does a Content Security Policy do, and how would you roll one out safely?
What is CSRF and how do SameSite cookies and tokens stop it?
How do you reduce the risk of third-party scripts?
Practice
Rewrite this safely: card.innerHTML = "<h3>" + user.name + "</h3><p>" + user.bio + "</p>";.
Write isSafeImageUrl(input) that accepts only https: URLs on your own host img.hectal.example.
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 URLand 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.