Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 7 of 12AdvancedAug 3, 2026

Security Architecture

XSS and CSRF as architectural concerns, designing a Content Security Policy, the httpOnly cookie versus localStorage decision, and sandboxing third-party scripts.

frontend-system-designsecurityxss

Why It Matters

Frontend security fails architecturally, not tactically. Nobody writes an XSS vulnerability on purpose - it appears because a rich text feature needed raw HTML, because a redirect parameter was never validated, because a third-party widget was added without anyone asking what privileges it inherits.

The useful posture is layered: assume any single control will eventually be bypassed, and design so that a bypass is contained rather than total.

XSS as an Architectural Concern

Cross-site scripting is attacker-controlled JavaScript executing in your origin. Once that happens, the attacker has everything your JavaScript has - the DOM, localStorage, non-httpOnly cookies, and the ability to make authenticated requests as the user.

Where auto-escaping helps, and where it stops

Modern frameworks escape interpolated values by default. {userInput} in React renders as text, not markup, and that closes off the most common historical vector almost entirely.

The vulnerabilities that remain are the places that opt out:

dangerouslySetInnerHTML and innerHTML. This is the actual attack surface in most modern codebases. It exists in nearly every application - rich text editors, CMS-authored content, email previews, markdown rendering - and it accepts raw HTML by definition.

URL-valued attributes. An href or src bound to user input accepts javascript:alert(1). Escaping does not help, because the value is a valid attribute value, not markup. These need explicit protocol allowlisting - accept http, https, and mailto, reject the rest.

Server-rendered state bootstrapping. Interpolating user data into a <script> tag to hydrate the client injects into a JavaScript context, where HTML escaping is the wrong escaping entirely. A string containing </script> breaks out of the tag.

Third-party DOM manipulation. Any library that touches the DOM directly is outside the framework's model and outside its guarantees.

Sanitization and its limits

When you genuinely must render user HTML, sanitize with a maintained, allowlist-based library - DOMPurify being the standard answer. Allowlist, never blocklist: enumerate the tags and attributes you permit and drop everything else, because the set of dangerous constructs is open-ended and grows with every browser release.

Understand what sanitization is and is not. It is a parser competing with the browser's parser, and mutation-XSS bugs - where the sanitizer and the browser disagree about how ambiguous markup parses - are a recurring class of finding. Sanitize on the server, or on both sides. Client-only sanitization is bypassed by anything that writes to your API directly.

CSRF

Cross-site request forgery abuses credentials the browser attaches automatically. An attacker's page triggers a request to your API; the browser dutifully includes the user's cookie; your server sees an authenticated request the user never intended.

The defenses:

SameSite cookies. SameSite=Lax (the modern default) stops cookies being sent on cross-site POSTs while still allowing top-level navigations. Strict is tighter but breaks legitimate inbound links from other sites. This is the strongest single control and it is nearly free.

CSRF tokens. An unpredictable per-session token, submitted with the request and validated server-side. An attacker's page cannot read it, because the same-origin policy stops it reading your pages.

Origin and Referer validation. Reject state-changing requests whose Origin does not match. Cheap defense in depth.

The relationship to token auth is where candidates go wrong. A bearer token in an Authorization header is attached by your JavaScript, and an attacker's page cannot run your JavaScript - so CSRF does not apply. But this is only true if cookie auth is genuinely not accepted. If any endpoint takes a session cookie as a fallback, that endpoint is CSRF-exposed no matter what the main flow does.

Content Security Policy

CSP tells the browser what your page is allowed to load and execute. It is the layer that assumes XSS prevention has already failed and limits the damage.

The most valuable directive is script-src, because it governs execution. A well-designed policy:

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-r4nd0mV4lue' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data: https://cdn.example.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  object-src 'none';
  base-uri 'self'

Nonce-based allowlisting is the mechanism that actually works. The server generates a fresh random nonce per response and stamps it on the script tags it emits. Scripts without the nonce do not execute.

Diagram
100%
sequenceDiagram participant A as Attacker participant S as Your Server participant B as Browser S->>B: HTML + CSP: script-src 'nonce-a8f3d9' Note over S,B: Fresh random nonce per response S-->>B: script nonce="a8f3d9" - your code B->>B: nonce matches policy B->>B: EXECUTES A->>S: stored comment: script src=evil.com S-->>B: comment renders, tag is in the DOM B->>B: no nonce on this tag B->>B: BLOCKED - refused to execute B->>S: CSP violation report Note over A,B: Payload injected successfully,<br/>but never executes
visualized byIOCombats

