Preventing XSS, CSRF, and SQL Injection
I walk through the concrete defenses for XSS, CSRF, and SQL injection — output encoding, CSP, SameSite cookies, CSRF tokens, and parameterized queries — with working TypeScript code examples.

Every developer who has taken a security course has heard the same advice: sanitize your inputs. It sounds like a single thing. It isn’t.
CSRF doesn’t exploit malformed input at all — the browser sends a perfectly well-formed request and the server can’t tell it apart from a legitimate one. XSS is about where data ends up in a page, not what it looks like before it gets there. SQL injection is the case where the “sanitization” framing gets closest to the truth, but even there the actual fix is structurally different from any cleaning step.
These three attacks are grouped together because they’re common, not because they share a root cause. Their defenses don’t share one either.
I’ll walk through what actually stops each one, why each technique works mechanically, and where it can still fail.
On this page
- XSS prevention: output encoding and CSP
- CSRF prevention: SameSite cookies and tokens
- SQL injection prevention: parameterized queries
- Defense in depth: combining the layers
- Where these defenses can still fail
- Think in mechanisms, not labels
XSS prevention: output encoding and CSP
Cross-site scripting works by injecting a script into a page that another user views. The browser runs it because it looks the same as any other script — there’s no magic signal that says “this came from user input.”
The fix is output encoding: converting characters that have special meaning in HTML into safe equivalents before rendering them. The < character becomes <, " becomes ", and the browser displays those as text instead of parsing them as markup.
The piece most explanations skip: the encoding method depends on where the data ends up in the page, not just that you encode it. The OWASP XSS Prevention Cheat Sheet calls these “injection contexts.” Encoding for an HTML body, an HTML attribute value, a JavaScript string, and a CSS property each requires a different approach. Applying HTML entity encoding to a value that ends up inside a <script> block doesn’t stop anything.
Modern frameworks — React, Vue, Angular — handle HTML body encoding automatically, which is why XSS is less common in apps that use them correctly. The gaps appear wherever the framework deliberately stops escaping: dangerouslySetInnerHTML in React, Angular’s bypassSecurityTrustHtml, or any place where you’re rendering raw HTML from user input. Those still need explicit sanitization with a dedicated library.
The second layer is a Content Security Policy. A CSP header tells the browser which sources are allowed to run scripts on a page.
import type { Request, Response, NextFunction } from "express";
export function cspMiddleware(
_req: Request,
res: Response,
next: NextFunction
): void {
res.setHeader(
"Content-Security-Policy",
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
].join("; ")
);
next();
}
script-src 'self' blocks any script that didn’t come from your own origin. object-src 'none' removes the old plugin injection vector. base-uri 'self' prevents base tag injection, which can redirect all relative URLs to an attacker-controlled domain.
OWASP is explicit that CSP is a defense-in-depth tool, not a primary fix. Output encoding at the point where data is rendered comes first. CSP limits the blast radius if encoding fails somewhere you didn’t notice.
CSRF prevention: SameSite cookies and tokens
CSRF works because browsers attach cookies automatically to every request to a domain — including requests triggered by another website’s HTML or JavaScript. An attacker embeds a form or fetch call on their own site, a logged-in user stumbles onto it, and the browser sends the session cookie along without asking.
The request content isn’t malformed. Input sanitization doesn’t apply. The problem is that the server can’t distinguish an intentional request from a forged one.
SameSite cookies address this at the cookie level. The attribute controls whether the browser includes a cookie in cross-site requests.
// Set a session cookie that won't travel on cross-site requests
res.cookie("session", sessionToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
});
SameSite=Strict means the cookie is never sent on cross-origin requests, even when a user follows a regular link from another site to yours. SameSite=Lax — the default in Chrome since 2020, followed by Firefox and Edge — allows the cookie on top-level navigations with safe HTTP methods but blocks cross-site POST requests.
The limitation worth knowing: SameSite scope is the registrable domain, not the full origin. A cookie on app.example.com is treated as same-site by a request from other.example.com. If you share a domain with subdomains you don’t fully control, SameSite alone isn’t enough.
CSRF tokens fill that gap. The server generates a random, unpredictable token tied to the session and includes it in the response. The client must send it back with state-changing requests. An attacker on a different origin can’t read cookies or response bodies from your domain, so they can’t get the token.
import crypto from "node:crypto";
/** Generates a cryptographically random CSRF token. */
export function generateCsrfToken(): string {
return crypto.randomBytes(32).toString("hex");
}
/**
* Compares a stored token and a request token in constant time.
* Using constant-time comparison prevents timing attacks that could
* allow an attacker to guess the token one character at a time.
*/
export function verifyCsrfToken(
storedToken: string,
requestToken: string
): boolean {
if (storedToken.length !== requestToken.length) return false;
return crypto.timingSafeEqual(
Buffer.from(storedToken, "hex"),
Buffer.from(requestToken, "hex")
);
}
The token must travel in the request body or a custom header — never in a cookie. Cookies travel automatically; body parameters and custom headers require the page’s own JavaScript to set them, which cross-origin code cannot do.
If you’re building a REST API — like the kind covered in my post on building a REST API with Node.js and Express — CORS configuration with a strict Access-Control-Allow-Origin provides partial protection for requests with custom headers, because those trigger a preflight that the server controls. But CORS and CSRF protection are separate mechanisms addressing different threat models. Using one to substitute for the other is a common gap.
Understanding how session tokens relate to identity connects directly to the authentication vs. authorization boundary — CSRF attacks act with a user’s identity and privileges, even though the attacker never steals the credentials.
SQL injection prevention: parameterized queries
SQL injection works by constructing a query string where user-supplied data and SQL syntax are concatenated. The database parser can’t tell where the data ends and the command begins.
-- Vulnerable: input appended directly to the query string
SELECT * FROM users WHERE email = 'user@example.com' OR '1'='1'
The ' OR '1'='1 suffix turns a single-user lookup into a query that returns every row. More destructive payloads can drop tables, exfiltrate schemas, or bypass authentication entirely.
The fix is parameterized queries (also called prepared statements). You write the query with a placeholder and pass the value separately. The database driver handles binding, and the value is always treated as data — never parsed as SQL syntax.
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
/**
* Returns the user matching the given email, or null if not found.
* The $1 placeholder keeps query structure and user data structurally
* separate — the database never interprets the email as SQL.
*/
async function getUserByEmail(email: string) {
const result = await pool.query(
"SELECT id, name FROM users WHERE email = $1",
[email]
);
return result.rows[0] ?? null;
}
No matter what email contains — quotes, semicolons, ; DROP TABLE users -- — the database treats it as a string to match, not syntax to execute.
ORMs like Prisma, TypeORM, and Sequelize use parameterized queries internally by default. The risk comes back when you reach for raw query methods. The difference is not always obvious:
// Safe — Prisma's tagged template literal parameterizes automatically
const user = await prisma.$queryRaw`
SELECT id, name FROM users WHERE id = ${userId}
`;
// NOT safe — string interpolation defeats parameterization entirely
const user = await prisma.$queryRawUnsafe(
`SELECT id, name FROM users WHERE id = ${userId}`
);
The $queryRawUnsafe name is a genuine warning, but under time pressure it’s easy to copy the wrong form. The safe rule: never use string interpolation or concatenation when building a query string.
For table names and column names — which can’t be parameterized, only values can — the safe approach is an explicit allow-list. Compare the user-supplied name against a hard-coded set of valid names and reject anything not on the list. Escaping a column name is fragile and database-specific; allow-lists are neither.
Check this before moving on
- Every database query in your codebase uses parameterized variables, not string concatenation
- Any raw query method is absent or used only with literal values and bound parameters
- Table or column names that come from user input are validated against a hard-coded allow-list
- Database credentials use the least-privilege account needed for the operation
Defense in depth: combining the layers
None of these three defenses is complete on its own, and they don’t protect each other. An XSS vulnerability can steal a CSRF token from the DOM. A CSRF attack doesn’t care whether SQL injection is present. A parameterized query doesn’t help if the session is already hijacked.
| Defense | Primary attack stopped | What it doesn’t cover |
|---|---|---|
| Output encoding | Script injection in rendered content | Wrong context, missed escape points |
| CSP header | Execution of injected scripts | Doesn’t fix the injection itself |
SameSite=Strict cookie |
Most cross-site POST requests | Same-domain subdomain requests |
| CSRF tokens | All cross-origin state changes | Client-side CSRF from same-origin JS |
| Parameterized queries | Value-based SQL injection | Column/table name injection |
| Allow-list validation | Column/table name injection | Everything else |
The practical pattern across all three: use the primary defense for the root cause, then add a second layer that limits damage when the first layer has a gap. Output encoding plus CSP. SameSite plus CSRF tokens. Parameterized queries plus least-privilege database permissions.
This is the same layering principle that applies to centralized error handling in Node.js — a single catch-all layer doesn’t replace validation, but it catches what validation misses.
Where these defenses can still fail
XSS: React’s dangerouslySetInnerHTML and Angular’s bypassSecurityTrust* functions opt out of the framework’s auto-encoding entirely. Content going into either needs DOMPurify or an equivalent sanitizer first. A CSP with 'unsafe-inline' in script-src renders the policy nearly useless, because inline scripts will execute regardless. Setting up CSP in report-only mode (Content-Security-Policy-Report-Only) before enforcing it is the practical way to catch legitimate breakages before they become outages.
CSRF: SameSite is scoped to the registrable domain, not the full origin. A request from a compromised sibling subdomain is still “same-site.” The CSRF token comparison must use constant-time equality — the crypto.timingSafeEqual call in the example above is not optional. A standard string comparison is vulnerable to timing attacks that let an attacker probe the token character by character.
SQL injection: ORMs aren’t injection-proof when you bypass their defaults. $queryRawUnsafe with string interpolation, Sequelize’s query() with manual concatenation, and Knex’s knex.raw() without bound parameters all reintroduce the vulnerability. Escaping user input as a fallback — the option OWASP explicitly discourages — is database-specific, brittle, and fails silently in edge cases.
Think in mechanisms, not labels
The phrase “sanitize your inputs” doesn’t map to any of these three defenses specifically. XSS requires context-aware encoding at the render point. CSRF requires server-side token verification or cookie attribute control. SQL injection requires structural separation of code and data before the query reaches the database engine.
Understanding why each fix works — not just what to call it — is what lets you catch gaps. When you see innerHTML being assigned, you know to ask what goes in. When you see a mutation route with no CSRF header, you know the token is missing. When you see a string being concatenated into a query, you know exactly what to change.
The underlying request lifecycle — the one I covered in how the web works — is what ties these together. XSS targets the rendering step. CSRF targets the cookie-authentication step. SQL injection targets the database query step. Each attack exploits a different layer, and each fix operates at that same layer. The rest is implementation details.