Guide · Frontend
Frontend Security: XSS, CSRF and CSP Explained
26 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Frontend security means stopping attackers from turning your own pages against your users. The three attacks to know are XSS, CSRF and clickjacking, and CSP is the main browser-side defence.
- XSS (cross-site scripting) gets an attacker's script running inside your page. Block it by inserting user data as plain text, and pass any HTML you must allow through a sanitiser, a library that strips out anything that can run.
- CSRF (cross-site request forgery) tricks a logged-in browser into sending a request the user never meant. Block it with a secret CSRF token or a server check of where each request came from.
- SameSite cookies, a cookie setting that keeps the cookie off most requests started by other sites, add a second layer. CSP (Content Security Policy) is a rule list the server sends with the page, naming which scripts may run. OWASP, a non-profit that publishes free security guidance, says it should not be your primary defence.
- Beginners can stop after the table comparing XSS, CSRF and clickjacking. Engineers then get what we ran in Chromium 141, a 2025 build of the open-source browser Chrome is built on. That covers which cookies a cross-site or sibling-site form post carries, and how CSP keywords quietly switch each other off.
What goes wrong when a logged-in page trusts the wrong input?
The browser ends up doing the attacker's work, and stopping that is what frontend security is about. It runs any script your page contains, and it attaches your login cookie to requests automatically, so an attacker who steers either one can act as the user.
Picture a common setup. A cooperative credit society runs a member portal where members check deposits, track gold loans and update their mobile number. Branch officers approve loan requests on their own screens. The setup is made up, but the problems are real.
Before adding online loan applications, the society asks a security reviewer to use the portal like a member, with an attacker's eye. The review finds three problems in one afternoon.
- In a test loan request, the reviewer types a small piece of HTML into the purpose box. When a branch officer opens the request, that HTML starts a script on the officer's screen.
- The change-mobile-number form accepts any request that carries the member's login cookie. A page on any other website can submit that form behind the member's back.
- Any website can load the portal inside an invisible frame. A fake bonus offer could trick a member into clicking a real portal button they cannot see.
The first problem is XSS, the second is CSRF and the third is clickjacking. None of them needs a stolen password. Each one makes the member's own browser do the attacker's work, and each has a fix below.
What is frontend security and what does it cover?
Frontend security is the set of habits that stop attackers from turning your pages, scripts and cookies against your own users. It covers what runs in the browser, and what the browser sends on the user's behalf.
A frontend is the part of an app that runs in the user's browser: the HTML, CSS and JavaScript. Your server sends it, but the browser runs it, on the user's machine, while the user is logged in.
Two words come up again and again. An origin is the scheme, host and port of a page, such as https, portal.example and 443. A site is looser: roughly the scheme plus the registrable domain, the name you buy from a registrar.
So the society's blog subdomain and its portal subdomain are different origins but the same site. Ports do not count towards the site either. Keep this difference in mind, because cookie rules work by site.
The browser's main safety rule is the same-origin policy. A script from one origin cannot read pages or responses from another origin. XSS gets around it by running inside your origin. CSRF gets around it by never needing to read anything.
What is cross-site scripting (XSS) and how does it work?
Cross-site scripting, or XSS, is an attack where someone gets their own JavaScript to run inside your page. The browser cannot tell it apart from your code, so it gets all the power your page has.
Think of a rent agreement typed on stamp paper and taken to a notary. Someone slips one extra clause into the draft before it is printed. The notary stamps the whole document, and the slipped-in clause now carries the same authority as the real ones.
- The notary is the browser. It approves whatever arrives from your origin.
- The stamp paper is your page, sent by your server.
- The slipped-in clause is text a member typed, which your code inserted as HTML instead of as text.
- The stamp is your origin's authority: your cookies, your API and everything the page can see.
Where the picture breaks: a slipped clause just sits on paper, but an injected script acts. It can click buttons, read the page and send requests the moment it runs.
The three kinds of XSS
- Stored XSS. The script is saved in your database and served to everyone who views it. The portal's loan purpose box is this kind, and it hits branch officers.
- Reflected XSS. The script rides inside a link, for example in a search term, and the server echoes it straight back into the page.
- DOM-based XSS. The DOM is the browser's live model of the page. Here the script never touches the server: your own JavaScript reads something like the address bar and writes it into the page as HTML.
Here is how the reviewer's test request turned into a running script on the officer's screen.
Stored XSS on the loan review screen
Drag to exploreNotice that every server step works as designed. The damage happens at the last hop, when the officer's page inserts stored text as HTML.
Diagram connections:
- Member fills loan form to Portal API saves request
- Portal API saves request to Loan requests table
- Loan requests table to Officer opens the request (Loaded for review)
- Officer opens the request to Page uses innerHTML
- Page uses innerHTML to Script runs as the officer
- Member fills loan form: Purpose box holds an HTML tag
- Portal API saves request: Stores the purpose exactly as typed
- Loan requests table: Holds the hidden script for days
- Officer opens the request: Branch staff with approval rights
- Page uses innerHTML: The purpose text is parsed as HTML
- Script runs as the officer: Can approve loans, read and send data
A script on the officer's screen is especially bad because it runs with the officer's login. It can read every request on the page, press the approve button, or quietly send member details to another server.
How do you prevent XSS in a frontend app?
You prevent XSS by never letting user data become code. Insert it as text, encode it for the exact place it goes, and pass any HTML you must allow through a sanitiser.
OWASP calls the middle step output encoding. Encoding turns risky characters into harmless codes, and the same data needs different codes inside HTML, an attribute, a link or a script.
- Use text sinks. A sink is a browser API that puts data into the page. textContent and createTextNode treat data as plain text, except inside a script or style element, where the browser reads it as script or CSS.
- Use setAttribute only for plain attributes such as title or class. An event attribute, a link or a script source can still run code.
- Avoid HTML sinks. innerHTML, outerHTML, insertAdjacentHTML and document.write parse their input as HTML, so any tag inside the data becomes real.
- Trust your framework's default. Frameworks such as React escape values in templates, turning a less-than sign into a code that shows on screen as text.
- Watch the escape hatches. Raw-HTML features such as React's dangerouslySetInnerHTML switch that escaping off, so search your code for them.
- Check links before you use them. Allow only http and https addresses in an href, because a javascript: address runs code when someone clicks it.
- Never build code from data. eval, new Function and timers given a string all run text as JavaScript.
The portal has two cases. Loan purposes and remarks are always plain text. The notice board for branch managers allows bold, italics and bullet lists. The frontend handles both with DOMPurify, an open-source HTML sanitiser that OWASP recommends.
If you are new to JavaScript, skim the code and read the two paragraphs after it. They say what each part does.
import DOMPurify from "dompurify";
// Loan purposes, names, remarks: always shown as plain text.
export function showPurpose(cell, purpose) {
cell.textContent = purpose;
}
// Notice board: five formatting tags and no attributes at all.
const NOTICE_RULES = {
ALLOWED_TAGS: ["p", "b", "i", "ul", "li"],
ALLOWED_ATTR: [],
ALLOW_DATA_ATTR: false, // otherwise data-* attributes are kept
ALLOW_ARIA_ATTR: false, // otherwise aria-* attributes are kept
};
// With Trusted Types on, innerHTML accepts only values from a policy like this one.
const noticePolicy = window.trustedTypes?.createPolicy("notice-html", {
createHTML: (html) => DOMPurify.sanitize(html, NOTICE_RULES),
});
export function showNotice(box, html) {
box.innerHTML = noticePolicy
? noticePolicy.createHTML(html)
: DOMPurify.sanitize(html, NOTICE_RULES); // browsers without Trusted Types
}The key line in showPurpose is textContent. Whatever the purpose holds, the officer just sees those characters on screen. In showNotice, ALLOWED_TAGS keeps five formatting tags and removes every other tag. Most removed tags leave their text behind, but script and style tags vanish with their contents.
The two ALLOW lines matter more than they look. DOMPurify keeps data attributes (custom labels starting data-) and aria attributes (hints for screen readers) by default, even when ALLOWED_ATTR is an empty list. We checked this on DOMPurify 3.4.17, and the README states both defaults.
OWASP adds two cautions. If you change the HTML after sanitising it, you can undo the protection. And you must patch DOMPurify regularly, because new ways around sanitisers appear as browsers change.
Trusted Types: let the browser enforce the rule
Trusted Types is a browser feature that makes HTML sinks refuse plain strings. You switch it on with the CSP directive require-trusted-types-for 'script'. After that, innerHTML only accepts values made by a Trusted Types policy.
A policy is a named function, created with trustedTypes.createPolicy. In the sample, noticePolicy wraps the DOMPurify call, so the notice board keeps working. If any old code passes a raw string to innerHTML, the browser throws an error instead.
You can watch it work. On a page with Trusted Types switched on, open the browser's developer console and assign any string to document.body.innerHTML. Chromium 141 threw: TypeError: Failed to set the 'innerHTML' property on 'Element': This document requires 'TrustedHTML' assignment.
MDN marks Trusted Types as Baseline 2026: working in the latest major browsers since February 2026. MDN's compatibility data lists Chrome 83, Firefox 148 and Safari 26 as the first versions, as of 19 October 2026. Older browsers skip the check, so keep the sanitiser too.
What is CSRF and how does a CSRF attack work?
CSRF, short for cross-site request forgery, tricks a logged-in user's browser into sending your site a request the user never meant to send. The browser attaches the login cookie, so your server believes the user asked.
A cookie is a small value the browser stores and sends back to the site that set it, on every matching request. It proves which browser is talking, not who wrote the request.
The portal's session cookie had SameSite=None, left over from an old partner widget. SameSite is a cookie setting that says whether the cookie may travel on requests started by other sites. None means always, so the reviewer's demo page worked like this.
A forged mobile number change
Drag to exploreNotice that the attacker never sees the cookie or the reply. The member's browser adds the cookie, and only a server-side check can stop the request.
Diagram connections:
- Member logged in to portal to Fake bonus offer page (Opens a shared link)
- Fake bonus offer page to Hidden form submits itself
- Hidden form submits itself to Browser adds the cookie
- Browser adds the cookie to Token or origin checked?
- Token or origin checked? to Mobile number changed (No)
- Token or origin checked? to Request rejected (Yes, and it fails)
- Member logged in to portal: Session cookie saved in the browser
- Fake bonus offer page: Hosted on the attacker's own site
- Hidden form submits itself: Carries a number the attacker chose
- Browser adds the cookie: SameSite=None allowed it cross-site
- Token or origin checked?: The old portal checked neither
- Mobile number changed: Future one-time passwords go to the attacker
- Request rejected: What the fixed portal does
An OTP is a one-time password sent by SMS. Whoever owns the mobile number receives every future OTP, so this one form guards the whole account.
Notice what the attacker cannot do. Their page cannot read the portal's reply, because of the same-origin policy. CSRF works anyway, because changing the number needs no reading, only sending.
How do you prevent CSRF attacks?
You prevent CSRF by making the server reject state-changing requests (ones that change data) that did not come from your own pages. Check a CSRF token or the request's origin on each of them, and add SameSite cookies as an extra layer.
Fix the cookie
Set the session cookie with HttpOnly, Secure and SameSite=Lax. Lax still sends it when a member follows a link to the portal, but not on cross-site form posts.
Never change data on GET
Lax still sends cookies when another site links to a portal page with GET, the method browsers use to open pages. So every action that changes data must use POST, PUT, PATCH or DELETE.
Add a CSRF token
The server makes a random token for each session and puts it in the page. Every state-changing request must send it back, and the server compares it with the session's copy.
Check where the request came from
Read the Sec-Fetch-Site header, which browsers add to say whether a request came from the same origin, the same site or another site. Page scripts cannot set it. Accept only same-origin, fall back to the Origin header for older browsers, and reject requests that carry neither.
SameSite alone is not enough. OWASP calls it useful as defence in depth that does not replace a proper CSRF defence in most deployments. Some older or embedded browsers ignore it, and a sibling subdomain counts as the same site.
Browser defaults also differ. Chrome began treating cookies without a SameSite setting as Lax in 2020. On 11 August 2020 it set the rollout target to all users of Chrome 80 and later, then ramped up gradually. As of 19 October 2026, MDN's compatibility data lists Firefox with this only behind a setting, and Safari not at all, so always set SameSite yourself.
On the frontend, the job is to send the token. The server writes it into a meta tag when it builds the page, and this helper adds it as a header on every request that changes data.
// The server puts this session's token in a meta tag on every page.
function csrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) throw new Error("CSRF token missing from page");
return meta.content;
}
export async function updateMobile(newMobile) {
const res = await fetch("/api/profile/mobile", {
method: "POST",
credentials: "same-origin", // the default, written out for reviewers
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken(), // a page on another site cannot read this
},
body: JSON.stringify({ mobile: newMobile }),
});
if (!res.ok) throw new Error(`Mobile update failed: ${res.status}`);
}The X-CSRF-Token line does two jobs. The server compares it with the session's token. And a page on another site cannot add a custom header to a request to the portal, unless the portal approves it through CORS.
CORS, short for cross-origin resource sharing, is the browser's permission check for requests between origins. For a custom header, the browser first sends a small preflight request and waits for the server's yes. The portal's API never says yes to other sites.
The credentials line sends cookies only to the portal's own origin. That is already the fetch default, but writing it out helps code review. On the server, one small function runs before every handler that changes data.
import { timingSafeEqual } from "node:crypto";
// Call before any POST, PUT, PATCH or DELETE handler runs.
export function allowStateChange(req, sessionToken, ourOrigin) {
const site = req.headers["sec-fetch-site"];
if (site && site !== "same-origin") return false; // other sites and sibling subdomains
if (!site && req.headers.origin !== ourOrigin) return false; // older browsers
const sent = Buffer.from(String(req.headers["x-csrf-token"] ?? ""));
const expected = Buffer.from(sessionToken);
return sent.length === expected.length && timingSafeEqual(sent, expected);
}Read it top to bottom. Any Sec-Fetch-Site value except same-origin fails first, so a sibling subdomain cannot get through. With no Sec-Fetch-Site header, the Origin header must match exactly. Then the token must match, compared in constant time: the check takes as long when the first character differs as when the last one does.
What is Content Security Policy (CSP) and how does it stop XSS?
Content Security Policy, or CSP, is a response header (extra information sent with the page) that tells the browser which scripts, plugins and frames a page may use. If an injected script is not approved, the browser refuses to run it.
MDN recommends a strict CSP, built on nonces or hashes. A nonce is a random value the server makes fresh for every response. It goes in the header and on each script tag the server meant to send.
Here is the policy the reviewer writes for the portal, one directive per line. A directive is one rule inside the policy.
- script-src 'nonce-RANDOM' 'strict-dynamic': run only scripts carrying this response's nonce, plus scripts those trusted scripts add later.
- object-src 'none': block old plugin content, which can also run code.
- base-uri 'none': stop an injected base tag from changing where relative script paths point.
- frame-ancestors 'none': no other site may show the portal inside a frame.
- require-trusted-types-for 'script': switch on Trusted Types, as described earlier.
- report-to csp-endpoint: send violation reports to the address that a Reporting-Endpoints header names csp-endpoint.
- report-uri /csp-reports: the older reporting directive. OWASP notes that browsers supporting report-to ignore it, so it only serves older browsers.
The reviewer's test tag has no nonce, so under this policy it never runs. Inline event attributes, the onclick-style code inside a tag, are blocked too. The attacker cannot copy the nonce in advance, because it changes on every response.
Here is the same policy as server code for Node.js 22. The page then puts the returned nonce on its own script tags.
import { randomBytes } from "node:crypto";
// Sets the CSP headers on one HTML response and returns that response's nonce.
export function setCspHeaders(res) {
const nonce = randomBytes(16).toString("base64"); // new for every response
const policy = [
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
"require-trusted-types-for 'script'",
"report-to csp-endpoint",
"report-uri /csp-reports",
];
res.setHeader("Content-Security-Policy", policy.join("; "));
res.setHeader("Reporting-Endpoints", 'csp-endpoint="https://portal.example/csp-reports"');
return nonce;
}Never rely on 'unsafe-inline', which allows any inline script. MDN notes that browsers ignore it when a nonce or hash is present, so it only ever helps very old browsers. The strict-dynamic keyword is a trade-off too: if a trusted script builds new scripts from user data, CSP will not catch it.
Clickjacking and frame-ancestors
Clickjacking hides your real page inside an invisible frame on the attacker's page, under a fake button. The member thinks they are claiming a bonus, but they actually press the portal's confirm button.
frame-ancestors 'none' stops the frame from loading at all. MDN calls it a more flexible replacement for the older X-Frame-Options header, which some teams still send for very old browsers.
Here is the fixed portal. Look for the two places where checks happen: inside the member's browser, and at the API.
The portal's layered frontend defences
Drag to exploreNotice the two places where checks happen. The browser enforces CSP and Trusted Types on the page, and the API checks every request that changes data.
Diagram connections:
- Portal web server to Member's browser (Page plus CSP header)
- Member's browser to Portal frontend code (Runs only nonce scripts)
- Portal frontend code to Portal API (POST with CSRF token)
- Portal API to Session store (Validates session)
- Portal API to Members database
- Member's browser to CSP report collector (Violation reports)
- Portal web server: Sends the CSP header and a fresh nonce
- Member's browser: Enforces CSP and Trusted Types
- Portal frontend code: Text by default, DOMPurify for notices
- Portal API: Checks Sec-Fetch-Site and the CSRF token
- Session store: Cookie is HttpOnly, Secure, SameSite=Lax
- Members database: Stores text exactly as typed
- CSP report collector: The team reviews blocked scripts weekly
How do you roll out CSP without breaking your site?
Start in report-only mode, fix what the reports show, then enforce. The Content-Security-Policy-Report-Only header applies the policy without blocking anything, and reports each violation.
List every script
List every script the portal loads: the app bundle, an analytics tag and an old chat widget. Each one needs a nonce, or a reason to be removed.
Ship the report-only header
Send the policy in the report-only header on every response. MDN says a report-only policy cannot be delivered in a meta tag, so it must be a real header.
Read and fix the reports
Group reports by what was blocked. Most come from inline onclick handlers on old pages, which the team moves into script files using addEventListener.
Enforce, and try the next step
Switch to the enforcing header. MDN says both headers are honoured when sent together, so a stricter draft can keep running in report-only mode beside it.
Keep the nonce fresh
Make the nonce in server code for each response, never inside a cached HTML file. A cached nonce is the same for everyone, so an attacker can simply read it.
Check the result from a terminal. This command asks for the page's headers only and keeps the two lines that matter.
curl -sI https://portal.example/ | grep -i -E '^(content-security-policy|set-cookie):'You should see one Content-Security-Policy line starting with script-src 'nonce- and ending with report-uri /csp-reports. You should also see a Set-Cookie line containing HttpOnly, Secure and SameSite=Lax. Run it twice: the nonce, 24 characters in our sample, must change every time.
What is the difference between XSS, CSRF and CSP?
XSS and CSRF are attacks, and CSP is a defence. XSS runs the attacker's code inside your page, CSRF sends a forged request from another site, and CSP limits what your page may run or who may frame it.
| XSS | CSRF | Clickjacking | |
|---|---|---|---|
| What the attacker gets | Their script runs in your page | A request sent with the user's cookie | A real click on a hidden page |
| What it abuses | Data inserted as HTML | Cookies sent automatically | Your page loading in any frame |
| Can it read your data? | Yes, anything the page can see | No, it can only send | No, it only borrows clicks |
| Main defence | Text sinks, encoding and a sanitiser | CSRF token or origin check | frame-ancestors 'none' in CSP |
| Extra layer | Strict CSP and Trusted Types | SameSite cookies | X-Frame-Options for old browsers |
| Hole found in the portal | Loan purpose box | Change mobile number form | Portal framed by any site |
Beginners can stop here: you know the three attacks and the main defence for each. The next section is for engineers who ship these defences, and every result in it comes from a browser we ran.
Which frontend security details catch out experienced engineers?
The surprises sit at the edges: which cookies travel from a sibling site, a two-minute gap in Lax by default, and CSP keywords that quietly cancel each other. We ran each case on 19 October 2026 in Chromium 141.0.7390.37, the build bundled with Playwright 1.56, a browser-testing tool released on 6 October 2025. As of 19 October 2026, the newest Playwright, 1.64.1, bundles Chromium 156, so re-check these results on the browsers your users run.
The portal ran on localhost port 8401. A different site ran on 127.0.0.1, and a same-site sibling ran on localhost port 8403, since ports do not count towards the site. Every test script was inert: it only set a flag or logged a header.
| Case | What happened |
|---|---|
| Lax session cookie, top-level form post from another site | Cookie not sent. Sec-Fetch-Site said cross-site |
| Lax and even Strict cookies, form post from a same-site sibling | All sent. Sec-Fetch-Site said same-site, and the CSRF check returned 403 |
| Cookie with no SameSite, cross-site form post right after it was set | Sent, because of the Lax-plus-POST exception |
| The same cookie, 126 seconds after it was set | Not sent |
| Custom X-CSRF-Token header from another site with fetch | Only an OPTIONS preflight arrived, with no cookies. No POST reached the server |
| A cookie named with the Host prefix, which must be Secure with Path=/ and no Domain, that names a Domain anyway | Rejected. The browser never stored it |
| Nonce plus 'unsafe-inline', inline script without the nonce | Blocked. The console said 'unsafe-inline' is ignored when a nonce is present |
| 'strict-dynamic' plus an allowed host, plain script tag from that host | Blocked. A script added by a trusted script still ran |
| The full guide policy: a trusted script sets a new script's src to a plain string | TypeError: the document requires a TrustedScriptURL. Trusted Types also guards script addresses |
| report-to and report-uri in one policy | Nothing was sent to the report-uri address |
| Report-only policy or frame-ancestors inside a meta tag | Both ignored, each with a console error |
SameSite stops at the site boundary
Pause and predict: a sibling page on the same site submits a form to the API. Will a SameSite=Strict cookie go with it?
It did, along with every other cookie, because Strict only blocks requests from other sites. Sec-Fetch-Site still told the truth: it said same-site.
That is why the server check rejects same-site as well as cross-site. OWASP says to treat same-site as allowed only if your threat model trusts sibling subdomains. A forgotten marketing subdomain is rarely worth that trust.
Lax by default has a two-minute gap
Chromium treats a cookie with no SameSite as Lax, but with an exception. It still sends that cookie on top-level cross-site POSTs if the cookie is at most 2 minutes old.
Chromium announced this exception in 2019 as temporary, and MDN still documents it as of 19 October 2026. Our test saw it in Chromium 141. A session cookie set at login is fresh for exactly those first 2 minutes.
The fix is one word. Our cookie with an explicit SameSite=Lax never travelled on a cross-site POST, at any age, so always write the setting out.
CSP keywords that quietly switch others off
A nonce switches off 'unsafe-inline', and 'strict-dynamic' switches off host lists and 'self'. MDN's script-src page documents both, and Chromium 141 even explained each block in the console.
This is useful, because older browsers fall back to the keywords they understand. But it means a host you added to the list is not what lets a script run. Only the nonce, or trust passed down from a nonce script, does.
Trusted Types adds one more catch. With both switched on, a trusted loader script that sets a script address from a plain string throws an error. Third-party loaders then need a Trusted Types policy with a createScriptURL function, so test each one in report-only mode first.
How do you explain frontend security in a system design interview?
Treat it as one layer of the design. Say where user data enters, where it is shown, and how every state-changing request proves it came from your own page.
Map the sinks
Name every place user text is shown, such as loan purposes and notices. Say it is rendered as text, with a sanitiser only where HTML is truly needed.
Describe the session cookie
HttpOnly, Secure and SameSite=Lax, with a name that starts with the Host prefix: two underscores, then Host and a hyphen. The browser accepts such a cookie only if an HTTPS page sets it with Secure, Path=/ and no Domain.
Protect every change
Put a CSRF token on every POST, PUT, PATCH and DELETE. Add a Sec-Fetch-Site check that accepts only same-origin, with an Origin fallback.
Add the browser backstop
Describe a strict nonce-based CSP with frame-ancestors 'none' and Trusted Types, rolled out in report-only mode first.
Plan how you would notice
Explain your signals: CSP reports, unusual approval patterns, and an alert to the member whenever their mobile number changes.
Questions people ask
- Does React prevent XSS automatically?
- Mostly, for normal rendering. React escapes values in its templates, so a tag in a member's text shows as plain characters. It cannot protect you when you use dangerouslySetInnerHTML or write to the DOM directly. React 19 blocks javascript: links, while React 18 only warns, and OWASP says this does not replace your own URL checks.
- Is an HttpOnly cookie enough to stop XSS?
- No. HttpOnly stops JavaScript from reading the cookie, so an injected script cannot copy it to another server. The browser still sends that cookie with every request the script makes. The attacker can act as the user from inside the page, without ever seeing the cookie.
- Do I need CSRF protection if my login token is in local storage?
- Not for CSRF, but you take on a bigger XSS risk instead. A token in local storage is not sent automatically, so a forged cross-site request has nothing to ride on. But any script on the page can read it. OWASP says not to store session identifiers in local storage, and notes HttpOnly cookies reduce this risk.
- Is SameSite=Lax enough to stop CSRF?
- Usually not on its own. Lax keeps cookies off cross-site POSTs, which stops the classic hidden form. It still allows cookies on cross-site GET links, and requests from a sibling subdomain are same-site, so they carry every cookie. OWASP treats it as an extra layer beside a token or origin check.
- Can CSP stop CSRF attacks?
- No. CSP controls what your own page may load, run and frame. A CSRF attack starts on the attacker's page, where your policy does not apply at all. Use CSRF tokens, Sec-Fetch-Site or Origin checks, and SameSite cookies to stop it.
- What is the difference between a nonce and a hash in CSP?
- A nonce is a random value the server makes fresh for each response and puts on every script it trusts. A hash is a fingerprint of a script's exact content. Nonces suit pages built by a server on each request. Hashes suit static pages, but must be recalculated whenever a script changes.
- Should CSP go in a header or a meta tag?
- Use the Content-Security-Policy response header whenever you can. MDN says the meta tag does not support every CSP feature. In our Chromium 141 test, frame-ancestors and a report-only policy were both ignored in a meta tag. A meta tag is a fallback for static pages where you cannot set headers.
Sources and further reading
- OWASP Cheat Sheet Series: Cross Site Scripting Prevention
- OWASP Cheat Sheet Series: Cross-Site Request Forgery Prevention
- OWASP Cheat Sheet Series: Content Security Policy
- OWASP Cheat Sheet Series: HTML5 Security (local storage)
- MDN Web Docs: Content Security Policy (CSP) guide
- MDN Web Docs: CSP script-src directive (strict-dynamic)
- MDN Web Docs: Trusted Types API
- MDN Web Docs: Set-Cookie header (SameSite, HttpOnly, cookie prefixes)
- MDN Web Docs: Sec-Fetch-Site header
- The Chromium Projects: SameSite Updates (Lax by default rollout)
- MDN Web Docs: Subresource Integrity