Two properties are essential. The nonce must be unpredictable and per-response - a static nonce is a password printed in the page source. And avoid 'unsafe-inline', which disables the protection entirely; teams add it to fix a broken widget and silently turn CSP off.

'strict-dynamic' is what makes this practical: it lets a nonce-approved script load further scripts it trusts, so bundlers and tag managers keep working without a URL allowlist that would need constant maintenance.

Deploy with Content-Security-Policy-Report-Only first. Collect violations for a couple of weeks, fix what breaks, then enforce. Enforcing a policy you have not measured breaks production.

CSP is defense in depth, not a substitute for output encoding. It does nothing about injected markup that is not a script - a fake login form styled to look like yours needs no JavaScript at all.

Token Storage: A Real Architectural Decision

This is the question where "it depends" is the correct answer, because the two options are vulnerable to opposite attacks.

Diagram
100%
flowchart TB subgraph HC["httpOnly cookie"] H1["JavaScript cannot read it"] H2["XSS payload cannot steal the token"] H3["Browser attaches it automatically"] H4["CSRF-exposed - needs SameSite + token"] H5["Attacker acts only while<br/>user is on their page"] end subgraph LS["localStorage"] L1["Any JavaScript on the page reads it"] L2["One XSS - token exfiltrated to attacker server"] L3["Nothing attaches it automatically"] L4["CSRF does not apply"] L5["Stolen token replayed later,<br/>from anywhere, until expiry"] end X["XSS occurs"] -->|"token safe"| H2 X -->|"token stolen"| L2 C["CSRF attempted"] -->|"blocked by SameSite + token"| H4 C -->|"not applicable"| L4 style HC fill:#1e3f2d,stroke:#22c55e style LS fill:#3f2d1e,stroke:#f59e0b style X fill:#3f1e1e,stroke:#ef4444
visualized byIOCombats

httpOnly cookie: immune to XSS theft (JavaScript literally cannot read it), exposed to CSRF (the browser sends it automatically).

localStorage: immune to CSRF (nothing attaches it automatically), fully exposed to XSS (any script on the page reads it).

httpOnly usually wins, and the reason is asymmetry of consequences, not likelihood:

  • CSRF lets an attacker act as the user while they are on the malicious page. Bounded, and defeated by SameSite plus a token - controls that are cheap and reliable.
  • A stolen bearer token can be exfiltrated and replayed later, from anywhere, until it expires. There is no equivalently cheap control, and rotation only shortens the window.

The practical shape most production systems land on: a short-lived access token in memory (never persisted), a long-lived refresh token in an httpOnly, Secure, SameSite cookie scoped to the refresh endpoint. XSS cannot read the refresh token, the access token dies with the tab, and CSRF is contained by cookie attributes.

Sandboxing Third-Party Scripts

A <script src="https://vendor.example/tag.js"> in your page runs with the full privileges of your origin. It can read the DOM, read localStorage, read non-httpOnly cookies, intercept form input, and make authenticated requests as the user.

You are extending complete trust to that vendor - and transitively to whoever compromises them. This is exactly how supply chain incidents propagate: the vendor is breached, and every site embedding their tag executes attacker code.

Containment, strongest first:

Cross-origin sandboxed iframe. Run the script in an iframe on a different origin. The same-origin policy then isolates it from your DOM, storage, and cookies by design. Add sandbox to restrict further, and communicate through a narrow postMessage contract. This is the only option that provides real isolation - and it costs the vendor the page access they usually want, which is the real negotiation.

CSP restriction. script-src limits which origins can serve scripts; connect-src limits where data can be sent. This will not stop a malicious script reading the DOM, but it can stop it exfiltrating what it read.

Subresource Integrity. A hash on the script tag; the browser refuses to execute the file if its content changed. This defends against the vendor's CDN being compromised, and it is incompatible with tags that self-update - which most analytics vendors require.

Self-hosting a reviewed copy. You audit a version and serve it yourself. Strong control, real ongoing maintenance cost.

The organisational point worth making: the number of third-party scripts on a page is a security decision, and it is usually made by people who are not weighing it as one. Having a review gate for new tags is worth more than any individual technical control.

Tradeoffs

