Learn Frontend Part 14: Web Security - XSS, CSP, CSRF, CORS and Secure Token Storage

Learn Frontend Part 14: Web Security for Frontend Developers - CodingHumans

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, postMessage or 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 Function and href values.
  • 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: and https: before putting user input into href or src, since javascript: 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 HttpOnly so 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-ancestors controls who can embed your page (clickjacking protection).
  • Roll out with Content-Security-Policy-Report-Only first 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. Strict never sends it cross-site. None always sends it and requires Secure.
  • 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 Origin or Sec-Fetch-Site header on state-changing requests.
  • Never change state with GET requests.
  • Tokens sent in an Authorization header 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 OPTIONS preflight to ask permission.
  • To send cookies, the client sets credentials: "include" and the server must reply with Access-Control-Allow-Credentials: true and 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 Origin header 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) or X-Frame-Options: DENY (legacy) to stop other sites from framing your pages.
  • When you use postMessage between windows, always check event.origin before trusting the message, and pass an exact target origin instead of "*" when sending.
  • Links with target="_blank" get rel="noopener" behaviour by default in modern browsers, which stops the new page from controlling window.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:

StorageXSS riskCSRF riskNotes
localStorage / sessionStorageHigh: any injected script can read itNone: not sent automaticallySimple, but one XSS bug leaks the token
JavaScript memoryLower: lost on refresh, harder to findNoneUsually combined with a refresh cookie
HttpOnly; Secure; SameSite cookieScript cannot read it (it can still make requests while the page is compromised)Handled with SameSite plus CSRF tokensThe 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 integrity hash 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 audit or 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.
Key takeaways
  • 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.

  1. Build a comment box that renders user input with innerHTML, show the XSS, then fix it with textContent and with DOMPurify.
  2. Add a strict nonce-based CSP to a small Express or Next.js app in report-only mode and read the violation reports.
  3. Set up two local servers on different ports and reproduce a CORS error, then fix it with the correct headers and credentials.
  4. Build a demo CSRF attack against a local form with a SameSite=None cookie, then block it with SameSite=Lax and a CSRF token.
  5. Add frame-ancestors to a page and confirm it can no longer be embedded in an iframe from another origin.
  6. 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

Answered 0/0Correct 0