
Security used to be treated as a backend topic, but many real attacks go through the browser: injected scripts, forged requests, stolen tokens and framed pages. Senior frontend interviews now regularly ask how XSS works, how CSRF differs from it, what CORS really protects, and where to store auth tokens.
This is Part 14 of the Learn Frontend series, after Next.js and Rendering Strategies. We start with the same-origin policy, the browser's core security rule, then cover XSS, CSP, CSRF and SameSite cookies, CORS, clickjacking, token storage, security headers and supply-chain risks.
1. The same-origin policy
An origin is the combination of scheme, host and port. https://shop.com and https://shop.com:443/cart are the same origin; http://shop.com, https://api.shop.com and https://shop.com:8080 are all different origins.
The same-origin policy (SOP) stops a script on one origin from reading data from another origin: its DOM, cookies, localStorage or the body of a fetch response. Without it, any site you visit could read your open email tab.
What SOP does not stop is equally important: a page can still send requests to other origins (forms, images, scripts), and the browser attaches cookies to them. That gap is what CSRF exploits. Note also that a site (scheme plus registrable domain, such as shop.com) is broader than an origin; SameSite cookies use sites, not origins.
2. Cross-site scripting (XSS)
XSS happens when an attacker gets their JavaScript to run on your origin. Because it runs as your page, it can read the DOM, call your APIs with the user's cookies, steal tokens from storage and change what the user sees.
- Stored XSS: the payload is saved on the server, for example in a comment, and served to every visitor.
- Reflected XSS: the payload is in the URL and the server echoes it into the page.
- DOM-based XSS: client-side code takes data from
location,postMessageor storage and writes it into a dangerous sink without the server ever seeing it.
const params = new URLSearchParams(location.search);
const name = params.get("name");
// Vulnerable: DOM-based XSS
// ?name=<img src=x onerror=alert(document.cookie)>
greeting.innerHTML = "Hello " + name;
// Safe: textContent never parses HTML
greeting.textContent = "Hello " + name;
// React escapes this automatically
<p>Hello {name}</p>
// Dangerous in React: only with sanitised HTML
import DOMPurify from "dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userHtml) }} />
// URL injection: block javascript: links
const safeHref = /^https?:\/\//i.test(url) ? url : "#";
<a href={safeHref}>Profile</a>Defences:
- Escape output by context. React and other frameworks escape text in JSX automatically. The danger zones are
dangerouslySetInnerHTML,innerHTML,v-html,eval,new Functionandhrefvalues. - If you must render user HTML (for example Markdown or a rich text editor), sanitise it with a well-tested library such as DOMPurify.
- Validate URLs: allow only
http:andhttps:before putting user input intohreforsrc, sincejavascript:URLs run code. - Use a Content Security Policy as a second line of defence, and Trusted Types to lock down DOM sinks.
- Mark session cookies
HttpOnlyso injected scripts cannot read them.
3. Content Security Policy (CSP)
CSP is a response header that tells the browser which sources of scripts, styles, images and connections are allowed. Even if an attacker injects a <script> tag, the browser refuses to run it if it breaks the policy.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-r4nd0m123' 'strict-dynamic';
style-src 'self' 'nonce-r4nd0m123';
img-src 'self' https://images.example-cdn.com data:;
connect-src 'self' https://api.example.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'self';
<!-- Only scripts carrying the matching nonce will run -->
<script nonce="r4nd0m123" src="/app.js"></script>- A nonce-based strict CSP is the modern recommendation: the server generates a random nonce per response and adds it to every legitimate script tag.
'strict-dynamic'lets those trusted scripts load further scripts. - Avoid
'unsafe-inline'and'unsafe-eval'; they remove most of the protection. frame-ancestorscontrols who can embed your page (clickjacking protection).- Roll out with
Content-Security-Policy-Report-Onlyfirst to collect violations without breaking the site.
4. CSRF and SameSite cookies
Cross-site request forgery tricks a logged-in user's browser into sending a request the user did not intend. A malicious page submits a hidden form to bank.com/transfer; the browser attaches the bank's session cookie, and the bank sees a valid authenticated request. The attacker cannot read the response, but the action still happens.
<!-- On evil.com: auto-submits as soon as the victim opens the page -->
<form action="https://bank.example/transfer" method="POST" id="f">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById("f").submit();</script>
// Defence on the server: a session cookie the browser will not send cross-site
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/- SameSite cookies are the main defence.
SameSite=Lax(the default in modern browsers when no value is set) does not send the cookie on cross-site POSTs, iframes or fetches, only on top-level GET navigations.Strictnever sends it cross-site.Nonealways sends it and requiresSecure. - CSRF tokens: a random token in the form or a custom header that the server checks. An attacker's page cannot read it because of SOP.
- Check the
OriginorSec-Fetch-Siteheader on state-changing requests. - Never change state with GET requests.
- Tokens sent in an
Authorizationheader are not attached automatically, so they are not vulnerable to CSRF, but they are exposed to XSS if stored in JavaScript-readable storage.
Short version: XSS runs the attacker's code on your site; CSRF makes the user's browser send a request from the attacker's site.
5. CORS: what it really does
Cross-Origin Resource Sharing is a way for a server to relax the same-origin policy. When app.com calls api.com with fetch, the browser sends the request but only lets JavaScript read the response if api.com replies with a matching Access-Control-Allow-Origin header.
// Frontend on https://app.example.com
const res = await fetch("https://api.example.com/orders", {
method: "PUT", // not "simple", so a preflight is sent first
credentials: "include", // send cookies cross-origin
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "shipped" }),
});
// Preflight response the API must return
// Access-Control-Allow-Origin: https://app.example.com (exact origin, not *)
// Access-Control-Allow-Credentials: true
// Access-Control-Allow-Methods: GET, POST, PUT, DELETE
// Access-Control-Allow-Headers: Content-Type
// Access-Control-Max-Age: 600- Simple requests (GET, HEAD or POST with basic content types) are sent directly. Other requests, such as PUT, DELETE, JSON bodies or custom headers, first send an
OPTIONSpreflight to ask permission. - To send cookies, the client sets
credentials: "include"and the server must reply withAccess-Control-Allow-Credentials: trueand a specific origin, never*. - CORS is enforced by the browser, not the server. It protects users, not your API. Tools like curl or Postman ignore it, so CORS is not authentication or access control.
- A "CORS error" in the console means the server did not allow your origin. The fix is on the server (or a same-origin proxy), not in the frontend code.
- Reflecting any
Originheader back with credentials allowed is a serious misconfiguration.
6. Clickjacking and framing
In clickjacking, an attacker loads your site in a transparent iframe on top of a decoy page, so the user thinks they are clicking the attacker's button but actually clicks "Delete account" on your site.
- Send
Content-Security-Policy: frame-ancestors 'self'(modern) orX-Frame-Options: DENY(legacy) to stop other sites from framing your pages. - When you use
postMessagebetween windows, always checkevent.originbefore trusting the message, and pass an exact target origin instead of"*"when sending. - Links with
target="_blank"getrel="noopener"behaviour by default in modern browsers, which stops the new page from controllingwindow.opener. Add it explicitly if you support old browsers.
7. Storing auth tokens safely
"Where should I store the JWT?" is a classic question. There is no perfect answer, only trade-offs:
| Storage | XSS risk | CSRF risk | Notes |
|---|---|---|---|
| localStorage / sessionStorage | High: any injected script can read it | None: not sent automatically | Simple, but one XSS bug leaks the token |
| JavaScript memory | Lower: lost on refresh, harder to find | None | Usually combined with a refresh cookie |
HttpOnly; Secure; SameSite cookie | Script cannot read it (it can still make requests while the page is compromised) | Handled with SameSite plus CSRF tokens | The usual recommendation for browser apps |
A common pattern is a short-lived access token kept in memory plus a long-lived refresh token in an HttpOnly cookie, or a backend-for-frontend that keeps tokens entirely on the server and gives the browser only a session cookie. Also: never put secrets or API keys in frontend code, since everything in the bundle is public, and in Next.js only variables prefixed with NEXT_PUBLIC_ reach the browser. More background: sessions vs cookies.
8. Security headers and the supply chain
- Strict-Transport-Security (HSTS) forces HTTPS for future visits and prevents SSL-stripping attacks.
- X-Content-Type-Options: nosniff stops the browser from guessing a file's type and running it as script.
- Referrer-Policy: strict-origin-when-cross-origin avoids leaking full URLs (which may contain tokens) to other sites.
- Permissions-Policy disables features you do not use, such as camera, microphone or geolocation.
- Subresource Integrity (SRI): add an
integrityhash to scripts loaded from a CDN, so a tampered file is refused. - Dependencies: a typical frontend has hundreds of npm packages, and malicious or compromised packages are a real attack vector. Commit lock files, run
npm auditor Dependabot, review new dependencies, and limit install scripts. - Third-party scripts such as analytics and chat widgets run with full access to your page. Load as few as possible and restrict them with CSP.
- The same-origin policy blocks reading cross-origin data, but not sending requests. CSRF exploits that gap.
- XSS runs attacker code on your origin. Escape output, avoid innerHTML and eval, sanitise user HTML with DOMPurify and validate URLs.
- A strict, nonce-based CSP is a strong second layer against XSS. Avoid unsafe-inline.
- Defend against CSRF with SameSite cookies, CSRF tokens, Origin checks and no state changes on GET.
- CORS is a browser-enforced way for servers to relax SOP. It is not authentication, and CORS errors are fixed on the server.
- Prefer HttpOnly, Secure, SameSite cookies for sessions; tokens in localStorage are exposed to any XSS.
- Use security headers (HSTS, nosniff, frame-ancestors, Referrer-Policy), SRI for CDN scripts, and watch your npm dependencies.
Practice problems
Try these on a small local app you control. Never test attacks on sites you do not own.
- Build a comment box that renders user input with innerHTML, show the XSS, then fix it with textContent and with DOMPurify.
- Add a strict nonce-based CSP to a small Express or Next.js app in report-only mode and read the violation reports.
- Set up two local servers on different ports and reproduce a CORS error, then fix it with the correct headers and credentials.
- Build a demo CSRF attack against a local form with a SameSite=None cookie, then block it with SameSite=Lax and a CSRF token.
- Add frame-ancestors to a page and confirm it can no longer be embedded in an iframe from another origin.
- Check a site you own with your browser devtools and a header scanner, and add any missing security headers.
Interview questions and FAQ
What is the difference between XSS and CSRF?
XSS injects and runs the attacker's script inside your site, so it can do anything the user can. CSRF does not run code on your site; it makes the user's browser send a forged request from another site, relying on cookies being attached automatically.
Does CORS protect my API from attackers?
No. CORS is enforced by browsers to protect users by controlling which origins can read responses. Any non-browser client can call your API directly. Use authentication and authorisation on the server.
Where should you store a JWT in the browser?
Ideally in an HttpOnly, Secure, SameSite cookie, so scripts cannot read it, together with CSRF protection. If it must be in JavaScript, keep a short-lived access token in memory and the refresh token in an HttpOnly cookie. Avoid long-lived tokens in localStorage.
Does React protect against XSS?
React escapes values rendered in JSX, which prevents most XSS. It does not protect dangerouslySetInnerHTML, javascript: URLs in href, direct DOM manipulation through refs, or server-rendered HTML you inject yourself.
What is a CORS preflight request?
An OPTIONS request the browser sends before a non-simple cross-origin request (for example PUT, DELETE, JSON content or custom headers) to ask the server which origins, methods and headers it allows.
What does SameSite=Lax do?
The cookie is sent on same-site requests and on top-level GET navigations from other sites, but not on cross-site POSTs, iframes, images or fetch calls. That blocks most CSRF attacks while keeping normal links working.
Comments
Post a Comment