OptionProsConsWhen to Use
httpOnly cookie authXSS cannot steal the token, browser-managed, works without JSCSRF-exposed, needs SameSite and tokens, awkward cross-originDefault for browser sessions
localStorage tokenSimple, no CSRF surface, easy cross-originOne XSS exfiltrates a replayable tokenRarely - only where cookies are genuinely unavailable
In-memory + httpOnly refreshAccess token dies with the tab, refresh token unreadable by JSMore moving parts, refresh flow on every reloadProduction SPAs handling anything sensitive
Nonce-based CSPBlocks injected scripts even after XSS succeeds, reports violationsNeeds per-response server rendering of the nonce, breaks careless inline scriptsAny application handling user data
CSP with 'unsafe-inline'Nothing breaksProvides essentially no XSS protectionNever - if you need it, the policy is not ready
Sandboxed cross-origin iframeTrue isolation from DOM, storage, and cookiesVendor loses the access they want, postMessage contract to maintainUntrusted widgets, ads, embeds, user-authored content
Direct third-party script tagVendor works out of the box, zero integration effortFull origin privileges, inherits the vendor's supply chain riskOnly for vendors you would trust with your database

Where This Applies

The token storage decision determines how every authenticated request in Networking and Data Fetching is constructed, and CSRF exposure depends directly on that choice. Iframe sandboxing appears in Application Architecture at Scale as an isolation strategy for micro-frontends - the same primitive serving a different goal. CSP violation reports are a telemetry stream that belongs in the pipeline described in Observability, and the rule about never logging tokens or PII client-side is stated there.

In the applied practice problems, this decides Rich Text Editor, where pasted HTML must render without becoming an injection vector, File Upload System, where a presigned URL is the narrowest possible capability, and Shopping Cart, where a client-computed total is a suggestion and the login merge requires session rotation.

Advertisement

Frequently Asked Questions

React escapes output by default. Why do XSS vulnerabilities still appear in React applications?

Because the default is only a default, and every real application has escape hatches. dangerouslySetInnerHTML bypasses escaping entirely and exists in most codebases for rendering rich text or CMS content. An href or src bound to user input accepts a javascript URL scheme, which escaping does not touch because the value is a valid attribute, not markup. Third-party libraries that touch the DOM directly are outside React's model altogether. And server-rendered pages that interpolate user data into a script tag to bootstrap state inject into a JavaScript context, where HTML escaping is the wrong escaping. The framework closes the common case and leaves the interesting ones open.

Why does CSRF matter less with token-based auth but still matter with cookies?

Because CSRF exploits credentials the browser attaches automatically. A cookie is sent on any request to its origin regardless of what page triggered it, so an attacker's page can submit a form to your API and the browser helpfully authenticates it. A token in an Authorization header is attached by your JavaScript, and an attacker's page cannot run your JavaScript or read your token, so it cannot construct an authenticated request. The nuance is that it still matters with cookies even if you also use tokens - if any authenticated endpoint accepts cookie auth as a fallback, that endpoint is CSRF-exposed regardless of what the main flow does.

How does a nonce-based CSP stop an injected script that is already on the page?

The policy says scripts execute only if they carry the current request's nonce. The server generates a fresh random nonce per response and stamps it onto the script tags it emits. An injected script did not come from your server, so it has no nonce, and the browser refuses to execute it even though the tag is sitting in the DOM. The critical property is that the nonce must be unpredictable and regenerated per response - a static nonce is just a password the attacker reads from the page source. It is defense in depth, not a substitute for output encoding, because it does nothing about injected markup that is not a script.

Walk through the httpOnly cookie versus localStorage decision.

They are vulnerable to opposite attacks, which is what makes it an architectural decision rather than a preference. An httpOnly cookie cannot be read by JavaScript, so an XSS payload cannot steal the token - but the browser attaches it automatically, so it is CSRF-exposed and needs SameSite plus a token. localStorage is readable by any JavaScript on the page, so a single XSS gets the token and can exfiltrate it to an attacker's server for offline use - but nothing attaches it automatically, so CSRF does not apply. The reason httpOnly usually wins is asymmetry of consequences - CSRF lets an attacker act as the user while they are on the malicious page, whereas a stolen bearer token can be replayed later from anywhere.

A marketing team wants to add a third-party analytics tag. What is the actual risk and how do you contain it?

A script tag in your page runs with the full privileges of your origin - it can read the DOM, read localStorage, read cookies that are not httpOnly, intercept form input, and make authenticated requests as the user. You are extending complete trust to a vendor and, transitively, to whoever compromises that vendor, which is how supply chain incidents propagate. Containment options, roughly in order of strength - run it in a sandboxed iframe on a different origin so the same-origin policy isolates it, restrict what it can reach with CSP connect-src and script-src, pin it with subresource integrity so the file cannot change under you, or self-host a reviewed copy. Full isolation costs you the data access the vendor wants, which is the real negotiation.