Safeguard
Application Security

10 React security best practices

Real CVEs, real npm supply chain hijacks, and the concrete React practices — from CSP to token storage — that actually stop them.

Priya Mehta
DevSecOps Engineer
8 min read

React powers an estimated 40% of production JavaScript frontends tracked across major web-analytics crawls, and that scale makes it a fixed target rather than a moving one. On October 22, 2021, attackers compromised ua-parser-js — a device-detection library pulled into thousands of React builds — shipping versions 0.7.29, 0.8.0, and 1.0.0 with a postinstall script that dropped a cryptominer and password stealer on every machine that ran npm install that week. Two weeks later, on November 4, 2021, the coa and rc packages, transitive dependencies of react-scripts, were hijacked the same way, silently breaking Create React App builds worldwide. In March 2025, CVE-2025-29927 let attackers bypass Next.js middleware authorization with a single spoofed header, exposing admin routes in live React applications. These are not hypothetical risks — they are incidents with CVE numbers, patch dates, and confirmed exploitation. The practices below target the specific ways React applications actually get breached, from JSX injection to compromised build pipelines.

What Makes dangerouslySetInnerHTML the Top XSS Vector in React Apps?

dangerouslySetInnerHTML is the top XSS vector in React apps because it explicitly disables the JSX auto-escaping that protects every other render path, injecting raw HTML — and any embedded <script> tag or onerror handler riding along with it — directly into the DOM. Standard JSX expressions like {userInput} are converted to text nodes, so a string like <img src=x onerror=alert(1)> renders as harmless text. The moment a developer swaps that for <div dangerouslySetInnerHTML={{__html: userBio}} /> to render markdown previews, CMS content, or rich-text comments, that protection disappears entirely.

This pattern shows up constantly in blogging platforms, help-desk widgets, and admin dashboards that render user-submitted or third-party HTML. The fix is to never pass unsanitized input to this prop: run it through DOMPurify with an explicit tag/attribute allowlist first, and treat any markdown renderer (react-markdown, marked, remark) as untrusted unless its plugin chain has been audited. Pair sanitization with a strict CSP (covered below) as a second layer, because sanitizer bypasses do surface — DOMPurify itself has shipped multiple mutation-XSS patches, most recently addressing nested SVG/MathML bypass techniques in its 3.x line.

Why Do npm Supply Chain Attacks Hit React Projects So Often?

npm supply chain attacks hit React projects so often because a typical React app pulls in 1,000+ transitive dependencies through npm, and a small number of maintainers control packages downloaded millions of times a week — giving an attacker who compromises one maintainer account or one publish token a blast radius spanning every downstream app. The ua-parser-js incident is the clearest example: the malicious postinstall script executed automatically on install, no application code needed to call the compromised function, and it was live for roughly 30-60 minutes before npm pulled it — long enough to hit CI pipelines running scheduled builds. The coa/rc compromise a month later followed the identical playbook and broke react-scripts-based CRA builds globally because coa sat several layers deep in the dependency tree, invisible to most package.json files.

Defend against this with npm ci and committed lockfiles (never floating semver ranges in production), npm audit signatures to verify provenance attestations, and --ignore-scripts in CI where lifecycle scripts aren't required. Track every dependency — including transitive ones — in a generated SBOM so a compromise like coa/rc is a five-second lookup instead of a multi-day dependency-tree archaeology project.

How Did CVE-2025-29927 Let Attackers Bypass Authorization in Next.js React Apps?

CVE-2025-29927, disclosed March 21, 2025, let attackers bypass Next.js middleware-based authorization by sending a request with a crafted x-middleware-subrequest header, which tricked the framework's internal routing into treating the request as an already-processed internal subrequest and skipping the middleware chain — including auth checks — entirely. The bug affected Next.js versions from 11.1.4 up through releases prior to 12.3.5, 13.5.9, 14.2.25, and 15.2.3, meaning any React app using middleware.ts to gate /admin or /api routes behind a session check was potentially exposed to unauthenticated access with nothing more than one extra HTTP header.

