Security for Developers

XSS, CSRF, Clickjacking and Security Headers

How a script gets into your page and how to stop it with output encoding and CSP, how CSRF uses your cookies, SameSite and CSRF tokens, frame-ancestors, and the headers worth setting.

Lesson 0216 min read#security#xss#csrf#csp#headers

On this page

  1. Words used in this lesson
  2. XSS: when text becomes a script
  3. CSP: a safety net behind the encoding
  4. CSRF: the browser sends the cookie for you
  5. Clickjacking: an invisible frame
  6. The security headers worth setting
  7. Putting the picture together
  8. Try it
  9. Interview questions
  10. Key takeaways
  11. What's next

In one line: XSS is injection into a web page: user text becomes code in someone else's browser. Stop it by encoding output for the place where it lands, and add a Content Security Policy as a safety net. CSRF is a different trick: a bad site makes the user's browser send a real request with the user's real cookie. Stop it with SameSite cookies and a CSRF token. A few response headers close other doors, such as clickjacking.

Lesson 1 was injection on the server: user data ended up inside SQL or a shell command. This lesson is the same mistake, but inside the browser. It also covers a cousin attack, CSRF, which does not inject anything and instead tricks the browser into using its own login. We run a tiny local server, drive a real Chromium against it, and see each attack work and then fail.

You can read Browser Lesson 7 (defence in depth for the front-end) and Browser Lesson 3 (where to keep tokens) next to this one. Cookies and sessions are explained in Browser Lesson 2, and the rules for cross-site requests in Browser Lesson 4 (CORS). The next lesson, Passwords and login, is about the thing CSRF and XSS aim at: the session.

What I ran / checked. All of it ran on Linux with Node.js v22.22.0 and a plain node:http server on localhost:3100. I used curl 8.5.0 to see headers. To see what a real browser does I used Playwright 1.56.1 driving Chromium 141.0.7390.37 (installed locally at /opt/pw-browsers). For the CSRF test I served the "evil" page from 127.0.0.1:3200 and the "bank" from localhost:3100. The browser treats those as two different sites, which is what makes it a real cross-site test. For headers I installed helmet 8.3.0 from npm and printed what it sends. Everything attacked is my own toy server. Sources I read (ideas in my own words): OWASP Cheat Sheet Series pages Cross Site Scripting Prevention, Cross-Site Request Forgery Prevention, Content Security Policy, Clickjacking Defense and HTTP Headers, and the OWASP Top 10:2025 pages for A01 and A05. Could not reach: owasp.org itself, PortSwigger Web Security Academy and MDN were blocked from my machine, so I did not read them for this lesson. Things I know from general knowledge and did not verify in a source: that Chrome has a short exception that lets cookies without a SameSite setting go on cross-site POST requests (see the trap below, I saw the effect, I did not read the rule), and the usual lists of what each header does beyond what the OWASP pages said. I did not test Firefox or Safari. The toy CSRF token in my server is a fixed string, only good for a demo.

Words used in this lesson

  • XSS (cross-site scripting) is when an attacker gets their JavaScript to run in another user's browser, on your site.
  • Reflected XSS means the bad text is in the request (for example a URL) and the server sends it straight back in the page.
  • Stored XSS means the bad text is saved (for example in a comment) and shown to every visitor later.
  • DOM-based XSS means the page's own JavaScript writes bad text into the page, for example with innerHTML.
  • Output encoding (or escaping) means changing special characters like < into safe codes like <, so the browser shows them as text.
  • CSP (Content Security Policy) is a response header that tells the browser which scripts and resources it may run or load.
  • CSRF (cross-site request forgery) is when a different site makes the user's browser send a request to your site, and the browser attaches the user's cookie.
  • A cookie is a small value the browser stores and sends back to the site that set it.
  • A site is the registrable domain (like example.com) and its scheme. Same-site and same-origin are not the same thing; the port does not matter for same-site.
  • SameSite is a cookie setting (Strict, Lax or None) that says if the cookie is sent on requests that start from another site.
  • A CSRF token is a random secret value that your page includes in each form, and that the server checks. A different site cannot read it.
  • Clickjacking means a bad page loads your site in an invisible frame and tricks the user into clicking on it.
  • frame-ancestors is a CSP setting that says which sites may put your page in a frame.
  • A security header is a response header that tells the browser to turn on a protection.
  • HttpOnly is a cookie setting that hides the cookie from JavaScript.

