Common Attacks and Fixes
28 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Most real breaches use a few old mistakes: building SQL from text, printing user input as HTML, keeping keys in code, trusting every field in a request, and not checking who owns a record. For each one you will see the weak code, why it fails and the standard fix.
SQL injection: keep values out of the SQL text
SQL injection is an attack where input is read as part of the SQL command instead of as data. It can leak, change or delete data. Parameterised queries prevent it.
If you join visitor input into a query string, the visitor can change the query itself. The fix is a parameterised query: you send the SQL with placeholders, and the values separately. The database then treats the values only as data.
city = request_value
# weak: input becomes part of the SQLsql = f"SELECT email FROM customers WHERE city = '{city}'"
# safe: SQL text and values travel apartrows = db.execute("SELECT email FROM customers WHERE city = ?", (city,))Example 1 · The same input in an f-string and in a parameter
import sqlite3
db = sqlite3.connect(":memory:")db.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, email TEXT, city TEXT)")db.executemany("INSERT INTO customers (email, city) VALUES (?, ?)", [("asha@example.com", "Pune"), ("ravi@example.com", "Delhi"), ("meena@example.com", "Pune")])
city = "Pune' OR '1'='1" # what a hostile visitor might type
unsafe_sql = f"SELECT email FROM customers WHERE city = '{city}'"print(unsafe_sql)print("f-string rows:", len(db.execute(unsafe_sql).fetchall()))
safe = db.execute("SELECT email FROM customers WHERE city = ?", (city,)).fetchall()print("parameter rows:", len(safe))print(db.execute("SELECT email FROM customers WHERE city = ?", ("Pune",)).fetchall())SELECT email FROM customers WHERE city = 'Pune' OR '1'='1'
f-string rows: 3
parameter rows: 0
[('asha@example.com',), ('meena@example.com',)]What this code does
- The f-string version turns the input into part of the condition, so every customer comes back. The parameter version looks for a city with that exact odd name and finds none.
- A normal city name still works with the parameter query.
Parameters protect values only. Table and column names cannot be parameters, so check them against a fixed list. SQLAlchemy does this for you when you use its expressions, but not when you paste text into text().
XSS: escape what you print
XSS (cross-site scripting) is an attack where a page runs script text that an attacker put into it, usually through user input shown without escaping. It can steal sessions and act as the victim.
Cross-site scripting happens when text from a user is placed into a web page as HTML. A comment containing a script tag would then run in other visitors' browsers. Escaping turns the special characters into harmless text.
import html
comment = '<script>steal()</script>'safe = html.escape(comment)print(safe)Example 2 · html.escape on user text
import html
comment = '<script>alert("hi")</script>'print(html.escape(comment))
name = 'x" onmouseover="steal()'print(f'<input value="{html.escape(name)}">')
print(html.escape("Tom & Jerry's"))<script>alert("hi")</script> <input value="x" onmouseover="steal()"> Tom & Jerry's
What this code does
- The script tag becomes plain text that the browser shows but does not run.
- Quotes are escaped as well, so the value cannot break out of the attribute.
Template engines such as Jinja2 escape by default; keep that switched on. A JSON API is safe by itself, but the page that shows the data must not insert it with innerHTML. A Content-Security-Policy header adds a second layer.
Secrets in code and mass assignment
Mass assignment is a flaw where a server copies all fields of a request into an object or database row, so a user can set fields they should not, such as role or price. Fix it by naming the allowed fields.
Keys, passwords and tokens written in code end up in Git history and in every copy of the repository. Read them from the environment and stop with a clear error when one is missing. Mass assignment is the other trap: copying every field of a request into a record lets a user send role: admin.
import os
SECRET_KEY = os.environ["APP_SECRET_KEY"] # missing: stops at start
class SignUp(BaseModel): email: str password: str # only these two fields are readExample 3 · Mass assignment and its fix
from fastapi import FastAPIfrom fastapi.testclient import TestClientfrom pydantic import BaseModel
USERS = {}app = FastAPI()
@app.post("/bad-signup")def bad_signup(body: dict): user = {"role": "customer", **body} USERS[body["email"]] = user return user
class SignUp(BaseModel): email: str password: str
class UserOut(BaseModel): email: str role: str
@app.post("/signup", response_model=UserOut)def signup(body: SignUp): user = {"role": "customer", "email": body.email, "password_hash": "x" * 8} USERS[body.email] = user return user
client = TestClient(app)attack = {"email": "mallory@example.com", "password": "pw", "role": "admin"}print(client.post("/bad-signup", json=attack).json()["role"])print(client.post("/signup", json=attack).json())admin
{'email': 'mallory@example.com', 'role': 'customer'}What this code does
- The weak route copies every field, so the attacker becomes admin.
- With a
SignUpmodel only email and password are read,roleis ignored, and the response model hidespassword_hash.
Example 4 · Secrets from the environment
import osimport secrets
def get_setting(name): value = os.environ.get(name) if not value: raise RuntimeError(f"Missing setting: {name}") return value
try: get_setting("APP_SECRET_KEY")except RuntimeError as err: print(err)
os.environ["APP_SECRET_KEY"] = "value-set-by-the-server" # normally set outside the codeprint(get_setting("APP_SECRET_KEY"))
new_key = secrets.token_urlsafe(32)print(len(new_key))Missing setting: APP_SECRET_KEY value-set-by-the-server 43
What this code does
- A missing setting stops the program with a clear message instead of using a default key.
secrets.token_urlsafe(32)makes a strong key. 32 random bytes give a 43-character text.
Pydantic ignores fields that the model does not name. Use separate models for input and output, so fields like role and password_hash are never read from or sent to the client.
IDOR: filter by owner in the query
An insecure direct object reference lets one user read or change another user's record by changing an id. Put the owner into the query itself, so a record that is not yours simply is not found. This is safer than loading the row and checking afterwards.
row = db.execute( "SELECT id, total FROM orders WHERE id = ? AND owner_id = ?", (order_id, current_user_id),).fetchone()if row is None: raise HTTPException(404, "Order not found")Example 5 · Owner inside the query
import sqlite3
db = sqlite3.connect(":memory:")db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, owner_id INTEGER, total INTEGER)")db.executemany("INSERT INTO orders (id, owner_id, total) VALUES (?, ?, ?)", [(1, 1, 500), (2, 2, 900)])
def get_order(order_id, user_id): return db.execute("SELECT id, total FROM orders WHERE id = ? AND owner_id = ?", (order_id, user_id)).fetchone()
def cancel_order(order_id, user_id): cur = db.execute("DELETE FROM orders WHERE id = ? AND owner_id = ?", (order_id, user_id)) return cur.rowcount
print(get_order(1, 1))print(get_order(2, 1))print(cancel_order(2, 1), cancel_order(2, 2))(1, 500) None 0 1
What this code does
- User 1 asks for order 2, which belongs to user 2, and gets nothing. The same rule makes the delete do nothing: its row count is 0.
- Only the owner changes the row.
Apply the same rule to updates and deletes. Use random ids (UUIDs) for extra protection, but never as the only protection: ids can leak.
Next step
Check your understanding
12 quick questions