bigonotes
System DesignSecurity

Web App Attacks: Injection, XSS and CSRF

How SQL injection, cross-site scripting and cross-site request forgery work, plus session and error-message flaws, and their defenses.

8 min readLast updated

Most web attacks come from one mistake: the app treats untrusted input as trusted. This note covers the attacks you are most likely to meet on the job and in interviews: injection, cross-site scripting (XSS), cross-site request forgery (CSRF), weak session tokens and verbose errors. For each one you learn how it works and the standard defense. It builds on the "all input is untrusted" rule from security fundamentals.

Code injection#

An interpreter runs code at runtime: a SQL engine, a shell, a template engine, Python's eval. Your app often builds code for an interpreter and puts user data inside it. If the data can change the structure of that code, you have code injection. The interpreter cannot tell your instructions from the attacker's.

SQL injection#

SQL injection is the classic case and one of the most damaging. A successful attack can let an anonymous user read or change every row in the database. In some setups it can even take over the database server.

Here is a login check that builds SQL by string formatting.

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT, role TEXT)")
conn.execute("INSERT INTO users VALUES ('alice', 'admin')")


def find_user_unsafe(name: str) -> list[tuple[str, str]]:
    # BAD: user input becomes part of the SQL text.
    query = f"SELECT name, role FROM users WHERE name = '{name}'"
    return conn.execute(query).fetchall()


print(find_user_unsafe("' OR '1'='1"))  # [('alice', 'admin')]

The input ' OR '1'='1 closes the string and adds a condition that is always true. The query now returns every user.

Defense: parameterized queries#

A parameterized query (prepared statement) sends the SQL and the data separately. The database parses the SQL first, then binds the data as plain values. The data can never become SQL code.

def find_user(name: str) -> list[tuple[str, str]]:
    # GOOD: the ? placeholder is filled in by the driver, as data.
    query = "SELECT name, role FROM users WHERE name = ?"
    return conn.execute(query, (name,)).fetchall()


print(find_user("' OR '1'='1"))  # []

Parameterized queries are the main fix, and a few more layers help too.

  • Use an ORM or query builder that parameterizes for you. Avoid raw string SQL inside it.
  • Give the app's database user only the rights it needs. A read-only service should not be able to DROP TABLE.
  • Validate input against an allow list where the shape is known, such as an ID that must be digits.

The same rule applies to other interpreters. Don't pass user input to eval, exec or a shell. If you must run a command, pass arguments as a list to subprocess.run and never use shell=True with user data.

Cross-site scripting (XSS)#

SQL injection attacks your server. XSS turns the same mistake against your users' browsers.

XSS lets an attacker run their own script in other users' browsers, inside your site's pages. The browser trusts the script as if your site sent it. The script can then read cookies that are not HttpOnly, steal tokens, change the page, show a fake login form, or act as the user.

XSS gets around the browser's same-origin policy. That policy stops one site's scripts from reading another site's data. An injected script runs as your site, so the policy does not stop it.

There are three common forms.

Type Where the script comes from Example
Stored Saved in your database, served to every viewer A comment that contains a <script> tag
Reflected Echoed back from the request A search page that prints the query unescaped
DOM-based Client-side JavaScript writes input into the page Code that puts location.hash into innerHTML

Defense: output encoding and CSP#

The main defense is output encoding: escape data for the place where you put it, at the moment you output it. In HTML, < becomes &lt;, so the browser shows the text instead of running it.

import html

comment = '<script>steal(document.cookie)</script>'
print(f"<p>{html.escape(comment)}</p>")
# <p>&lt;script&gt;steal(document.cookie)&lt;/script&gt;</p>
  • Use a template engine that escapes by default, such as Jinja2 with autoescaping on. Avoid the "mark as safe" escape hatch with user data.
  • Encode for the context. HTML text, HTML attributes, JavaScript and URLs each need different escaping.
  • In front-end code, set textContent, not innerHTML, for user data.
  • Add a Content Security Policy (CSP) header. It tells the browser which script sources may run, so an injected inline script is blocked.
  • Mark session cookies HttpOnly, so scripts cannot read them even if XSS gets through.

A minimal CSP that only allows scripts from your own site looks like this:

Content-Security-Policy: default-src 'self'; script-src 'self'

Input validation helps, but it is not enough by itself. The same text can be safe in one context and dangerous in another, so encoding on output is the reliable fix.

Cross-site request forgery (CSRF)#

CSRF tricks a logged-in user's browser into sending a request the user did not intend. It works because browsers attach a site's cookies to requests for that site automatically, even when another site started the request. Here's how a typical attack plays out:

  1. The user logs in to bank.example and gets a session cookie.
  2. The user visits a malicious page, perhaps from a link in an email or chat.
  3. That page holds a hidden form or image that sends a request to bank.example, for example "transfer money" or "change email".
  4. The browser attaches the bank's session cookie to the request.
  5. The bank sees a valid session and carries out the action.

CSRF targets state-changing requests. The attacker cannot read the response, so stealing data is not the goal. Against an admin account, one forged request can compromise the whole app.

Defense: SameSite cookies and CSRF tokens#