The patch removes trust in that header at the edge, but the deeper lesson is architectural: middleware should never be the only authorization boundary. Re-verify identity and permissions inside the route handler or data-access layer itself, so that a routing-layer bypass — this one or the next one — doesn't translate directly into a data breach. Teams still on unpatched Next.js versions after March 2025 were running a publicly known, actively scanned-for authorization bypass in production.

Where Should React Apps Store Authentication Tokens to Prevent Theft?

React apps should store authentication tokens in httpOnly, Secure, SameSite cookies set by the server — never in localStorage or sessionStorage, where any successful XSS payload, including one delivered through a compromised dependency like ua-parser-js, can exfiltrate the token with a single document.localStorage read and a fetch call to an attacker's server. localStorage has no origin-level script restriction; if an attacker gets any JavaScript to execute on the page, the token is theirs, and JWTs stored this way often carry hours of validity with no revocation mechanism.

httpOnly cookies are invisible to document.cookie and any injected script, closing that exfiltration path entirely. The tradeoff is CSRF exposure, which SameSite=Lax or Strict plus a double-submit CSRF token neutralizes for the vast majority of request patterns. OWASP's ASVS (V3.4) explicitly requires this for session tokens; if a React app currently reads a JWT out of localStorage on every request, that's a direct line from any future dependency compromise to full account takeover.

How Does a Strict Content Security Policy Stop React-Specific Injection Attacks?

A strict Content Security Policy with no unsafe-inline and no unsafe-eval stops React-specific injection attacks because it blocks the exact browser primitives most XSS payloads depend on — inline <script> execution and eval-based dynamic code — so that even if malicious HTML slips through a dangerouslySetInnerHTML gap or a compromised dependency, the browser simply refuses to run it. This is not theoretical: in June 2024, the cdn.polyfill.io domain was sold to a new operator and began injecting malware into scripts served to over 100,000 sites, a meaningful share of which were React apps that had added <script src="https://cdn.polyfill.io/..."> to index.html years earlier and forgotten about it. A script-src CSP allowlisting only first-party and explicitly vetted origins would have refused to execute that injected payload the moment the CDN went rogue.

Older Create React App dev servers required unsafe-eval for hot module reloading, but production builds don't need it — set the policy per environment, use nonces for any legitimate inline scripts, and wire up report-uri/report-to so violations (a strong early signal of an active injection attempt or a stale third-party script) show up in logs instead of silently failing closed.

Why Did Create React App's 2025 Deprecation Turn Into a Security Problem?

Create React App's 2025 deprecation turned into a security problem because the React team officially marked CRA as deprecated in February 2025 and react-scripts stopped receiving updates, leaving every app still built on it running frozen versions of webpack, Babel, and dozens of transitive build-tool dependencies that will never get a patch for newly disclosed CVEs. This isn't abstract: loader-utils, a common CRA-era transitive dependency, had a prototype pollution flaw (CVE-2022-37601) patched in version 2.0.3 back in 2022, and any CRA project still pinned to older react-scripts releases has no supported path to that fix short of manual overrides.

Migrate active projects to Vite or Next.js, both of which ship current tooling and active CVE response. For apps that can't migrate immediately, use npm overrides or Yarn resolutions to force patched versions of known-vulnerable transitive dependencies, and flag the project explicitly as running unsupported build tooling in your risk register so it gets prioritized rather than forgotten.

How Safeguard Helps

Safeguard's reachability analysis takes the ua-parser-js and coa/rc scenarios out of the theoretical: instead of flagging every CVE in your package-lock.json, it traces whether the vulnerable function in a compromised or flawed package is actually called from your React app's code paths, cutting alert volume to what's exploitable rather than what's merely present. Griffin AI reviews pull requests for the specific patterns covered above — new dangerouslySetInnerHTML calls, tokens written to localStorage, missing CSP headers — and flags them before merge instead of after a breach. Safeguard generates and ingests SBOMs automatically on every build, so when the next ua-parser-js-style compromise hits npm, you get a direct answer to "are we exposed" in seconds instead of days of manual dependency-tree searching. And where a fix is unambiguous — bumping a patched version, adding a SameSite attribute, tightening a script-src directive — Safeguard opens an auto-fix PR directly against the affected branch, so remediation ships at the speed the attack moved.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.