How apps actually get broken
A working threat model for web apps: who attacks them, what they're after, and the shape every lesson in this security series follows.

Nobody breaks into your app by guessing your admin password in a dark room at 2 a.m. They send a request your server was already willing to answer, just shaped in a way you didn't plan for. That's almost every real breach: not a genius move, a gap between what you assumed and what you actually checked.
This series spends the next eleven lessons closing those gaps in a real app. We'll use Linkstash, a small link-saving API with users, sessions, and a SQLite database, the same one built across the Backend series. If you haven't read why you'd build a backend at all, that's worth a look first, but you don't need it to follow along here. Every lesson in this series does the same thing: break something in Linkstash, watch what it costs, then fix it with the smallest change that actually works.
Where the attacks actually come from
Draw a box around your app and ask what's allowed to touch it. Usually the answer is: the internet. Anyone with a browser or a script can send it a request. That's the whole attack surface, and it's bigger than most teams treat it.
Three different starting points, same target. An anonymous attacker can only prod what's public: your login form, your signup endpoint, anything that doesn't need a token. A logged-in attacker, meaning anyone who made a free account, gets to test what the app lets authenticated users do to each other. And the scariest one, someone holding a stolen session, walks straight past your login screen because as far as your server can tell, they already won.
Most tutorials teach you to build the box. This series teaches you to think like someone poking at its edges.
The shape of a real vulnerability
Security bugs get a bad reputation for being exotic. They're usually not. Here's a real one, lifted straight from Linkstash's code:
export async function verifyUser(db: Database, email: string, password: string) {
const row = db.prepare("SELECT id, email, passwordHash FROM users WHERE email = ?")
.get(email);
if (!row) {
return null; // looks fine
}
const ok = await argon2.verify(row.passwordHash, password);
return ok ? { id: row.id, email: row.email } : null;
}Read it as a feature and it's correct. Look up the user, check the password, return null on failure. It compiles, it passes a happy-path test, and it ships. The bug is what it doesn't do: a login for an email that exists costs a real password hash comparison, tens of milliseconds. A login for an email that doesn't exist returns instantly, because there's no hash to check. Time the responses and you've built an account-enumeration oracle without touching the database once. Lessons 5 and 6 fix this exact function and show you the fix that actually ships in Linkstash.
That's the pattern for this whole series. Not a contrived puzzle, a decision that looked reasonable in isolation and only breaks once you ask "what can someone learn from how this responds, not just what it returns?"
What a vulnerability actually costs
"Security" sounds abstract until you attach a number to it. A SQL injection in a login form isn't a checkbox you forgot, it's every row in your users table, including password hashes, in one request. An IDOR (lesson 7's topic) on a /orders/:id endpoint isn't a technicality, it's a stranger reading every customer's shipping address by counting up from 1. Rate limiting isn't a nice-to-have (lesson 11), it's the difference between your signup form and a free tool for spinning up ten thousand fake accounts, or running your OpenAI bill up on someone else's behalf.
Treat each vulnerability as a cost, not a category, and prioritizing them gets easy. A bug that leaks one user's display name is not the same emergency as one that leaks every password hash.
OWASP, briefly
The Open Worldwide Application Security Project publishes a Top 10 list of the most common, highest-impact web vulnerabilities, updated every few years from real breach data. This series doesn't walk the list in order, but by the end you'll have hit injection, broken authentication, broken access control, security misconfiguration, and vulnerable dependencies, five of the ten, all against one real app.
The shape every lesson follows
Twelve lessons, one pattern each time:
- How the attack works. A concrete request, not a hand-wave.
- What it costs. What an attacker actually walks away with.
- The specific fix. The real code that closes it, quoted from Linkstash.
Lesson 2 is the one to not skip: a real SQL injection, run live against a real SQLite database in your browser, no server required. You'll type ' OR 1=1 -- into a query yourself and watch every row in a table come back.
Quick check
Why does timing (not just the returned status code) matter for a login endpoint?
What to do with this
You don't need a security background to follow this series, you need to be willing to look at code you'd normally trust and ask what it does when the input isn't what you expected. That's the entire skill. Everything else is specifics.
Next up: SQL injection, broken live in your browser, then fixed.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


