# Secure coding & the OWASP Top 10

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

DevSecOps Engineer roadmap · Secure the code · 22 min read · https://levelcode.org/roadmaps/devsecops/secure-coding

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:

| ID | Category | Covered here |
|---|---|---|
| A01 | Broken Access Control | IDOR and missing authorization checks |
| A02 | Cryptographic Failures | Briefly, with password storage |
| A03 | Injection (including XSS) | SQL, OS command, XSS |
| A04 | Insecure Design | Threat modeling lesson |
| A05 | Security Misconfiguration | Debug mode, headers, CORS |
| A06 | Vulnerable and Outdated Components | Dependency scanning lesson |
| A07 | Identification and Authentication Failures | Password hashing, sessions |
| A08 | Software and Data Integrity Failures | Insecure deserialization |
| A09 | Security Logging and Monitoring Failures | Incident response lesson |
| A10 | Server-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.

> [!WARNING]
> Application-level checks have a gap: DNS can return a public IP when you check and a private one when `requests` connects (DNS rebinding). Back them up at the network layer: send outbound fetches through an egress proxy that refuses internal ranges, and on AWS require IMDSv2, whose session tokens a simple GET can't obtain. If you only need a few known hosts, an allowlist of hostnames beats any blocklist.

## 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.

> [!TIP]
> Cryptographic failures (A02) often look like misconfiguration: TLS disabled between services, home-made encryption, keys in source code. Use TLS everywhere, use your platform's vetted libraries (never invent algorithms), and keep keys in a secret manager.

## 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"]}
```

**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.

## Quiz

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

   A. Pass it as a bound parameter like the other values
   B. Map the user's choice through an allowlist of known column names
   C. Escape single quotes in the input
   D. Base64-encode it before inserting it into the query

   **Answer: B.** Placeholders bind values, not identifiers, so a column name can't be a parameter. Mapping input to a fixed set of known-good strings means the raw input never reaches the SQL text.

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?

   A. Cross-site scripting
   B. SQL injection
   C. Insecure direct object reference (broken access control)
   D. Security misconfiguration

   **Answer: C.** Authentication and a role check don't establish that this particular order belongs to the caller. The fix is an ownership condition in the query and a 404 when it doesn't match.

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

   A. A javascript: URL contains no special HTML characters but still runs script when clicked
   B. Browsers decode href attributes twice
   C. Encoding breaks all valid URLs
   D. href attributes are never rendered by the browser

   **Answer: A.** Output encoding is context-specific. In a URL context you must also validate the scheme (allow only http and https), otherwise javascript: URLs execute.

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

   A. Proxies make requests faster
   B. requests cannot connect to public IPs without a proxy
   C. IPv6 addresses cannot be checked in Python
   D. DNS can return a different (internal) address between your check and the actual connection, so a network-level control is needed as a second layer

   **Answer: D.** This is DNS rebinding, a time-of-check to time-of-use gap. An egress proxy or network policy that refuses internal ranges enforces the rule at connection time, independently of application code.
