Security in React apps is often misunderstood as something React handles entirely for you. The truth is more nuanced: React prevents a lot of XSS, but not all of it, and the responsibility still rests partly on your shoulders. Over 5+ years building production platforms, I've seen teams confidently ship vulnerabilities because they assumed React's safety nets covered everything.
By day 84, you're past the basics of React. You understand components, state, hooks. Now it's time to understand where your app can bleed—and how to stop it.
JSX Escapes by Default — Text in Curly Braces Is Escaped Automatically
React's most powerful security feature is invisible. When you render a string in JSX using curly braces, React automatically escapes HTML special characters. This prevents the most common XSS attacks.
Here's what that means in practice:
const userName = "<img src=x onerror='alert(\"hacked\")' />";
export default function Profile() {
return <h1>Welcome, {userName}</h1>;
}React converts that malicious string to safe HTML entities. The browser sees <img src=x... instead of an executable script tag. The user sees the literal angle brackets rendered as text.
This escaping happens for:
- Text content in curly braces
- String values in attributes (when quoted)
- Children passed to components
I've watched developers unconsciously rely on this protection. They pull user data from an API, drop it into JSX, and sleep soundly. For text content, that's perfectly safe. The problem emerges when you deviate from this pattern.
dangerouslySetInnerHTML — Only with Sanitized HTML. The Name Is a Warning
The moment you use dangerouslySetInnerHTML, you're opting out of React's protection. React is literally telling you: you are responsible now. And it's named that way for a reason.
One real scenario I've handled: a CMS platform where blog posts were stored as HTML in the database. We couldn't render them as text—they needed formatting. We had to render HTML, which meant dangerouslySetInnerHTML.
// ❌ DANGEROUS: Never do this
const blogContent = await fetchBlogPostHTML(postId);
export default function BlogPost() {
return (
<article>
<div dangerouslySetInnerHTML={{ __html: blogContent }} />
</article>
);
}If an attacker controls the CMS or the API is compromised, arbitrary JavaScript runs in your user's browser. This is game over.
The fix is sanitization. I've used the sanitize-html and DOMPurify libraries on production e-commerce platforms. They parse HTML, strip dangerous elements, and let safe tags through.
import DOMPurify from 'dompurify';
const blogContent = await fetchBlogPostHTML(postId);
const sanitizedHTML = DOMPurify.sanitize(blogContent);
export default function BlogPost() {
return (
<article>
<div dangerouslySetInnerHTML={{ __html: sanitizedHTML }} />
</article>
);
}DOMPurify strips <script>, event handlers like onerror, and javascript: URLs. Configure it to allow only the tags you need. On a travel booking platform, I limited it to <p>, <strong>, <em>, <a>. That covered 99% of legitimate content and eliminated 100% of attack surface.
Rule of thumb: if you control the HTML, you don't need dangerouslySetInnerHTML. If you don't control it, sanitize first.
javascript: URLs — Validate User-Provided Links Before Rendering Them in href
This one catches developers off guard because it feels harmless. You're setting a link attribute, not embedding HTML. React won't save you here.
const userLink = "javascript:alert('xss')";
export default function Profile({ userProfile }) {
return <a href={userLink}>Visit profile</a>;
}Clicking that link executes JavaScript. React doesn't escape the href attribute in this case because it's valid to have dynamic URLs. It's your job to validate.
I've seen this in ride-hailing platforms where drivers can add a website URL to their profile. A malicious driver embeds javascript: instead. Every passenger who clicks it gets attacked.
Validate before render:
const isValidUrl = (urlString) => {
try {
const url = new URL(urlString);
return ['http:', 'https:'].includes(url.protocol);
} catch {
return false;
}
};
export default function ProfileCard({ profileUrl }) {
if (!isValidUrl(profileUrl)) {
return <p>No valid profile link</p>;
}
return <a href={profileUrl}>Visit profile</a>;
}The URL constructor throws if the string isn't a valid URL. By checking that the protocol is http: or https:, you block javascript:, data:, and other dangerous schemes. This is defensive and clear.
On SaaS platforms I've worked on, we also allowlisted domains for redirects. If a user can set their profile link to any URL, a competitor could set it to their site and drive traffic away. Validation solves both security and business logic.
Content Security Policy — A Strict CSP Limits Damage When Something Slips Through
CSP is your second line of defense. Even if an XSS vulnerability exists in your code, a strict CSP can prevent the attack from running.
CSP works by declaring what resources your page is allowed to load. The browser blocks anything not on the allowlist. This is deployed via HTTP headers or meta tags.
A strict CSP for a React app might look like:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' https:;
connect-src 'self' https://api.example.com;What this does:
default-src 'self'— Only load resources from your domain by defaultscript-src 'self'— Only load JavaScript from your domain. Inline scripts blocked. Attacker can't run injected<script>tags.style-src 'self' 'unsafe-inline'— Styles from your domain or inline (React components often use inline styles)img-src 'self' https:— Images from your domain or any HTTPS sourceconnect-src 'self' https://api.example.com— XHR/fetch only to your domain or your API
I've implemented CSP on e-commerce platforms where the catalog has third-party seller images. Without CSP, if the image URL pointed to a malicious server, it could be a vector for tracking or fingerprinting. CSP limited it to HTTPS, adding friction.
The trickiest part: finding a balance. Too strict, and your app breaks. Too loose, and you lose protection. React apps often struggle with script-src because build tools inject hashes or nonces. Modern Next.js handles this with built-in CSP support.
Next.js example from a trading platform I worked on:
// next.config.js
module.exports = {
headers: async () => [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'nonce-{nonce}';",
},
],
},
],
};CSP also provides report-uri endpoints where the browser sends CSP violations. On production, I've parsed these logs to catch XSS attempts in real time. It's your early warning system.
Common Mistakes
- Trusting all API data. Your backend might be compromised or misconfigured. Always validate external data as if it's hostile. On one project, a database migration corrupted user-supplied content with injected scripts. We caught it because sanitization was already in place.
- Assuming third-party libraries are safe. npm packages get compromised. A library might start innocent and turn malicious in an update. Audit dependencies, use lock files, and keep packages updated. I once found a logging library exfiltrating session tokens.
- Disabling CSP to make development easier. I've seen teams set
script-src 'unsafe-inline'globally to avoid build complexity. This is security theater. CSP only works if it's strict. Handle it properly in your build pipeline. - Using user input in template literals for URLs. Even string interpolation can be exploited if you're not careful about protocols. Always validate, don't just concatenate.
- Forgetting about attribute injection. Attributes aren't just
href. Event handlers likeonmouseoverin user-controlled content are dangerous. Sanitization catches this; validation alone doesn't.
Key Takeaways
- React escapes text by default in JSX. This prevents most XSS, but it's one layer, not the whole defense.
dangerouslySetInnerHTMLrequires sanitization. Use DOMPurify or sanitize-html. Configure to allow only necessary tags.- Validate URLs before rendering them in
href. Check the protocol, not just the format. Allowlist domains if possible. - Content Security Policy is your safety net. Deploy a strict policy and monitor violations. It stops attacks that slip through application code.
- Security in React apps is shared responsibility. React does the heavy lifting for text content. Everything else requires your vigilance.
- Treat all external data as hostile. APIs, databases, user input, third-party libraries. Validate, sanitize, and validate again.
- Test your defenses. If you deploy
dangerouslySetInnerHTML, test it with known XSS payloads. If you set CSP, check browser console for violations.
Security is never one fix. It's a series of small decisions that compound. By day 84, you're building real systems. Users trust you with their data. Make decisions that honor that trust.






Comments
Loading comments…