Use several of these together.

  • SameSite cookies. SameSite=Lax or Strict tells the browser not to send the cookie on most cross-site requests. Modern browsers treat cookies as Lax by default when the attribute is missing, but set it explicitly.
  • CSRF tokens. The server puts a random, unguessable token in each form, tied to the user's session. On submit it checks that the token matches. An attacker's page cannot read the token, so it cannot forge a valid request.
  • Never change state with GET. Use POST, PUT or DELETE for actions. GET requests are easy to trigger from an image tag or link.
  • Check the Origin or Referer header as an extra layer. Reject state-changing requests that come from other sites.

A session cookie set with these protections looks like this:

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/

Most web frameworks, including Django, include CSRF token protection. Turn it on rather than writing your own. If you do write one, generate tokens with secrets.token_urlsafe() and compare them with hmac.compare_digest().

APIs that authenticate with an Authorization: Bearer header, not a cookie, are not exposed to classic CSRF. The browser never adds that header on its own.

XSS vs CSRF#

The two names sound alike but the attacks work in opposite directions.

XSS CSRF
Abuses The browser's trust in your site Your site's trust in the browser
Attacker gets Their script running in your pages A forged request with the user's cookies
Can read responses ✓ ✗
Victim must be logged in ✗ (but it helps the attacker) ✓
Main defense Output encoding, CSP SameSite cookies, CSRF tokens

XSS defeats most CSRF defenses. A script running inside your page can read the CSRF token and send a valid request. Fix XSS first.

Session token weaknesses#

XSS and CSRF both ride on the user's session. The session token itself can also be the weak spot.

After login, most apps give the user a session token, usually in a cookie. The server uses it to link each request to that user. Anyone who holds the token is that user, so most session attacks try to steal or guess someone else's token.

Weakness Fix
Predictable tokens (counters, timestamps) Generate with a CSPRNG (cryptographically secure random generator), such as secrets.token_urlsafe(32)
Token sent over plain HTTP HTTPS everywhere, and the Secure cookie flag
Token readable by scripts The HttpOnly cookie flag
Token in the URL Keep it in a cookie; URLs end up in logs and Referer headers
Same token before and after login (session fixation) Issue a new token at login
Tokens that never expire Idle and absolute timeouts; delete the session on logout

For token-based auth with JWTs, see API security considerations.

Verbose error messages#

Detailed errors help you debug, and they help attackers too. A stack trace can reveal your framework, library versions, file paths and SQL queries. A login error such as "user not found" tells an attacker which usernames exist.

  • Show users a generic message and a reference ID.
  • Log the full details on the server, linked to that ID. See logs, metrics and traces.
  • Use one message for both a wrong username and a wrong password: "Invalid username or password".
  • Turn off debug mode in production.

Key takeaways#

  • Injection happens when data can change the structure of code. Use parameterized queries, and never pass user input to eval or a shell.
  • XSS runs an attacker's script in your pages. Encode output for its context, add a CSP, and mark cookies HttpOnly.
  • CSRF makes the browser send a forged, state-changing request with the user's cookies. Use SameSite cookies and CSRF tokens, and never change state on GET.
  • Session tokens must be random, sent only over HTTPS, HttpOnly, renewed at login and expired.
  • Show generic errors to users and keep the details in server logs.
Revision notes · 2 min

Core idea#

Most web attacks happen when an app treats untrusted input as trusted. Each attack has a standard defense.

Key points#

  • Injection: user data changes the structure of code for an interpreter (SQL, shell, eval).
  • SQL injection can expose or change every row. Input like ' OR '1'='1 makes a condition always true.
  • Use parameterized queries: the driver binds data as values, never as SQL.
  • Add layers: an ORM, a least-privilege database user, allow-list validation.
  • Never pass user input to eval, exec or shell=True. Pass subprocess.run a list.

XSS vs CSRF#

XSS CSRF
Abuses Browser's trust in your site Site's trust in the browser
Attacker gets Script running in your pages Forged request with user's cookies
Reads responses ✓ ✗
Main defense Output encoding, CSP SameSite cookies, CSRF tokens
  • XSS types: stored, reflected, DOM-based. It gets around the same-origin policy.
  • Encode output for its context. Use auto-escaping templates and textContent, not innerHTML.
  • Add a Content Security Policy and mark cookies HttpOnly.
  • CSRF targets state-changing requests. Never change state on GET.
  • Check the Origin or Referer header as an extra layer.
  • Bearer-token APIs are not exposed to classic CSRF.

Session tokens#

Weakness Fix
Predictable token CSPRNG: secrets.token_urlsafe(32)
Plain HTTP HTTPS and the Secure flag
Readable by scripts HttpOnly flag
Token in URL Keep it in a cookie
Same token after login Issue a new token at login
Never expires Idle and absolute timeouts

Pitfalls#

  • Relying on input validation alone against XSS. Encode on output.
  • Fixing CSRF but not XSS. A script in your page can read the CSRF token.
  • Verbose errors that leak stack traces or say "user not found". Show a generic message and log details.

Say it in an interview#

  • "Parameterized queries stop SQL injection, because data never becomes SQL."
  • "XSS needs output encoding and CSP; CSRF needs SameSite cookies and tokens."

Found a mistake? Email ask@bigonotes.com

Further reading#