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.
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.hashorpostMessageand 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:
- Raw HTML insertion:
dangerouslySetInnerHTMLin React,set:htmlin Astro,innerHTMLin plain scripts. Anything that reaches them from users, a CMS, or an API is a risk unless it is sanitised. - Dangerous URLs:
<a href={value}>or<iframe src={value}>with ajavascript:URL. Only pass URLs you have checked. - Rendered Markdown or rich text from untrusted authors, which is HTML in disguise.
- Third-party scripts: an analytics, chat, or ad script runs with the same rights as your own code, and its supply chain becomes yours.
- 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-srcwith 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'andbase-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
- Send the policy as
Content-Security-Policy-Report-Onlyfirst. The browser reports violations without blocking anything. - 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.
- Switch to
Content-Security-Policywhen the reports are quiet. - 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. UseStrictif your flows allow it.- The
__Host-prefix makes the browser requireSecure,Path=/, and noDomain, 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, andjavascript:. 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', andbase-urito that policy. - Keep session tokens in
Secure,HttpOnly,SameSitecookies, 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 auditor 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.