XSS: when text becomes a script

Think of a search page: you type shoes and it shows "Results for shoes". The server has to put your text into the HTML. If it puts it in without changing it, the browser cannot tell your text from the page's own HTML.

Here is the whole vulnerable server route:

JavaScript
// vulnerable: the query text goes straight into the HTMLif (u.pathname === "/bad") out = `<h1>Results for ${q}</h1>`;

Now someone opens http://localhost:3100/bad?q=<script>alert(1)</script>. The server sends this (I fetched it with curl):

TEXT
<!doctype html><html><body><h1>Results for <script>alert(1)</script></h1></body></html>

The browser sees a real <script> element and runs it. In real Chromium, driven by Playwright, the result was:

TEXT
/bad  alert fired: ["1"] | h1 text: "Results for alert(1)"

A dialog with 1 opened. Note the heading text: the tag itself disappeared, because the browser treated it as a tag and not as text. The alert(1) is a harmless test. A real attacker would not show a dialog. They would send the user's data to their own server, or click buttons as the user.

This is reflected XSS: the attack travels in a link. The attacker only needs the victim to open that link (in an email, a chat message or a post). Stored XSS is worse: the attacker posts <script>...</script> as a comment, the server saves it, and every visitor who opens the page runs the script. DOM-based XSS is the same thing done by the browser-side code, for example el.innerHTML = location.hash.

What a script can do on your page

Once the attacker's script runs on your page it has all the power of your own code on that origin. It can read the page, call your API with the user's session, change what is shown (a fake login form), and read localStorage. An HttpOnly cookie cannot be read by JavaScript, but the script can still use the logged-in session by making requests, so HttpOnly reduces the damage and does not stop it. See Browser Lesson 3 for this trade-off.

The fix: encode the output for where it lands

The cure has the same shape as in lesson 1: keep data as data. When you put text into HTML, change the characters that HTML treats as special, so they become plain text. Here is a tiny function and the route that uses it:

