Step 06 of 25

Secure coding & the OWASP Top 10

Recognise and fix injection, broken access control, XSS, SSRF, insecure deserialization, auth failures and misconfiguration in real code.

22 min 9 sections

Most web application breaches come from a short list of mistakes developers have made for twenty years. Each has a well-understood fix, and once you've seen vulnerable and fixed code side by side, you start spotting them in review. For each class below you'll see how the attack works, the vulnerable code, the fix and the pitfalls. Examples are in Python (Flask) and TypeScript; the patterns apply in every language.

The OWASP Top 10

The OWASP Top 10 is a widely used awareness list of the most critical web application security risks, compiled from vulnerability data across many organisations. The 2021 edition is:

IDCategoryCovered here
A01Broken Access ControlIDOR and missing authorization checks
A02Cryptographic FailuresBriefly, with password storage
A03Injection (including XSS)SQL, OS command, XSS
A04Insecure DesignThreat modeling lesson
A05Security MisconfigurationDebug mode, headers, CORS
A06Vulnerable and Outdated ComponentsDependency scanning lesson
A07Identification and Authentication FailuresPassword hashing, sessions
A08Software and Data Integrity FailuresInsecure deserialization
A09Security Logging and Monitoring FailuresIncident response lesson
A10Server-Side Request Forgery (SSRF)URL fetching

OWASP has since published a 2025 revision that reorders the list, folds SSRF into Broken Access Control, broadens Vulnerable and Outdated Components into Software Supply Chain Failures and adds Mishandling of Exceptional Conditions, but the vulnerability classes in this lesson appear in both. Treat the list as a map of what to learn, not a compliance checklist: a "Top 10 compliant" app can still have a logic flaw that lets users pay themselves.

One idea runs through everything below: never trust input that crosses a trust boundary. Bodies, query strings, headers, cookies, files, URLs, webhooks and even user-originated data in your own database are attacker-controllable.

Injection: keep data out of the code

Injection happens when untrusted data is concatenated into something that gets interpreted: a SQL query, a shell command, an LDAP filter, a template. The interpreter can't tell where your code ends and the attacker's data begins.

SQL injection

python
# Vulnerable: the email is pasted into the SQL text
def find_user(db: sqlite3.Connection, email: str):
    query = f"SELECT id, email FROM users WHERE email = '{email}'"
    return db.execute(query).fetchall()

Send the email ' OR '1'='1 and the database receives:

sql
SELECT id, email FROM users WHERE email = '' OR '1'='1'

The condition is always true, so every user comes back. With other payloads an attacker can read other tables, and on some databases modify data or run commands.

The fix is a parameterised query (also called a prepared statement or bound parameter). The SQL text and the values travel separately, so values can never change the query's structure:

python
# Fixed: the driver sends the value separately from the SQL
def find_user(db: sqlite3.Connection, email: str):
    return db.execute(
        "SELECT id, email FROM users WHERE email = ?", (email,)
    ).fetchall()
text
>>> find_user(db, "' OR '1'='1")
[]

Placeholder style varies by driver (? for sqlite3, %s for psycopg, $1 for pgx and node-postgres); the principle doesn't. ORMs bind parameters for you, until you use a raw-SQL escape hatch with string formatting.

Placeholders work for values only, not identifiers like column names or ASC/DESC. When users choose a sort column, map their input through an allowlist:

python
SORT_COLUMNS = {"newest": "created_at DESC", "email": "email ASC"}

def list_users(db: sqlite3.Connection, sort: str):
    order_by = SORT_COLUMNS.get(sort, "created_at DESC")   # never the raw input
    return db.execute(f"SELECT id, email FROM users ORDER BY {order_by}").fetchall()

OS command injection

The same mistake with a shell:

python
# Vulnerable: host = "8.8.8.8; cat /etc/passwd" runs two commands
def ping(host: str) -> str:
    return subprocess.run(f"ping -c 1 {host}", shell=True,
                          capture_output=True, text=True).stdout
