Skip to content

Application Security

Injection, XSS, CSRF, SSRF, broken object-level authorisation and mass assignment — what attackers do to your application and the structural fixes.

Intermediate22 minUpdated 27 July 2026

TLS protects data crossing the network. Authentication establishes who the caller is. Both can be flawless while your application hands an attacker the entire user table, because the attacker is not intercepting anything — they are a properly authenticated user sending a request you did not expect.

Application security is the discipline of assuming every byte arriving from a client is hostile, and building so that hostility cannot change what your code does. Almost every vulnerability below is one idea in different costumes: data supplied by a user escaped its lane and became instructions.

There is a hierarchy of fixes, and it matters more than any individual attack. Structural fixes make the class of bug impossible — a parameterised query cannot be injected regardless of the input. Filtering fixes try to spot bad input, and they lose eventually, because you are guessing at an infinite set. Prefer structure. Filter only as an extra layer.

Injection: separate code from data

Conceptual diagramSQLSQL identifierHTMLOutbound URLObject idUntrusted inputWhere does it end up?Bind as a parameterMap through an allow-listEncode on output, per contextAllow-list the destinationCheck this user owns it
Diagram, as text
  • Untrusted input leads to Where does it end up?
  • Where does it end up? leads to Bind as a parameter (SQL)
  • Where does it end up? leads to Map through an allow-list (SQL identifier)
  • Where does it end up? leads to Encode on output, per context (HTML)
  • Where does it end up? leads to Allow-list the destination (Outbound URL)
  • Where does it end up? leads to Check this user owns it (Object id)

An injection bug happens when user input is concatenated into something an interpreter parses — SQL, a shell command, an LDAP filter, a template.

// Every value of `email` is potentially a fragment of your query
db.query(`SELECT * FROM users WHERE email = '${email}'`);

Supply ' OR '1'='1 and the query returns every row. The interpreter has no way to know which characters you intended as data, because by the time it sees the string that distinction has already been destroyed. Parameterised queries restore it:

db.query('SELECT * FROM users WHERE email = ?', [email]);

The query text and the values travel separately. The database plans the statement first and binds the value afterwards, so the value can contain any characters at all and will never be parsed as syntax. This is not an escaping technique that works well; it is a structural change that makes the bug impossible. The same reasoning applies to the shell. Never build a command string from input; invoke the binary directly with an argument array, so there is no shell to interpret metacharacters. If you find yourself writing an escaping function for shell arguments, you have chosen the losing branch.

Cross-site scripting: encode on output

XSS is injection where the interpreter is the browser. Attacker-supplied text ends up in your page and executes as script in your users' sessions, with all their privileges.

  • Stored. The payload is saved — a comment, a display name, a support ticket — and served to everyone who views it. The most damaging variety, because it needs no interaction from the victim.
  • Reflected. The payload rides in a URL or form field and is echoed back in the response. Requires the victim to follow a crafted link.
  • DOM-based. No server involvement at all. Client code takes something from the URL fragment or postMessage and writes it into the page.

The instinct is to sanitise on input: strip <script> tags as data arrives. This fails twice over. The blocklist is unwinnable — event handlers, javascript: URLs, encoded variants, SVG, and whatever browser vendors ship next. More fundamentally, safety is a property of the destination, not the value: the same string is harmless in a JSON response, dangerous in HTML, dangerous differently inside a <script> block, and dangerous differently again in an attribute or a URL. So encode on output, contextually, at the moment of rendering. Modern templating and UI frameworks do this by default, which is why XSS has become rarer — and why the remaining cases cluster around the APIs that deliberately bypass it, the ones named something like "dangerously set inner HTML". Every use of those is a decision requiring justification, and if the content genuinely must be rich text, run it through a real sanitiser library rather than a regular expression you wrote.

CSRF, and what SameSite changed

Cross-site request forgery exploits the browser's helpfulness: cookies for your domain are attached to requests regardless of which site initiated them. An attacker's page can submit a form to your bank endpoint, and the victim's session cookie rides along.

The historical defence is the synchroniser token — a random value your server issues and requires back in the request body or a header. The attacker's page cannot read it, because the same-origin policy stops them reading your responses.

SameSite cookies changed the picture: a cookie marked SameSite=Lax is not sent on cross-site POST requests at all, which removes the class of attack at the browser level, and Lax is now the default in major browsers. That is a genuine structural improvement — but not a complete answer, and the nuances are what get tested:

  • Lax still permits top-level GET navigations. Any state-changing endpoint reachable by GET remains exploitable. State changes belong on POST, PUT, PATCH and DELETE — which was already the rule.
  • Subdomains are same-site. An XSS or a takeover on a neglected subdomain sits inside your protection.
  • Token-in-header APIs are not affected, because an Authorization header is not attached automatically by the browser. CSRF is a cookie-authentication problem specifically.

Keep synchroniser tokens for cookie-authenticated state changes, and treat SameSite as the strong second layer that it is.