JavaScript
const esc = (s) => String(s)  .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")  .replace(/"/g, "&quot;").replace(/'/g, "&#39;");if (u.pathname === "/good") out = `<h1>Results for ${esc(q)}</h1>`;

The same attack:

TEXT
<!doctype html><html><body><h1>Results for &lt;script&gt;alert(1)&lt;/script&gt;</h1></body></html>
TEXT
/good alert fired: [] | h1 text: "Results for <script>alert(1)</script>"

No dialog. The browser shows the text <script>alert(1)</script> on the page as ordinary words. That is exactly what the user typed, which is the right outcome.

The OWASP XSS Prevention cheat sheet puts this as its first rule: when you want to show data as the user typed it, use output encoding, and start with the protection your framework already gives you.

Encoding depends on the place. The function above is right for HTML text. It is not enough for other contexts: inside a JavaScript string, inside an attribute without quotes, inside a URL, or inside CSS, each needs its own rules. Never write one "sanitise" function and use it everywhere. Use a template engine or framework that encodes for the right context, and avoid putting user data in script blocks and event attributes at all.

Your framework already helps, until you turn it off

React, Vue, Angular and most template engines encode text by default. In React, {name} is safe. The danger is in the escape hatches:

WhereSafe by default?The hole
React {value}YesdangerouslySetInnerHTML={{ __html: value }}
Vue {{ value }}Yesv-html="value"
Plain JSel.textContent = value is safeel.innerHTML = value is not
LinksText is safehref={value} with javascript:alert(1) as the value
Template enginesOften yesThe "raw" or "unescaped" output tag

The OWASP cheat sheet makes the same point: no framework is perfect, and you need to know where yours has gaps. The words "dangerously" and "unsafe" in an API name are a warning, so search your code for them.

If you really must allow some HTML from users (a rich text editor), do not write your own filter. Use a well-tested HTML sanitiser library (for example DOMPurify in the browser) with a small allow-list of tags, and keep it up to date. Sanitising is a harder job than encoding, so prefer plain text where you can.

CSP: a safety net behind the encoding

Encoding stops XSS if you never forget it. But people forget. A Content Security Policy is a second layer: a header in which the server tells the browser what it may run. If an injected script gets into the page, the browser can refuse to run it.

Here is the same vulnerable page, but now the server also sends a CSP header. The server code is the same as before, with one more header:

JavaScript
headers["content-security-policy"] = "default-src 'self'; script-src 'self'";

Meaning: by default only load things from my own origin ('self'), and run scripts only if they come from my origin as files. Inline scripts, written inside the page, are not allowed. I checked the header with curl:

TEXT
content-security-policy: default-src 'self'; script-src 'self'

And the same <script>alert(1)</script> attack, in real Chromium:

TEXT
/csp  alert fired: [] | h1 text: "Results for alert(1)" | console: Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution.

The page was still built wrongly (the injected <script> tag is still in the HTML), but the browser said "Refused to execute inline script" in its console, and no dialog opened. The mistake was not exploited. That is defence in depth (lesson 0) in practice.

The error text also tells you the three ways to allow an inline script: 'unsafe-inline' (which turns the protection off, avoid it), a hash of the script, or a nonce (a random value that changes on every response, placed in both the header and the script tag). The OWASP CSP cheat sheet leads with the idea that a strict policy based on nonces or hashes is the better choice than long lists of allowed domains.

What does the OWASP CSP page list that a policy can do? I read four goals: block inline scripts, restrict where remote scripts load from, restrict eval-style text-to-JavaScript functions, and restrict where forms can submit to (form-action).

A starter policy that many apps begin with, then adjust:

TEXT
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

CSP is a net, not a cure. It does not remove the hole, it only makes it harder to use. A policy that allows 'unsafe-inline', or a wildcard like https: for scripts, gives almost no protection. Roll it out carefully: the header Content-Security-Policy-Report-Only lets you see what would be blocked without blocking it. Also, third-party scripts (analytics, chat widgets) may need a nonce or a changed policy.

Now a different attack. No script is injected into your site. Instead, the user is logged in to bank.com in one tab, and opens evil.com in another. The evil page contains a hidden form that submits a request to bank.com/transfer. The browser sends the request and, because the cookie belongs to bank.com, attaches the user's cookie automatically. The bank cannot tell it from a request the user meant to make.

The bank only checks "is there a valid session cookie?" and does the action. That is CSRF: the server trusts the cookie, and the cookie is attached no matter who started the request. The attacker never sees the cookie or the response. They only need the action to happen.

The experiment

My toy "bank" has two routes: /login (sets a cookie sid=abc123) and /transfer (needs the cookie, then subtracts 50 from a balance). My "evil" page, served from a different site, auto-submits a POST form to the bank:

HTML
<body onload="document.forms[0].submit()">  <h1>You won a prize</h1>  <form method=post action="http://localhost:3100/transfer"></form></body>

For each cookie setting I logged in with a fresh browser profile, opened the evil page in another tab, and read the result:

TEXT
cookie SameSite=none-set -> evil POST result page: "sent 50, balance 50"cookie SameSite=None     -> evil POST result page: "sent 50, balance 0"cookie SameSite=Lax      -> evil POST result page: "not logged in"cookie SameSite=Strict   -> evil POST result page: "not logged in"

(The balance carries over between rows because it is one server variable. It starts at 100, so the first two rows moved 50 each.)

Look at what happened:

  • With SameSite=None (plus Secure, which Chrome requires with None), the cookie was sent on the cross-site POST and the money moved. The attack worked.
  • With SameSite=Lax and SameSite=Strict, the browser did not send the cookie on the request that came from the other site, so the bank said "not logged in". The attack failed.
  • The first row is the surprise. I set no SameSite attribute and the attack still worked, in this Chromium. Modern browsers treat a cookie without the attribute as Lax, which should block it. What I saw matches a Chrome rule I know of as a short "Lax-plus-POST" exception for new cookies without an explicit SameSite, but I did not find a source to read, so treat this as an observation and not a rule. The lesson is clear either way: write the SameSite attribute yourself. Do not depend on the default.

The OWASP CSRF Prevention cheat sheet says the same thing in its own order: for stateful apps, use the synchronizer token pattern; for stateless ones, double submit cookies; and SameSite on session cookies helps, with a warning not to set the cookie for a whole domain, because then every subdomain shares it.

SameSite in plain words

ValueThe browser sends the cookie when...
Strictthe request starts from your own site only. A click on a link from another site arrives with no cookie.
Laxsame as Strict, but also on a top-level navigation by link (GET). Not on cross-site POST, images or iframes.
Nonealways (needs Secure). Use only when you truly need cross-site cookies, such as an embedded widget.

A good default for a session cookie is:

TEXT
Set-Cookie: sid=...; HttpOnly; Secure; SameSite=Lax; Path=/

Lax still lets the user arrive from an outside link while logged in, which people expect. Strict is safer, and users may need to log in again after a click from an email.

Same-site is not same-origin. localhost:3100 and localhost:3200 are the same site (the port is ignored), so SameSite does not separate them. That is why my test used 127.0.0.1 for the evil page: a different host is a different site. Also, an XSS hole on any subdomain is also "same-site", so one weak subdomain can weaken SameSite on the others.

The CSRF token

SameSite stops most CSRF in modern browsers, but the standard second layer is a CSRF token: a secret random value that is part of the real form and checked by the server. The evil site cannot read your pages (the browser blocks that, see CORS in Browser Lesson 4), so it cannot know the token.

HTML
<!-- served only to the logged-in user --><form method=post action=/transfer-safe>  <input type=hidden name=csrf value=TOKEN-FOR-abc123>  <button>Send 50</button></form>

The server compares the csrf field to the token it stored for this session. I tested both a forged POST (cookie, but no token) and the real form post, with Playwright's request API:

TEXT
forged POST without token: 403 blocked: missing or wrong csrf tokenreal form POST with token: 200 sent 50, balance -50

The forged request is refused. (The negative balance is just because my toy app has no "insufficient funds" check, which would be another bug, a business-logic one.)

My token is a fixed string just to keep the demo short. A real token must be random, unpredictable, and tied to the user's session, as the OWASP page says about signed double-submit cookies. Use your framework's CSRF support or a maintained library, do not invent one. Also: a CSRF check on POST is useless if a GET route also changes data (my /transfer took any method). GET must never change anything.

Other defences from the same cheat sheet: check the Origin or Referer header, and use the Fetch Metadata header Sec-Fetch-Site, which tells the server if the request came from the same site. The OWASP page says these headers are supported by all major browsers since March 2023, and that an old-browser fallback to checking the Origin is a must if you use them. Also, JSON APIs that need a custom header like Authorization: Bearer ... are not exposed to classic CSRF, because a plain HTML form cannot add such a header. If you keep your token in localStorage you trade CSRF for XSS risk (see Browser Lesson 3).

CSRF and XSS are different, but they connect

XSSCSRF
Where the attacker's code runsIn your page, on your originOn their own site
What they needA hole that lets their text become scriptA cookie-based login and a request they can predict
Can they read responses?YesNo
Main defenceOutput encoding, CSPSameSite, CSRF token

An XSS hole beats every CSRF token, because the attacker's script runs on your origin and can read the token. This is why XSS is treated as the more serious of the two.

Clickjacking: an invisible frame

A bad page can load your site inside an <iframe>, make it invisible (or covered by a fake button), and place it so the user's click lands on your real "Delete account" button. The user thinks they are clicking on a free game.

The defence is to tell the browser which sites may frame your pages. The modern way is the CSP frame-ancestors directive:

TEXT
Content-Security-Policy: frame-ancestors 'none'

'none' means nobody may frame it. 'self' means only my own origin. You can also list sites. The older header X-Frame-Options: DENY (or SAMEORIGIN) does the same job and is still sent for old browsers. OWASP's clickjacking page names both as the main defences.

I put the "bank" page under this header and framed it from the evil site's page, together with a normal page for comparison:

TEXT
iframe f | frame url:  | text: "localhost is blocked ... localhost refused to connect. ... ERR_BLOCKED_BY_RESPONSE"iframe g | frame url: http://localhost:3100/good?q=hi | text: "Results for hi"

Frame f, the page with frame-ancestors 'none', was blocked: Chromium showed its error page with ERR_BLOCKED_BY_RESPONSE instead of the content. Frame g, which has no such header, loaded normally and could be clicked through. If the page that is framed can do something important with one click, set this header.

Setting frame-ancestors is a response to a framing attack, it does not help if the user is tricked into visiting your real site and clicking something there. For very sensitive actions (delete account, send money) ask the user to confirm again, for example with their password.

The security headers worth setting

Many protections are just response headers. You can set them by hand, or use helmet, an npm package for Node servers that sets a group of them. I installed helmet 8.3.0 into a clean folder, used it as one line (helmet() with default options), and printed the headers:

TEXT
Content-Security-Policy: default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requestsCross-Origin-Opener-Policy: same-originCross-Origin-Resource-Policy: same-originOrigin-Agent-Cluster: ?1Referrer-Policy: no-referrerStrict-Transport-Security: max-age=31536000; includeSubDomainsX-Content-Type-Options: nosniffX-DNS-Prefetch-Control: offX-Download-Options: noopenX-Frame-Options: SAMEORIGINX-Permitted-Cross-Domain-Policies: noneX-XSS-Protection: 0

In plain words, what the important ones do:

HeaderWhat it doesNote
Content-Security-PolicyControls what can run and load (the XSS safety net). frame-ancestors is here too.Needs tuning for your app. The default will break inline scripts and third-party scripts.
Strict-Transport-Security (HSTS)Tells the browser to use HTTPS only for this site, for a time.Only send it over HTTPS. Do not add preload unless you understand it.
X-Content-Type-Options: nosniffStops the browser from guessing a file type, for example treating an uploaded text file as a script.Safe and almost always right.
X-Frame-OptionsOlder way to stop framing.Use frame-ancestors and keep this for old browsers.
Referrer-PolicyControls how much of the URL is sent to other sites.no-referrer is strict. Some analytics tools need more.
Cross-Origin-Opener-Policy, Cross-Origin-Resource-PolicyKeep other sites' windows and other sites' scripts away from yours.Mostly useful for sensitive apps.
X-XSS-Protection: 0Turns off an old browser filter that caused its own problems.Helmet sets 0 on purpose. Do not set it to 1.

I skimmed the OWASP HTTP Headers cheat sheet, which lists many such headers and gives recommended values. I did not copy those values, I only report what helmet printed when I ran it.

Missing from helmet's list: cookies. Cookie flags are set when you set the cookie (HttpOnly, Secure, SameSite). Helmet does not do that for you.

You can check any site you own with curl -I https://your-site.example and read the headers it sends.

Do not paste a header list from a blog and call it done. A strict CSP can break your page, HSTS can lock you into HTTPS for a year if you have a mistake, and a header that is set by your app but removed by a proxy or CDN does nothing. Check the real response from the real URL, then test the page in a browser.

Putting the picture together

Here is the list of layers for a typical web page, in the order I would add them:

  1. Encode output for the context (or use a framework that does).
  2. Avoid raw HTML sinks: innerHTML, v-html, dangerouslySetInnerHTML. Sanitise with a library if you must.
  3. Cookies: HttpOnly; Secure; SameSite=Lax (or Strict), set explicitly.
  4. CSRF token for any state-changing cookie-based request, and never change data on GET.
  5. CSP, first in report-only mode, then enforcing; with frame-ancestors.
  6. Other headers (HSTS, nosniff, referrer policy) with helmet or your host's settings.
  7. Keep dependencies updated and watch for XSS in third-party scripts. See lesson 6.

Try it

  1. Run a tiny node:http server with the /bad route. Open it with ?q=<b>hi</b>. What do you see, and why is this a warning sign even before you use <script>?
  2. Add esc() and check the HTML with curl. What did < become?
  3. Add Content-Security-Policy: script-src 'self' and open the vulnerable route again. Open the browser console. What does it say?
  4. Make a page that sets a cookie with and without SameSite=Lax. Use two hosts (localhost and 127.0.0.1) to try a cross-site POST form. Which cookie arrives?
  5. Add a CSRF token to a form. What happens if the token is missing? If it is wrong?
  6. Add frame-ancestors 'none' and try to frame your page. What does the browser show?
  7. Run npm i helmet, print its headers with curl -I, and write one line about each header in your own words.

Answers

  1. The word "hi" appears bold, because the browser read <b> as a tag. That means your text is becoming HTML. If <b> works, <script> can work.
  2. < became < and > became >. The browser shows the characters, it does not run them.
  3. The console shows a message that the inline script was refused because it violates the Content Security Policy directive.
  4. The cookie with SameSite=Lax (or Strict) is not sent on the cross-site POST. The cookie with None; Secure is sent. A cookie with no attribute depends on the browser, which is why you set it yourself.
  5. Missing or wrong token: the server returns 403 and does not do the action.
  6. A browser error page (in Chromium: ERR_BLOCKED_BY_RESPONSE) instead of your page inside the frame.
  7. Compare with the table above. Your own words are the point: if you can explain a header to a teammate, you will remember it.

Interview questions

What is XSS and how do you prevent it? (Must know)

XSS is when an attacker gets their JavaScript to run in another user's browser on my site. It happens when I put user text into a page without encoding it for the place where it lands, so the browser reads the text as code. I prevent it mainly by output encoding, which my framework does by default, and by avoiding raw HTML sinks like innerHTML or dangerouslySetInnerHTML. If I must allow HTML, I use a tested sanitiser library with an allow-list. As a second layer I add a Content Security Policy. In my test, the same injected script ran with no protection, was shown as plain text with encoding, and was refused by the browser with a CSP.

What are reflected, stored and DOM-based XSS? (Must know)

Reflected: the bad input is in the request (for example a URL parameter) and the server returns it in the page. The victim must open the attacker's link. Stored: the bad input is saved on the server (a comment, a profile name) and shown to everyone who visits, which is usually worse. DOM-based: the page's own JavaScript takes the input (from the URL, for example) and writes it into the page with something like innerHTML, so the server may never see it at all.

Is escaping < and > enough to stop XSS? (Trap)

Only for one context: text between tags. The right encoding depends on where the data lands: HTML text, an attribute, a JavaScript string, a URL or CSS. A value placed in href can start with javascript: and needs no angle brackets at all. So I use a framework or template engine that encodes by context, I avoid putting data into script blocks or event attributes, and I validate URLs by scheme (only http and https).

What does a Content Security Policy do, and why is it not a replacement for encoding? (Senior signal)

CSP is a header where I tell the browser which sources it may load and run scripts from. It can block inline scripts, which stops most injected code. But it only limits the damage. The hole is still there, and a loose policy (unsafe-inline, wildcards) protects very little. I use CSP as a second layer behind encoding. In practice I start in report-only mode to find what would break, then enforce, and I prefer nonces or hashes to long allow-lists of domains.

What is CSRF and why does it work? (Must know)

It is when a different site makes the user's browser send a request to my site. The browser attaches my site's cookie automatically, so my server sees a request with a valid session and does the action. It works because the server trusts the cookie, and the cookie is attached no matter who started the request. The attacker cannot read the response, they only need the action to happen, such as a money transfer or an email change.

How do you prevent CSRF? (Must know)

Two layers. First, SameSite=Lax or Strict on the session cookie, so the browser does not send it on cross-site POSTs. I set it explicitly instead of trusting the default: in my test, a cookie with no attribute still allowed the cross-site POST. Second, a CSRF token that is random, tied to the session, put in every state-changing form or request, and checked by the server. I never use GET for actions that change data. Checking Origin or Sec-Fetch-Site is a good extra layer.

If my API uses a JWT in the Authorization header, do I need CSRF protection? (Senior signal)

Usually not for classic CSRF, because a forged form from another site cannot add a custom Authorization header and the browser does not attach it on its own. But if the JWT is stored in localStorage, any XSS can steal it, so I trade CSRF for XSS risk. If the token is in a cookie, then CSRF applies again. The place the credential lives decides which attack matters. See the browser series, lesson 3.

What is the difference between same-site and same-origin, and why does it matter for SameSite cookies? (Trap)

An origin is scheme, host and port. A site is the registrable domain, and the port does not count. So localhost:3100 and localhost:3200 are different origins but the same site, and SameSite does not separate them. Also subdomains of one domain are the same site. It matters because a weak subdomain (for example an old marketing site with XSS) can send same-site requests with my cookies, and because CORS rules and SameSite rules use different definitions.

What is clickjacking and how do you stop it? (Must know)

Clickjacking is loading my page in an invisible frame on a bad site so the user's click lands on a real button of mine. I stop it by telling the browser who may frame my pages: Content-Security-Policy: frame-ancestors 'none' (or 'self'), and X-Frame-Options for old browsers. In my test the framed page showed Chromium's ERR_BLOCKED_BY_RESPONSE error. For sensitive actions I add a re-confirmation step, because a frame header does not help if the user is on my real site.

Which security headers would you set on a new site? (Senior signal)

A CSP with frame-ancestors, HSTS over HTTPS, X-Content-Type-Options: nosniff, a Referrer-Policy, and cookie flags (HttpOnly, Secure, SameSite) on session cookies. I would use helmet in Node to get a reasonable group, then tune the CSP, because the default breaks inline scripts. I check the real response with curl -I, because a proxy or CDN may change headers, and I test the site in a browser after each change.

Does HttpOnly stop XSS? (Trap)

No. It only stops JavaScript from reading the cookie, so the attacker cannot copy the session ID. But the attacker's script runs in the page and can still make requests, and the browser attaches the cookie. So the attacker can act as the user while the page is open. HttpOnly reduces the damage, and encoding and CSP are what reduce the hole.

Why is XSS considered more serious than CSRF? (Senior signal)

CSRF can only send requests that the attacker could guess, and the attacker cannot read the answers. XSS runs the attacker's code on my origin, so it can read pages, read CSRF tokens, call any API as the user, and change what the user sees. XSS can defeat CSRF defences. So I fix XSS first, and I treat CSRF tokens as a layer on top.

Key takeaways

  • XSS is injection into HTML. Reflected, stored and DOM-based are three delivery routes. Encode output for the exact context, and avoid raw HTML sinks.
  • In real Chromium: the unencoded page ran the script, the encoded page showed it as text, and the page with a CSP header refused to run it.
  • CSP is a safety net. Prefer nonces or hashes over unsafe-inline, and try report-only first.
  • CSRF abuses the cookie that the browser attaches automatically. Set SameSite yourself and add a random session-bound CSRF token. Never change data on GET.
  • In my test, SameSite=Lax and Strict blocked a cross-site POST, None and an unset attribute did not.
  • Clickjacking is stopped with frame-ancestors (and X-Frame-Options for old browsers). Frame blocking showed ERR_BLOCKED_BY_RESPONSE.
  • Use helmet for a sensible header set, then check the real response. Cookie flags are your own job.
  • Run attacks only against your own toy app.

What's next

Next: Passwords and login, Lesson 3. XSS and CSRF both aim at the session, so we now look at how to build login safely: why you never store passwords, hashing with a salt, safe sessions, JWT mistakes, MFA and rate limits. Practise now: open the Network tab on a site you build and check which cookie flags and security headers it actually sends.

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

Keep learning

Related lessons

Part of the study guide

Related series