Express course · No. 22
Almost every web vulnerability traces to one mistake: trusting data that came from outside. The attacker controls the request, the form, the URL, the uploaded file — all of it. Web security is the discipline of treating every scrap of external input as hostile until proven safe, and learning the handful of classic attacks that punish anyone who forgets.
Essence only · One picture per idea · Learn the words
Before any specific attack, there's a single principle that prevents most of them. Internalise this one idea and the rest of the course is just it applied in different places.
All external input is attacker-controlled
A border checkpoint treats every arriving package as potentially dangerous until inspected — it doesn't wave things through just because they look ordinary.
Anything that enters your system from outside — form fields, URLs, headers, uploaded files, API calls — is controlled by whoever sent it, and that might be an attacker. They can send anything, not just what your form intended. So the foundational rule is: treat all external input as hostile until you've validated it. Almost every vulnerability in this course is, at heart, a place where someone trusted input they shouldn't have.
The trust boundary is where checks live
The wall around a building, with one guarded gate — inside is trusted, outside is not, and everything crossing in gets checked at that line.
A trust boundary is the line between the outside world and your system. Data crossing inward must be checked at that boundary, because once it's inside, your code tends to treat it as safe. The classic mistake is validating on the client (the browser) and assuming that's enough — but the attacker bypasses the browser entirely. Real security checks happen on the server, at the boundary you actually control.
Validate input, escape output
A bouncer checks IDs at the door (who comes in), and a translator makes sure your words can't be misread as a command in the next room (how things go out). Two different jobs.
Two habits stop most attacks. Validate input: reject data that isn't the shape you expect — wrong type, too long, malformed. Escape output: when you put data into another context (a database query, an HTML page, a shell command), neutralise anything that could be read as code there. Almost every attack below is untrusted input slipping into a place where it gets interpreted as instructions instead of data.
Every scrap of external input is attacker-controlled. Validate it at the boundary on the server, and escape it everywhere it's used. That one rule prevents most attacks.
The most damaging class of attack is also the simplest to understand: untrusted input gets treated as code. The textbook case is SQL injection, and it shows the whole pattern.
Injection: input becomes code
A form asking for your name, where someone writes an instruction instead — and the clerk, reading the whole line aloud, accidentally carries out the instruction.
Injection happens when untrusted input is mixed into a command or query and gets executed as part of it. The system meant to use your input as data, but the input was crafted to be read as code. It's the same root flaw across SQL, shell commands, and more: the boundary between "the instruction" and "the user's data" was never enforced, so the attacker supplied instructions.
SQL injection, the classic case
A library request slip where, in the title field, someone writes "...and also unlock every door" — and the automated system dutifully does both.
In SQL injection, an attacker types database syntax into a normal field. If your code builds a query by gluing the input into a string, their input becomes part of the query — letting them read other users' data, bypass a login, or delete tables. It has been one of the most common and serious web vulnerabilities for decades, and it comes entirely from building queries by string-concatenation with untrusted input.
Parameterized queries are the fix
A form with locked, labelled boxes: whatever you write in the "name" box is treated only as a name, never as part of the form's instructions, no matter what you put there.
The cure is a parameterized query (prepared statement): you write the query with placeholders and pass the user input separately, so the database always treats it as pure data, never as SQL. The input could be the nastiest string imaginable and it still can't change the query's structure. Never build a query by concatenating input into a string — use parameters, every time, and SQL injection simply cannot happen.
Injection is untrusted input executed as code. SQL injection is the classic case, and parameterized queries — input passed as data, never concatenated — are the cure.
If injection is input becoming code on the server, cross-site scripting is input becoming code in the browser. It turns your own page into a weapon against your users.
XSS: input becomes script in the browser
A community noticeboard where someone pins a note rigged so that anyone who reads it has their pockets quietly picked — the danger is delivered by the trusted board itself.
Cross-site scripting (XSS) is when an attacker gets their JavaScript to run in another user's browser, on your site. If you take user input — a comment, a name, a profile — and put it straight into a page, an attacker can submit a <script> instead of text, and it runs for everyone who views that page. Their code now acts with your user's session: stealing cookies, impersonating them, defacing the page.
It runs with your user's trust
An impostor wearing a trusted uniform: people comply because the uniform — your site's domain — is what they trust, not the person inside it.
XSS is dangerous because the malicious script runs as your site, with all the trust the user gives your domain. It can read what the user sees, act as them, and send their data to the attacker. The browser has no way to know the script wasn't yours — it came from your page. This is why "it's just a comment field" is famous last words: any spot where user input reaches the page is a potential XSS hole.
Escape output and use a content policy
A translator who renders every guest's words as plain text on the screen — so even if someone shouts a command, it appears as harmless quoted words, not an order anyone acts on.
The core fix is to escape output: when you put user data into HTML, convert characters like < and > into harmless display text so they render as content, never execute as markup. Modern frameworks do this by default, which is why you should let them. Layer on a Content Security Policy — a browser rule that restricts what scripts may run — as a second line of defense. Treat anything that reaches the page as untrusted, and neutralise it on the way out.
XSS is untrusted input running as script in your users' browsers, with your site's trust. Escape everything that reaches the page, and add a Content Security Policy.
The next attack doesn't inject anything — it abuses the browser's own habit of attaching your credentials to every request, tricking it into acting on your behalf without your intent.
CSRF: your browser is tricked into acting
Someone forges your signature on a form and mails it from your address — the bank sees a valid, signed request from you and processes it, never knowing you didn't write it.
Cross-site request forgery (CSRF) exploits the fact that browsers automatically attach your cookies — including your logged-in session — to any request to a site. An attacker puts a hidden request to your bank on their own malicious page; when you visit it while logged into the bank, your browser sends the request with your session attached, and the bank thinks you meant it. You're made to act without ever choosing to.
The confused deputy problem
A trusted assistant with the keys, tricked by a forged note into opening the vault — the assistant had the authority and was fooled into using it for someone else.
CSRF is a "confused deputy" attack: your browser holds real authority (your session) and is tricked into exercising it for the attacker. The server can't tell the difference, because the request looks exactly like a genuine one — same cookies, same user. The flaw isn't stolen credentials; it's that a valid request was triggered by someone other than you, and nothing proved your intent.
Prove intent with tokens and SameSite
A bank that requires a one-time code, printed only on your own statement, with every transfer — a forged request from elsewhere can't include it, so it's rejected.
The fix is to require proof that your page made the request. A CSRF token is a secret value your site embeds in its own forms and checks on submit; an attacker's page can't know it, so forged requests fail. The modern complement is the SameSite cookie attribute, which tells the browser not to send your session cookie on requests coming from other sites. Together they ensure a sensitive action came from your site, with your intent.
CSRF tricks your browser into firing a request with your session attached. Prove intent with a CSRF token and SameSite cookies, so forged cross-site requests are rejected.
Some data is so sensitive that how you store it is itself a security decision. Passwords are the classic example, and they reveal the difference between two words people constantly confuse.
Never store passwords as plain text
Keeping a list of everyone's house keys in an unlocked drawer at reception — one break-in, and every home is open. The convenience is the catastrophe.
If you store passwords as readable text, then anyone who gets your database — through a breach, a leak, an insider — instantly has every user's password. And because people reuse passwords, you've compromised their other accounts too. Storing a password as plain text is among the most serious and basic mistakes in web security. The data is too dangerous to keep in a form you can read.
Hashing is one-way; encryption is two-way
A paper shredder versus a locked box: the box can be unlocked back to the original, but the shredder turns paper into confetti you can never reassemble — yet the same paper always shreds the same way.
This is the distinction to nail. Encryption is reversible: with the key, you turn the scrambled data back into the original (use it for data you must read again, like a stored API key). Hashing is one-way: it turns input into a fixed fingerprint that cannot be reversed back to the input, but the same input always produces the same fingerprint. They solve different problems, and confusing them is a classic error.
Hash passwords, with a salt
Checking a wax seal: you don't need the original letter to verify it — you just re-stamp and compare imprints. You confirm a match without ever storing the secret itself.
You hash passwords because you never actually need the password back — you only need to check it. Store the hash; at login, hash what they typed and compare. A breach leaks fingerprints, not passwords. Add a salt — a unique random value per password — so identical passwords get different hashes and precomputed attack tables fail. Use a slow, purpose-built password hash (like bcrypt or Argon2), never a fast general one, so guessing is expensive.
Never store passwords as plain text. Encryption is reversible for data you must read back; hashing is one-way for data you only verify — hash passwords, salted, with a slow algorithm.
Beyond specific attacks are two principles that limit the damage when something gets through — because in security you assume something eventually will.
Give every part the least privilege it needs
A hotel key card that opens only your room and the gym — not every door in the building. If it's lost, the damage is contained to what it could reach.
Least privilege means every user, service, and credential gets only the minimum access required for its job — nothing more. The database account your web app uses shouldn't be able to drop tables if it only reads and writes rows. Then when something is compromised — and assume it will be — the blast radius is small, bounded by what that one piece was allowed to do. Over-broad permissions turn a small breach into a total one.
Defense in depth: layers, not a single wall
A castle with a moat, a wall, a gate, and guards — no single barrier is trusted to be perfect, so each one catches what the last let slip.
Defense in depth means layering independent protections so that one failure isn't fatal. Validate input and use parameterized queries and run with least privilege and escape output. Any single control can fail or be bypassed; together they make a full compromise require beating all of them at once. Security isn't one perfect wall — it's overlapping ordinary controls, each assuming the others might fail.
Don't leak clues in errors and responses
A locked door that, when rattled, helpfully announces "wrong key — the real one is brass and slightly bent" — telling the intruder exactly how to get in.
Verbose errors and over-sharing responses hand attackers a map. A login that says "wrong password" (vs "no such user") confirms which accounts exist; a stack trace exposes your framework, versions, and query structure. Show users a generic message, log the detail privately, and never return more data than the client needs. Quiet failure isn't just tidy — it denies an attacker the reconnaissance that makes the next step easy.
Assume something will get through. Least privilege bounds the damage, defense in depth means no single failure is fatal, and quiet errors deny attackers the map.
Security is a practice woven through how you build, not a feature bolted on at the end. The habits are few, and most of them are the one rule applied with discipline.
Lean on the framework and keep it patched
You don't forge your own locks — you fit proven ones and replace them when a flaw is announced. Rolling your own is how you get a worse lock.
Most classic attacks are already solved by mature frameworks and libraries: they escape output, parameterize queries, and handle CSRF tokens for you — if you use them as intended instead of working around them. And since vulnerabilities are found in dependencies constantly, keep them updated; an unpatched library is one of the most common ways in. Don't invent your own crypto or your own escaping — use the vetted tools and keep them current.
Know the OWASP Top 10 as your map
A pre-flight checklist exists because the same few failures cause most crashes — you check them every time rather than rediscovering them in the air.
You don't have to imagine every threat. The OWASP Top 10 is the industry's list of the most common, serious web vulnerabilities — injection, broken access control, and the rest — and it doubles as a checklist. Walk your application against it and you'll catch the categories that cause the overwhelming majority of real breaches. Using a known map beats hoping you thought of everything yourself.
- Where does external input enter, and is it validated at the boundary on the server? - Queries — parameterized, never built by concatenating input? - Output to the page — escaped, so user input can't run as script? - State-changing actions — protected against CSRF with tokens and SameSite? - Sensitive data — passwords hashed with a salt, secrets never in plain text? - Least privilege — does each part have only the access it needs?
- trust boundary / validate input / escape output — the one rule and its two habits. - injection / SQL injection / parameterized query — input run as code, and the cure. - XSS / Content Security Policy — input run as script in the browser, and its defenses. - CSRF / CSRF token / SameSite — a forged request with your session, and how to prove intent. - hashing / encryption / salt — one-way verify versus two-way read, and per-password randomness. - least privilege / defense in depth — bound the damage, and layer the protections. - OWASP Top 10 — the industry map of the most common vulnerabilities.
- You treat all external input as hostile, checked on the server at the boundary. - Queries are parameterized and page output is escaped — by default, via the framework. - State-changing requests carry a CSRF token, and cookies are SameSite. - Passwords are hashed and salted; nothing sensitive is stored in plain text. - Every part runs at least privilege, dependencies stay patched, and you check against the OWASP Top 10.
Web security is mostly one rule applied with discipline: never trust input. Validate at the boundary, escape on the way out, store secrets safely, and layer defenses for when something slips.