Security Engineering · Chapter 2 of 12
The vulnerability classes that still matter
Not the top-ten list. The five classes that actually cause breaches, what each looks like in code, and the fix that addresses the class rather than the instance.
Vulnerability lists are long and the distribution of real-world harm is not uniform. A small number of classes account for most serious incidents, and each has a structural fix that eliminates the class rather than patching an instance. Those are what this chapter covers.
1. Broken access control — the most common and least glamorous
The application checks that you are logged in and then does not check whether this user may access that object. You change the ID in the URL and you see somebody else's invoice.
It is consistently at or near the top of real-world findings and it is not exciting, which is part of why it persists. The reason it is hard to eliminate is that the check must happen at every single access path, and one new endpoint written on a Friday reintroduces it.
The structural fix: make it impossible to write the query without the check
Do not rely on remembering to call an authorisation function. Instead, make the data access layer itself require the tenant or user scope — a repository whose every method takes the acting principal, or row-level security enforced in the database, so a query without the predicate cannot return rows at all.
Then test the guard, not just the behaviour. Write a test that asserts a cross-tenant read is refused, and then delete the guard and confirm the test goes red. Tests for isolation pass vacuously with alarming frequency — because a randomly generated identifier would not have matched anything anyway — and mutation-testing the guard is the only reliable way to know your test is doing anything.
Being able to describe that verification step is one of the strongest security-engineering signals available in an interview, because almost nobody does it.
2. Injection, which is one bug with many names
SQL injection, command injection, LDAP injection, XPath injection, template injection, and prompt injection are all the same mistake: data was concatenated into a string that is then interpreted as instructions.
The fix, everywhere it is available, is the same: separate the code from the data. Parameterised queries send the SQL and the values on different channels, so no value can ever become syntax. Pass arguments as an array to a subprocess rather than through a shell. Use a template engine that escapes by default and treat any ‘render this string as a template’ feature as dangerous, because server-side template injection is usually immediate remote code execution.
Two notes worth carrying. Escaping is the fallback, not the answer — it is context-dependent and easy to get subtly wrong, whereas parameterisation is structurally safe. And ORMs do not make you safe: every ORM has a raw-query escape hatch, and that is where the injection will be.
Prompt injection is the interesting modern member of the family precisely because it has no equivalent fix. There is no parameterised channel for a language model — instructions and data arrive as the same token stream. That is why it is mitigated architecturally, by constraining what the model is permitted to do, rather than solved at the input boundary.
3. SSRF, and why it is a cloud-native catastrophe
Server-side request forgery is making the server fetch a URL of the attacker's choosing. On its own that sounds mild. In a cloud environment it is frequently a full account compromise.
Any feature that fetches a URL is a candidate: webhooks, avatar fetching by URL, PDF rendering from HTML, link previews, importing from a remote file, an XML parser resolving external entities. The last one, XXE, is SSRF arriving through a parser that nobody thought of as a network client.
- Enforce IMDSv2 (or the equivalent) so the metadata service requires a PUT to obtain a token. Most SSRF primitives can only issue a GET, so the chain breaks at step one.
- Resolve the hostname yourself and validate the resulting IP against a deny list of private, link-local and loopback ranges — then connect to that exact IP. Validating the string and then letting the HTTP library resolve it again is a time-of-check-to-time-of-use gap that DNS rebinding walks straight through.
- Disable redirects, or re-validate after every one. A 302 to the metadata address after your check passed is the standard bypass.
- Egress through an allowlisting proxy. This is the control that actually holds, because it does not depend on getting the parsing right.
- And minimise the role. If the instance role cannot read your S3 buckets, stolen credentials are worth much less. Defence in depth here is not a slogan — it is the difference between an incident and a breach.
4. Deserialisation and supply chain
Deserialising untrusted data into objects can execute code, because the deserialiser constructs arbitrary types and invokes their lifecycle methods. Java's native serialisation, Python's pickle, PHP's unserialize and .NET's BinaryFormatter have all produced remote code execution repeatedly. The fix is not a better allowlist; it is to use a data-only format — JSON parsed into a declared schema — so there is no mechanism to instantiate a type of the attacker's choosing.
The supply chain version is the same problem moved earlier: your build pulls hundreds of packages, each of which runs code at install time and ships code into your artefact. The controls are lockfiles with integrity hashes, pinned versions, a vetted internal registry, reviewing what a dependency actually does before adding it, and generating an SBOM so that when the next widely-used library has a critical vulnerability you can answer ‘are we affected?’ in minutes rather than in days.
Dependency confusion deserves a specific mention because the fix is trivial and the impact is severe: if your internal package name is not reserved on the public registry, an attacker publishes it there with a higher version number and your build may prefer it. Reserve your internal namespaces publicly, and configure your package manager to never fall back to the public registry for internal scopes.
5. Authentication and session handling
- Session fixation — always issue a new session identifier on authentication. Otherwise an attacker who set the identifier beforehand is now logged in as you.
- Cookie flags —
HttpOnlyso script cannot read it,Secureso it never crosses plaintext, andSameSiteas the primary CSRF defence.SameSite=Laxis the sensible default and it does not protect cross-site POSTs from a top-level navigation, so keep tokens for genuinely sensitive actions. - Enumeration — ‘no such user’ versus ‘wrong password’ tells an attacker which accounts exist. So does a measurably different response time, which is the version people forget.
- Rate limiting must be on the account as well as the IP. Credential stuffing comes from thousands of addresses trying one password each — per-IP limits do nothing against it.
- Password reset is the real authentication system, and it is routinely weaker than the login it protects. Single-use tokens, short expiry, invalidate all sessions on reset, and never reveal whether the address exists.
How to talk about this in an interview
The pattern that lands: name the class, describe the structural fix rather than the patch, and say how you would verify it. “I would move the tenant filter into the repository so a query without it cannot compile, add a cross-tenant test, and then delete the filter to confirm the test actually fails” is a complete, credible answer, and it is a different conversation from listing the OWASP top ten.
Check yourself