SSRF: the one the cloud made critical

Server-side request forgery is the vulnerability people still miss, and it became severe when applications moved onto infrastructure that exposes credentials over HTTP. The shape is innocent: your application takes a URL from a user — import an image, register a webhook, generate a link preview — and requests it from the server. The attacker supplies a URL that is not on the internet. Internal admin panels with no authentication because "they are only reachable internally". Databases and caches bound to a private interface. And above all, the cloud instance metadata endpoint, a link-local address that answers unauthenticated HTTP requests from the instance itself and will happily return the machine's role credentials. One naive fetch of a user-supplied URL becomes credentials for your account.

Defences, in order of strength:

  • Do not fetch user-supplied URLs at all if the feature can be built another way.
  • Allow-list destinations. A webhook feature can require pre-registered, verified hosts.
  • Resolve the hostname yourself and reject private, loopback, link-local and reserved address ranges before connecting — then connect to the resolved address, because otherwise a hostname can resolve to a public address during your check and a private one during the fetch.
  • Disable redirect following, or re-validate the target on every hop. Redirects are the standard bypass for a naive check.
  • Egress network policy. The strongest control, because it does not depend on your code being correct: the service that fetches user URLs should have no route to internal ranges or the metadata endpoint.

Broken object-level authorisation

The most common serious vulnerability in real APIs, and one scanners almost never find.

GET /api/invoices/8213

You authenticated the caller. You checked they have the invoices:read permission. You never checked that invoice 8213 belongs to them. The attacker changes the number and reads someone else's invoice — no exploit, no payload, just arithmetic. When identifiers are sequential integers, the whole table can be enumerated in a loop.

The classic advice is to use unguessable identifiers. It is worth doing, but it is obscurity rather than a fix, and it fails the moment an identifier appears in a shared URL, a log or an export. The real fix is to make ownership part of every data access, structurally, so forgetting it is not possible:

// Wrong: authorise after fetching, or not at all
const invoice = await invoices.findById(id);

// Right: the caller's scope is part of the query
const invoice = await invoices.findOne({ id, organisationId: user.orgId });

Enforce this in one place — a repository that requires a tenant scope, a policy layer every handler must call, a middleware that resolves the resource and checks it before the handler runs. A convention that every developer must remember on every endpoint will be broken by the fortieth endpoint. Make the safe path the only path.

Mass assignment

A close relative. Your update handler binds the request body straight onto an entity:

Object.assign(user, req.body);   // convenient, and a privilege escalation
await user.save();

The client sends { "name": "Ada", "role": "admin" }, or accountBalance, or emailVerified, and every writable field on your model just became a public API. The fix is the DTO discipline from low-level design: define exactly which fields this endpoint accepts, validate them, copy only those. Allow-list, never blocklist — a blocklist forgets the sensitive field somebody adds next quarter, and that omission is silent.

Secrets and dependencies

Nothing sensitive belongs in a repository. Not API keys, not database passwords, not signing keys, not a .env committed "temporarily". Git remembers: deleting the file in a later commit leaves the value in history, and public repositories are scraped for keys within minutes of a push. If a secret is committed, the only remediation is to rotate it — cleaning history is housekeeping, not a fix. Secrets belong in a managed store or the platform's injected environment, scoped narrowly and rotated on a schedule, with automated scanning in the pipeline so the mistake is caught before it merges.

Your dependency tree is your attack surface. Most of the code you ship, you did not write and have not read. Three practices carry the weight: keep a lockfile so builds are reproducible, run vulnerability scanning on every build so a known issue in a transitive dependency surfaces immediately, and treat adding a dependency as a decision — the smallest, least-audited packages are exactly the ones taken over.

Security headers: defence in depth

Headers do not fix bugs. They reduce the impact of the bug you have not found yet, which is the correct thing to expect of them.

HeaderWhat it does
Content-Security-PolicyRestricts which sources may execute script — turns many XSS bugs into a blocked console error
Strict-Transport-SecurityForces HTTPS for future visits, defeating downgrade attempts
X-Content-Type-Options: nosniffStops the browser guessing a content type and executing an upload
Referrer-PolicyPrevents leaking full URLs, and any identifiers in them, to third parties
X-Frame-Options / frame-ancestorsBlocks clickjacking by refusing to be framed

CSP is the one worth real effort, and the one most often disabled by its own configuration: a policy containing unsafe-inline for scripts permits exactly what CSP exists to prevent. Deploy it in report-only mode first, fix what it flags, then enforce.

Check your understanding

Common mistakes

Validating only in the client. The browser is a convenience, not a control. Anyone can send the request directly.

Sanitising input instead of encoding output. Safety depends on the destination context, which input-time filtering cannot know.

Authenticating without authorising the object. Knowing who the caller is says nothing about whether this row is theirs.

