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.

AttackWhere user input becomes "code"Damage
SQL InjectionInside the server's SQL queryLogin bypass, data theft or deletion
XSSInside another user's browser HTMLSession theft, acting as the user
CSRF(Not input) abuses cookies the browser sends automaticallyTransfers 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' }
ToolThe safe way
Node.js DB driversPlaceholders 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

KindWhere the malicious input comes fromExample
StoredSaved in the DB and shown to many peoplePosts, comments, profiles
ReflectedA URL parameter is echoed into the responseLuring someone to click a ?q=<img onerror=...> link
DOM-basedFrontend code inserts it directly, without the serverPutting location.hash into innerHTML

Defense

  1. Escape on output. Turn <, >, ", ', and & into HTML entities so they render as text. Insert text with textContent.
  2. 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.
  3. 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.
  4. 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

MethodHow it works
SameSite=Lax or Strict on cookiesCookies aren't attached to requests started from other sites. Stops most CSRF
CSRF tokenThe 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 headerVerify that state-changing requests come from your own site
Never change state with GETA 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

AttackRoot causeCore defense
SQL InjectionConcatenating input into SQL stringsParameter binding (? placeholders)
XSSParsing input as HTML on outputEscape on output (textContent, framework default output), careful with v-html etc., CSP
CSRFBrowsers send cookies automaticallySameSite 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.