By Simeon Zimmermann

Web application frontend security: XSS, Content-Security-Policy, and where to keep tokens

Where XSS still gets into React and Astro apps, how to roll out a Content-Security-Policy safely, and why session tokens belong in HttpOnly cookies.

  • Security
  • Frontend
  • Web

Modern frameworks escape output by default, and most frontend security incidents still happen. They come from the exceptions: the one place that inserts HTML, the script added by marketing, the token stored where any script can read it. This article covers two topics that make the biggest difference in practice: preventing and limiting cross-site scripting (XSS), and deciding where session tokens live.

Why XSS is the central problem

If an attacker gets their JavaScript to run on your page, it runs with the full rights of the logged-in user. It can read everything on the page, call your API with the user’s session, and change what the user sees. Most other frontend defences exist to make this either harder to achieve or less damaging.

There are three common routes in:

  • Stored XSS: malicious content is saved (a comment, a profile field) and rendered for other users.
  • Reflected XSS: content from the URL or a request is echoed into the page.
  • DOM-based XSS: your own client-side code takes data from a source such as location.hash or postMessage and writes it into the page in an unsafe way.

Where XSS still gets in with React and Astro

React and Astro escape text in templates. The remaining risk sits in a small number of places, and you can search for each of them:

  1. Raw HTML insertion: dangerouslySetInnerHTML in React, set:html in Astro, innerHTML in plain scripts. Anything that reaches them from users, a CMS, or an API is a risk unless it is sanitised.
  2. Dangerous URLs: <a href={value}> or <iframe src={value}> with a javascript: URL. Only pass URLs you have checked.
  3. Rendered Markdown or rich text from untrusted authors, which is HTML in disguise.
  4. Third-party scripts: an analytics, chat, or ad script runs with the same rights as your own code, and its supply chain becomes yours.
  5. Unsafe sinks in your own code: eval, new Function, document.write, and dynamic script URLs.

For the first two, two small guards cover most cases:

import DOMPurify from "dompurify";

// Sanitise before any raw HTML insertion.
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.html) }} />
// Allow only the protocols you expect.
const allowedProtocols = new Set(["http:", "https:", "mailto:"]);

export function safeUrl(value: string): string | undefined {
  try {
    const url = new URL(value, window.location.origin);
    return allowedProtocols.has(url.protocol) ? url.href : undefined;
  } catch {
    return undefined;
  }
}

Use an allow-list of protocols, not a block-list of bad ones. And sanitise on the server as well if the HTML is stored, because the client check protects only the clients that run it.

Content-Security-Policy as a second layer

A Content-Security-Policy (CSP) is an HTTP header that tells the browser which scripts and other resources the page may load or run. If an XSS hole exists, a strict policy can stop the injected script from executing. It does not replace correct escaping. It limits the damage when escaping fails.

A strict starting point for a site with its own scripts:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{random}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self'

What the parts do:

  • default-src 'self': by default, load things only from your own origin.
  • script-src with a nonce: only scripts carrying a fresh, random value (generated per response and added to each <script nonce="...">) may run. An injected script has no valid nonce. 'strict-dynamic' lets those trusted scripts load further scripts.
  • object-src 'none' and base-uri 'none': close two old bypass routes (plugins and injected <base> tags).
  • frame-ancestors 'none': nobody can embed your pages in a frame, which prevents clickjacking. Use 'self' if you need to embed your own pages.
  • form-action 'self': forms can only post to your origin.

Avoid 'unsafe-inline' and 'unsafe-eval' in script-src. They switch off exactly the protection you want. A fully static site cannot generate a nonce per response, so it uses hashes of its inline scripts ('sha256-…') or avoids inline scripts altogether.

Rolling it out without an outage

  1. Send the policy as Content-Security-Policy-Report-Only first. The browser reports violations without blocking anything.
  2. Collect the reports for a week or two and fix what they show: inline handlers, forgotten third-party scripts, a font loaded from a CDN.
  3. Switch to Content-Security-Policy when the reports are quiet.
  4. Keep the report endpoint running, so a later change that breaks the policy is noticed.

For third-party scripts you cannot avoid, add Subresource Integrity (integrity="sha384-…" with crossorigin="anonymous") when the file is versioned and stable. It makes the browser refuse a file that has been changed. Browsers also support Trusted Types (require-trusted-types-for 'script') to restrict dangerous DOM sinks; check current browser support before you rely on it.

Where session tokens should live

After a login, the frontend holds some proof of identity: a session ID or a token. Where you keep it decides what an XSS hole can do.

Storage Readable by page scripts Main risk
localStorage / sessionStorage Yes Any XSS can copy the token and use it elsewhere
Cookie without HttpOnly Yes Same, and it is also sent automatically
Cookie with HttpOnly No Cross-site request forgery (CSRF)
JavaScript variable in memory By scripts in the same page Lost on reload; XSS can still read it

localStorage is popular because it is easy, and it is the weakest choice for a long-lived token. A script that runs on your origin can read it, so a single XSS hole turns into account takeover that continues after the user closes the tab.

An HttpOnly cookie cannot be read by JavaScript, so it cannot be copied out. A reasonable default:

Set-Cookie: __Host-session=…; Path=/; Secure; HttpOnly; SameSite=Lax
  • Secure: sent only over HTTPS.
  • HttpOnly: invisible to scripts.
  • SameSite=Lax: the browser does not send the cookie on cross-site POST requests, which stops most CSRF. Use Strict if your flows allow it.
  • The __Host- prefix makes the browser require Secure, Path=/, and no Domain, so another subdomain cannot overwrite it.

Because cookies are sent automatically, also protect state-changing requests from CSRF: check the Origin header on the server and, for sensitive operations, require a CSRF token that attackers cannot read.

Be realistic about what HttpOnly does. It stops an attacker from stealing the token, but injected script can still send requests that carry the cookie while the page is open. That is why it comes after XSS prevention and CSP, not instead of them.

If your frontend talks to a separate API or an identity provider, a common pattern is a small backend for frontend (BFF): the browser only holds a session cookie for your own backend, and the backend keeps the OAuth tokens and calls the API on the browser’s behalf. The tokens never reach the browser at all. IETF guidance for browser-based OAuth applications describes this as the most secure option for most cases.

A short checklist

  • Search the code for dangerouslySetInnerHTML, set:html, innerHTML, eval, and javascript:. Each hit needs a reason.
  • Sanitise any HTML from users or CMS content before rendering it, and validate URLs against a protocol allow-list.
  • Send a nonce- or hash-based CSP, start with report-only, and avoid 'unsafe-inline' for scripts.
  • Add frame-ancestors, object-src 'none', and base-uri to that policy.
  • Keep session tokens in Secure, HttpOnly, SameSite cookies, and protect state-changing requests against CSRF.
  • Treat every third-party script as part of your code: keep the list short and review it regularly.
  • Run npm audit or an equivalent in CI and keep lockfiles committed.
  • Check your headers in the browser’s developer tools and with a header scanner such as Mozilla’s HTTP Observatory after every change to the setup.

Want to talk this through for your product?

Tell me what you are building and where you are stuck. I reply personally.