Binding request bodies straight onto entities. Every writable field becomes public, and blocklists forget the field added next quarter. Use allow-listed DTOs.

Trusting internal networks. SSRF, a compromised dependency and a rogue subdomain all originate inside the perimeter.

Leaking detail in errors. Stack traces and database messages hand attackers your schema. Log the detail, return a generic message and a reference id.

Practice

Your team is asked to 'add security' to an existing product before launch. Give the order you would work in, and justify the first three items.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Rank by what an attacker would actually reach for, not by what is easiest to demonstrate.

Show a strong answer

Work in order of realistic exploitation, not of how impressive the finding sounds.

  1. Broken object-level authorisation. Go through every endpoint that takes an identifier and verify the current user may access that object. This is consistently the most common serious flaw in real applications, it requires no skill to exploit — change a number in a URL — and it directly exposes other customers' data. It is also the one automated scanners find least reliably, because they cannot know your ownership rules.
  2. Injection at every boundary. Confirm parameterised queries everywhere, and check the places people forget: dynamic ORDER BY clauses, LIKE patterns, raw fragments in reporting queries, and anything constructing shell commands or LDAP filters. One missed concatenation is total database compromise, so the payoff per hour spent is enormous.
  3. Authentication and session handling. Password storage using a purpose-built slow hash; session invalidation on logout and password change; secure cookie attributes; rate limiting on login and password reset. These are the doors, and weaknesses here make everything else moot.

Then, in descending order: SSRF on any URL-fetching feature, mass assignment on every write endpoint, security headers, dependency scanning, and secrets audit.

What I would resist: starting with a penetration test. A test before the obvious classes are addressed spends the budget rediscovering them and produces a report the team already knew the shape of. Fix the known classes first, then test to find what you did not anticipate.

One process change worth more than any single fix: make "who is allowed to do this, to this object?" a required question in code review. Most of these bugs are introduced by developers who never considered the question, not by developers who got it wrong.

A search endpoint builds SQL as `ORDER BY \` + userColumn. Parameterised queries do not solve this. Explain why, and give the correct fix.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Ask what a database driver is willing to treat as a parameter.

Show a strong answer

Why parameters do not help. A bound parameter is a value — the driver sends it separately from the statement, and the database treats it as data that can never become syntax. But ORDER BY takes an identifier, not a value. ORDER BY ? is either a syntax error or, worse, sorts every row by a constant, which silently returns unordered results. The same applies to table names, column names in SELECT, and ASC/DESC.

So the one defence developers reliably reach for is structurally unavailable here, which is exactly why this pattern survives in codebases that are otherwise careful.

The correct fix: an allow-list mapping, not sanitisation.

Keep a fixed map from the values a client may send to the actual column names, and reject anything not in it:

  • The client sends sort=newest; the server looks up newest → created_at DESC.
  • Anything unrecognised produces a 400 with the permitted values listed, not a default that silently ignores the request.

Why an allow-list rather than escaping or a pattern check: identifier quoting rules differ between databases and are easy to get subtly wrong, and a regex like "letters and underscores only" still permits any column in the table — including ones the user should not be able to order by, which leaks information about values they cannot see. The mapping also decouples the API from the schema, so renaming a column does not break clients.

The generalisation worth carrying: parameters protect values. Anything that is part of the query's structure must come from a fixed set you control, never from input, however well-scrubbed.

Key takeaways

  • The recurring theme is data escaping its lane and becoming instructions — and the recurring fix is structural, not filtering.
  • Parameterised queries and argument-array process invocation make injection impossible rather than unlikely.
  • Defeat XSS by encoding on output for the specific context; input sanitisation cannot know where the value will land.
  • SameSite cookies remove most CSRF, but state changes must never be GETs, and subdomains count as same-site.
  • SSRF is the cloud-era miss: resolve and reject private, loopback and link-local addresses, refuse redirects, and block egress at the network.
  • Broken object-level authorisation is the most common real API vulnerability. Put the caller's scope inside the query and test as two tenants.
  • Use allow-listed DTOs to stop mass assignment, keep secrets out of repositories, and rotate anything committed.
  • Treat security headers and CSP as impact reduction for the bug you have not found yet — never as the fix.

Further reading

  • OWASP, Top 10 Web Application Security Risks (2021) — broken access control, injection and SSRF.
  • OWASP, Cheat Sheet Series — query parameterisation, output encoding and SSRF prevention.
  • Barth, Jackson and Mitchell, "Robust Defenses for Cross-Site Request Forgery" (CCS 2008).
  • Orange Tsai, "A New Era of SSRF" (Black Hat USA, 2017) — cloud metadata as the escalation path.
  • Michal Zalewski, The Tangled Web (No Starch Press, 2011) — browser security model and output contexts.

Check yourself

3 questions in this lesson. Answer each once to complete it.

No problem practices this lesson yet. Browse the practice problems

Next lessonAdvanced Security (OAuth & Zero Trust)
Lessons in this track