python
# Fixed: validate, then pass an argument list with no shell involved
def ping(host: str) -> str:
    addr = ipaddress.ip_address(host)          # raises ValueError if not an IP
    result = subprocess.run(["ping", "-c", "1", str(addr)],
                            capture_output=True, text=True, timeout=10)
    return result.stdout

With a list and no shell=True, arguments go straight to the program, so ;, | and $(...) mean nothing. Validation also stops arguments starting with - being read as options. Better still, use a library instead of shelling out.

Broken access control and IDOR

Broken access control tops the list because it's both common and invisible to most tools. The most frequent form is the insecure direct object reference (IDOR): the server checks that you're logged in, but not whether this object is yours.

python
# Vulnerable: any logged-in user can read any invoice by changing the number
@app.get("/api/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id: int):
    row = get_db().execute(
        "SELECT id, customer_id, total_cents FROM invoices WHERE id = ?",
        (invoice_id,),
    ).fetchone()
    if row is None:
        abort(404)
    return jsonify(dict(row))

User 7 requests /api/invoices/2, which belongs to user 9, and gets it. Scripting through IDs 1 to 100,000 downloads everyone's invoices. The fix puts the ownership rule into the query itself:

python
# Fixed: the row must belong to the caller, or it doesn't exist as far as they know
@app.get("/api/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id: int):
    row = get_db().execute(
        "SELECT id, customer_id, total_cents FROM invoices"
        " WHERE id = ? AND customer_id = ?",
        (invoice_id, g.user.id),
    ).fetchone()
    if row is None:
        abort(404)
    return jsonify(dict(row))

Returning 404 rather than 403 avoids confirming that invoice 2 exists. Here login_required is the authentication decorator that sets g.user; authorization is the extra condition.

Rules that prevent most access-control bugs:

  • Deny by default. Every route requires authentication and an explicit permission unless deliberately marked public.
  • Enforce on the server, per request. Hiding the "Delete" button is UX, not security; anyone can send the HTTP request.
  • Check the object, not just the role. "Is an editor" doesn't mean "may edit this document".
  • Don't trust client-supplied identity or roles, such as a user_id field in the body or a role claim the client can edit. Take identity from the server-side session or a verified token.
  • Test it. For each sensitive endpoint, write a test where user A tries to access user B's object and expects 404 or 403.

Random IDs (UUIDs) make guessing harder but aren't access control: IDs leak through URLs and logs.

Cross-site scripting and output encoding

Cross-site scripting (XSS) is injection into a web page. If attacker-controlled text is inserted into HTML without encoding, the browser runs any script in it with the victim's session: reading their data, acting as them, or rewriting the page.

ts
// Vulnerable: comment fields are pasted into HTML
type Comment = { author: string; body: string; website: string };

export function renderComment(list: HTMLElement, c: Comment): void {
  list.insertAdjacentHTML(
    "beforeend",
    `<li><a href="${c.website}">${c.author}</a>: ${c.body}</li>`,
  );
}

A comment body of <img src=x onerror="alert(document.domain)"> executes script for every visitor. That's stored XSS; if the payload comes from the URL and is reflected straight back, it's reflected XSS; if client-side code moves it from location into the DOM, it's DOM-based XSS. The fix is the same: treat data as text.

ts
// Fixed: build nodes and assign text; validate URLs separately
function safeHttpUrl(raw: string): string | null {
  try {
    const url = new URL(raw);
    return url.protocol === "https:" || url.protocol === "http:" ? url.href : null;
  } catch {
    return null;
  }
}

export function renderComment(list: HTMLElement, c: Comment): void {
  const li = document.createElement("li");
  const author = document.createElement("a");
  author.textContent = c.author;            // text, never parsed as HTML
  const href = safeHttpUrl(c.website);
  if (href) author.href = href;
  li.append(author, `: ${c.body}`);         // strings are appended as text nodes
  list.append(li);
}

Note the URL check. HTML encoding alone doesn't help in an href: a website of javascript:alert(1) contains no special characters, yet runs script when clicked. Encoding depends on context: HTML body, attribute, URL and JavaScript string contexts each need their own treatment, which is why you should let a framework do it.

  • Server-side templates like Jinja2 (Flask), Go's html/template and Django templates escape by default. XSS appears when you opt out: |safe, Markup(...), template.HTML(...).
  • React escapes text in JSX. The escape hatches are dangerouslySetInnerHTML and unvalidated href values.
  • When you must accept HTML (a rich-text editor), sanitise it with a maintained library such as DOMPurify configured with an allowlist of tags, never with your own regular expressions.

Add a Content Security Policy header as defense in depth. A policy like script-src 'self' stops the browser running inline scripts and scripts from other origins, so many XSS bugs become harmless even if one slips through.

Server-side request forgery (SSRF)

SSRF happens when your server fetches a URL that a user controls. Your server sits inside your network, so it can reach things the attacker can't: internal admin panels, databases with HTTP interfaces, and the cloud metadata service at 169.254.169.254, which on many setups hands out the machine's cloud credentials.

python
# Vulnerable: "import receipt from URL" fetches anything
def fetch_receipt(url: str) -> bytes:
    return requests.get(url, timeout=5).content

fetch_receipt("http://169.254.169.254/latest/meta-data/iam/security-credentials/") returns credentials to the attacker. The fixed version restricts the scheme, resolves the hostname and rejects any non-public address, refuses redirects (which could bounce to an internal address after the check), and caps the response size:

python
import ipaddress
import socket
from urllib.parse import urlsplit

import requests

MAX_BYTES = 5 * 1024 * 1024

def assert_public_host(host: str, port: int) -> None:
    for *_, sockaddr in socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP):
        ip = ipaddress.ip_address(sockaddr[0])
        if ip.version == 6 and ip.ipv4_mapped:
            ip = ip.ipv4_mapped                   # ::ffff:10.0.0.1 is really 10.0.0.1
        if not ip.is_global:
            raise ValueError(f"{host} resolves to non-public address {ip}")

def fetch_receipt(url: str) -> bytes:
    parts = urlsplit(url)
    if parts.scheme != "https" or not parts.hostname:
        raise ValueError("only https URLs are allowed")
    assert_public_host(parts.hostname, parts.port or 443)
    with requests.get(url, timeout=5, allow_redirects=False, stream=True) as resp:
        if resp.is_redirect:
            raise ValueError("redirects are not followed")
        resp.raise_for_status()
        body = resp.raw.read(MAX_BYTES + 1, decode_content=True)
    if len(body) > MAX_BYTES:
        raise ValueError("receipt too large")
    return body
text
https://169.254.169.254/  -> 169.254.169.254 resolves to non-public address 169.254.169.254
https://localhost:8443/   -> localhost resolves to non-public address 127.0.0.1
https://10.0.0.5/x        -> 10.0.0.5 resolves to non-public address 10.0.0.5
file:///etc/passwd        -> only https URLs are allowed

is_global rejects loopback, private, link-local and other reserved ranges in one check, and resolving first defeats tricks like http://2130706433/ (decimal for 127.0.0.1) or DNS names that point at internal IPs.

Insecure deserialization

Deserialization turns bytes back into objects. Some formats can encode not just data but instructions for constructing objects, and deserializing attacker-supplied bytes in those formats runs attacker-chosen code. Python's pickle is the textbook case:

python
# Vulnerable: user preferences stored in a cookie as a pickled object
prefs = pickle.loads(base64.b64decode(request.cookies["prefs"]))

A pickle can tell the loader to call any function with any arguments, through an object's __reduce__ method. This harmless proof shows code running during loading:

python
class Proof:
    def __reduce__(self):
        return (print, ("this ran during unpickling",))

pickle.loads(pickle.dumps(Proof()))   # prints: this ran during unpickling

Replace print with os.system and the server runs shell commands. Java's native serialization, PHP's unserialize, .NET's BinaryFormatter and YAML loaders that construct arbitrary objects (yaml.load with an unsafe loader) have the same class of problem.

The fix: exchange plain data in a data-only format such as JSON, then validate it into your own types.

python
# Fixed: JSON carries only data; validation enforces the shape
@dataclass(frozen=True)
class Prefs:
    theme: str = "light"
    page_size: int = 20

    @classmethod
    def from_dict(cls, data: object) -> "Prefs":
        if not isinstance(data, dict):
            raise ValueError("prefs must be an object")
        theme = data.get("theme", "light")
        page_size = data.get("page_size", 20)
        if theme not in {"light", "dark"}:
            raise ValueError("bad theme")
        if type(page_size) is not int or not 1 <= page_size <= 100:
            raise ValueError("bad page_size")
        return cls(theme, page_size)

prefs = Prefs.from_dict(json.loads(request.cookies.get("prefs", "{}")))

type(page_size) is not int is deliberate: isinstance(True, int) is true in Python, so isinstance would accept true as a page size. For YAML use yaml.safe_load. If state must round-trip through the client and must not be modified, sign it (Flask's built-in session cookie does this with your SECRET_KEY); signing stops tampering but doesn't hide the contents.

Authentication failures and misconfiguration

Authentication

Authentication bugs hand attackers whole accounts. The common ones and their fixes:

python
# Vulnerable: fast, unsalted hash; timing-unsafe comparison of an API token
def check_password(stored_md5: str, password: str) -> bool:
    return hashlib.md5(password.encode()).hexdigest() == stored_md5

def check_api_token(supplied: str) -> bool:
    return supplied == os.environ["API_TOKEN"]
python
# Fixed: argon2id (argon2-cffi) and a constant-time comparison
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()   # argon2id with sensible default parameters

def hash_password(password: str) -> str:
    return ph.hash(password)        # "$argon2id$v=19$m=65536,t=3,p=4$..."

def verify_password(stored_hash: str, password: str) -> bool:
    try:
        ph.verify(stored_hash, password)
    except VerifyMismatchError:
        return False
    return True

def check_api_token(supplied: str) -> bool:
    return hmac.compare_digest(supplied.encode(), os.environ["API_TOKEN"].encode())

MD5 and SHA-256 are fast, so leaked hashes are cracked at billions of guesses per second; argon2id is deliberately slow and memory-hard, with a random salt per hash. == on strings can return as soon as one character differs, leaking how much of a guess was right through timing; hmac.compare_digest takes the same time regardless. Beyond the code: rate-limit and monitor login attempts, offer MFA, return the same error for "unknown user" and "wrong password", and issue a new session identifier at login so a session ID planted before login (session fixation) is worthless.

Security misconfiguration

Secure code can be undone by its configuration. The classic example is shipping with debug mode on:

python
# Vulnerable
app.run(host="0.0.0.0", debug=True)

Flask's debug mode serves the interactive Werkzeug debugger on error pages, which lets whoever sees the error run Python on your server (it's PIN-protected, but that's not a boundary you want to rely on), and leaks stack traces and configuration. Production apps run under a WSGI server such as gunicorn with debug off, secrets from the environment, and safe defaults applied centrally:

python
# Fixed
app.config.update(
    DEBUG=False,
    SECRET_KEY=os.environ["SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
)

ALLOWED_ORIGINS = {"https://app.example.com"}

@app.after_request
def security_headers(resp):
    resp.headers["Content-Security-Policy"] = (
        "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
    )
    resp.headers["X-Content-Type-Options"] = "nosniff"
    resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    resp.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    origin = request.headers.get("Origin")
    if origin in ALLOWED_ORIGINS:                 # allowlist, never reflect blindly
        resp.headers["Access-Control-Allow-Origin"] = origin
        resp.headers["Access-Control-Allow-Credentials"] = "true"
    resp.headers.add("Vary", "Origin")
    return resp

The CORS block deserves attention. A common misconfiguration copies the request's Origin header straight into Access-Control-Allow-Origin together with Allow-Credentials: true, which lets any website read your API responses using the victim's cookies. Only echo origins from an explicit allowlist. Other frequent misconfigurations: default admin credentials, directory listings, verbose error messages, unnecessary ports and sample apps, and cloud storage left public.

Practice

The Flask endpoint below has four vulnerabilities. Find each one, name its class, and rewrite the endpoint securely.

python
@app.post("/api/reports/<int:report_id>/export")
@login_required
def export_report(report_id: int):
    fmt = request.args.get("format", "csv")
    report = get_db().execute(
        f"SELECT id, owner_id, title FROM reports WHERE id = {report_id}"
    ).fetchone()
    webhook = request.json["notify_url"]
    out = f"/tmp/report-{report_id}.{fmt}"
    subprocess.run(f"export-tool --id {report['id']} --out {out}", shell=True)
    requests.post(webhook, json={"file": out}, timeout=5)
    return {"file": out, "title": report["title"]}
Show solution
  1. Broken access control (IDOR): the report is never checked against g.user, so any user can export any report.
  2. SQL injection pattern: the query uses an f-string. report_id happens to be an int from the route converter, so this line isn't exploitable today, but it's one refactor away from being so. Always bind parameters.
  3. OS command injection: fmt comes from the query string and is pasted into a shell command, so ?format=csv;id runs id.
  4. SSRF: notify_url is user-controlled and the server posts to it, so it can reach internal services.
python
EXPORT_FORMATS = {"csv", "xlsx"}

@app.post("/api/reports/<int:report_id>/export")
@login_required
def export_report(report_id: int):
    fmt = request.args.get("format", "csv")
    if fmt not in EXPORT_FORMATS:
        abort(400)
    report = get_db().execute(
        "SELECT id, title FROM reports WHERE id = ? AND owner_id = ?",
        (report_id, g.user.id),
    ).fetchone()
    if report is None:
        abort(404)

    body = request.get_json(silent=True) or {}
    webhook = body.get("notify_url")
    if not isinstance(webhook, str):
        abort(400)
    parts = urlsplit(webhook)
    if parts.scheme != "https" or not parts.hostname:
        abort(400)
    try:
        assert_public_host(parts.hostname, parts.port or 443)
    except (ValueError, OSError):        # OSError covers DNS failures
        abort(400)

    out = f"/var/lib/reports/report-{report['id']}.{fmt}"
    subprocess.run(["export-tool", "--id", str(report["id"]), "--out", out],
                   check=True, timeout=60)
    requests.post(webhook, json={"report_id": report["id"]},
                  timeout=5, allow_redirects=False)
    return {"report_id": report["id"], "title": report["title"]}

Additional improvements: the output path moves out of world-shared /tmp, the format is allowlisted (which also prevents path tricks in the filename), the webhook payload no longer reveals a server path, and the title is returned as JSON, so the frontend must render it as text to avoid stored XSS. Even better: let users pick from pre-registered webhook URLs instead of arbitrary ones.

Key takeaways

  • Injection is data treated as code: use parameterised queries, argument lists instead of shells, and allowlists for identifiers.
  • Access control must be checked on the server for every request and every object; put ownership into the query and test cross-user access.
  • Prevent XSS by treating data as text (textContent, auto-escaping templates), validating URL schemes, sanitising unavoidable HTML, and adding a CSP.
  • Guard any server-side URL fetch: https only, resolve and reject non-public IPs, no redirects, size limits, and a network-level egress control.
  • Never deserialize untrusted data with object-constructing formats like pickle; use JSON plus validation, and sign data that must not be modified.
  • Hash passwords with argon2id or bcrypt, compare secrets in constant time, and ship with debug off, strict headers and an allowlisted CORS policy.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Users can sort a table by any column they choose. How should you include the column name in SQL safely?

  2. 2.An endpoint checks that the caller is logged in and has the 'customer' role, then returns /orders/{id} for any id. What is the vulnerability?

  3. 3.Why is HTML-encoding a user's website URL not enough before putting it in an href attribute?

  4. 4.Your SSRF defence resolves the hostname and rejects private IPs, but a security review still asks for an egress proxy. Why?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.