The 3 Basic Web Attacks: How SQL Injection, XSS, and CSRF Work and How to Stop Them
One line, ' OR '1'='1, in a login form logs you in as admin, and a single comment can run scripts in other users' browsers. How SQL Injection, XSS, and CSRF, the attacks web services suffer most, actually work, tried hands-on, and the right way to defend against each.
What happens the moment you trust input
Most web security incidents start with one mistake: using a value the user sent as part of code or a command, as-is.
| Attack | Where user input becomes "code" | Damage |
|---|---|---|
| SQL Injection | Inside the server's SQL query | Login bypass, data theft or deletion |
| XSS | Inside another user's browser HTML | Session theft, acting as the user |
| CSRF | (Not input) abuses cookies the browser sends automatically | Transfers or password changes without the user knowing |
These three are classic attacks that keep appearing on web security risk lists like the OWASP Top 10. Let's attack and defend each one.
1. SQL Injection: when input becomes the query
Try the attack
Say login is implemented like this: the email and password the user typed are concatenated into the SQL string.
function login(email, password) {
const sql = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
return db.prepare(sql).get();
}
Normal input works fine. But type admin@site.com' -- into the email field and the SQL becomes:
SELECT * FROM users WHERE email = 'admin@site.com' --' AND password = 'anything'
-- starts a SQL comment. The whole password check is commented out, and you're logged in as admin without a password. If you don't even know the email, ' OR '1'='1' -- makes the condition always true and logs you in as the first user (usually the admin).
Running it on SQLite, both attacks return the admin account:
attack 1: { id: 1, email: 'admin@site.com', role: 'admin' }
attack 2: { id: 1, email: 'admin@site.com', role: 'admin' }
The same mistake in a search feature is even worse: UNION lets you pull data from other tables.
search: zzz' UNION SELECT email || ':' || password FROM users --
result: admin@site.com:s3cret!, kim@site.com:hunter2 ← every account's password
Defense: parameter binding
There's one way to prevent SQL Injection: never concatenate input into the SQL string.
Put a ? (placeholder) where the value goes and pass the value separately. The database parses the SQL structure first and always treats the value as just a value. A ' or -- is just a character.
function login(email, password) {
return db
.prepare("SELECT * FROM users WHERE email = ? AND password = ?")
.get(email, password);
}
attack 1: undefined ← login fails
attack 2: undefined ← login fails
normal login: { id: 2, email: 'kim@site.com', role: 'user' }
| Tool | The safe way |
|---|---|
| Node.js DB drivers | Placeholders like query("... WHERE id = ?", [id]) or $1 |
| ORMs (Prisma, Sequelize, etc.) | The standard API is safe. Watch out when concatenating strings into raw queries |
| Places placeholders can't go (table names, sort columns) | Only use values from an allowlist (["name", "date"]) |
Storing passwords in plain text, as in the example above, is a serious problem on its own. Even without SQL Injection, the moment the DB leaks, every password is exposed. Store passwords with a dedicated hash function like bcrypt or argon2.
2. XSS: when input runs in someone else's browser
XSS (Cross-Site Scripting) is when content an attacker submitted gets interpreted as HTML on other users' pages and runs scripts. Anywhere you display user input back on screen, like comments, nicknames, and search terms, is a target.
Try it in the preview below. It outputs the same comment with innerHTML and with textContent.
Press "Output with innerHTML" and the <img> tag inside the comment is parsed as a real image tag. When the image fails to load, the code in its onerror attribute runs in the browser of whoever views the comment. In a real attack, this is where it would send cookies to the attacker's server, or post content and change passwords on the user's behalf.
"Output with textContent" shows the same input as literal text. <img ...> just appears as characters on screen, and nothing runs.
Put <script>alert(1)</script> into innerHTML and it won't run. That makes it easy to think you're "protected," but event attributes like onerror and onload still run. That's why filtering out specific tags can't stop XSS.
Kinds of XSS
| Kind | Where the malicious input comes from | Example |
|---|---|---|
| Stored | Saved in the DB and shown to many people | Posts, comments, profiles |
| Reflected | A URL parameter is echoed into the response | Luring someone to click a ?q=<img onerror=...> link |
| DOM-based | Frontend code inserts it directly, without the server | Putting location.hash into innerHTML |
Defense
- Escape on output. Turn <, >, ", ', and & into HTML entities so they render as text. Insert text with textContent.
- Use your framework's default output. Vue's {{ }} and React's { } escape automatically. The dangerous parts are the escape hatches: Vue's v-html and React's dangerouslySetInnerHTML.
- If you must allow HTML, sanitize it. For something like a rich-text editor, keep only allowed tags with a proven library such as DOMPurify.
- Add damage limiters. An HttpOnly session cookie can't be read by scripts. A CSP (Content-Security-Policy) header can block scripts that aren't allowed.
// ❌ User input as HTML
el.innerHTML = comment;
// ✅ As text
el.textContent = comment;
3. CSRF: abusing cookies the browser sends automatically
CSRF (Cross-Site Request Forgery) is different from the first two. It doesn't inject code. Instead it exploits the fact that the browser automatically attaches cookies to every request.
Say a user is logged into a bank site (bank.com) and visits a page the attacker made. That page hides a form like this:
<!-- A page on evil.com -->
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="1000000" />
</form>
<script>document.forms[0].submit();</script>
The moment the page opens, a transfer request goes to bank.com. The browser attaches bank.com's login cookie, and the bank server can't tell it apart from a legitimate request the logged-in user sent themselves.
As the CORS post showed, simple requests like form submissions aren't blocked by CORS and do reach the server. CORS only stops the response from being read, so it can't help when the request arriving is itself the damage, like a money transfer.
Defense
| Method | How it works |
|---|---|
| SameSite=Lax or Strict on cookies | Cookies aren't attached to requests started from other sites. Stops most CSRF |
| CSRF token | The server embeds an unpredictable token in each form and checks for it on submit. The attacker's page can't know it |
| Check the Origin header | Verify that state-changing requests come from your own site |
| Never change state with GET | A URL like GET /delete?id=3 can be triggered by a single <img src> |
Modern browsers may treat cookies without a SameSite attribute like Lax, but behavior isn't identical across browsers. Setting SameSite explicitly on auth cookies is the safe choice. Cookie attributes are covered in more detail in the JWT vs sessions post.
Summary: why this is worth knowing
| Attack | Root cause | Core defense |
|---|---|---|
| SQL Injection | Concatenating input into SQL strings | Parameter binding (? placeholders) |
| XSS | Parsing input as HTML on output | Escape on output (textContent, framework default output), careful with v-html etc., CSP |
| CSRF | Browsers send cookies automatically | SameSite cookies, CSRF tokens, Origin checks |
All three happen when the line between "data" and "code" breaks down. In SQL, a value becomes query structure; in HTML, text becomes a tag; in CSRF, the attacker's request becomes the user's request. The defense follows the same principle: treat user input as data all the way through, and confirm that a request really is what the user intended.