Neural Tech Daily
dev-tutorials

OWASP Top 10 secure coding tutorial: fix real vulnerabilities in a Python web app

Walk through six OWASP Top 10 categories on a FastAPI sample app: injection, broken auth, access control, misconfig, vulnerable components, SSRF, with the fix for each.

Updated ~23 min read
Share
OWASP Top 10 project repository on GitHub at github.com/OWASP/Top10, the canonical source for the A01 to A10 web-app vulnerability categories this tutorial walks through

Image: OWASP/Top10 GitHub repository, used for editorial coverage of the vulnerability taxonomy this tutorial follows.

What this tutorial fixes, concretely

A FastAPI sample app with six representative OWASP Top 10:2021 weaknesses. By the end you will have rewritten each endpoint to remove the weakness, and you will know which fix corresponds to which category code. The six categories covered, in OWASP’s ranking order, are A01 (Broken Access Control), A02 (Cryptographic Failures), A03 (Injection), A05 (Security Misconfiguration), A07 (Identification and Authentication Failures), and A10 (Server-Side Request Forgery). 1 These are six of the ten 2021-edition categories, the current public edition as of 2026-05-19. 2

Each section follows the same shape: a one-paragraph description of the weakness (linked to the OWASP category page), a code block flagged as the common vulnerability pattern, and the secure-coding rewrite directly underneath. No attack tooling, no exploit walk-through; the failure modes are described in language a backend developer needs to recognise their own code in. Audience: a backend developer who has shipped a FastAPI service without a formal security review and wants the six highest-impact fixes before that review.

This tutorial targets FastAPI 0.136.1, the current latest stable as of 2026-05-19. 12 Reader prerequisites: Python 3.10 or newer, familiarity with one FastAPI tutorial’s worth of route handlers, and 90 minutes of focus. Each section runs in 10-15 minutes.

Set up the vulnerable sample app

Create a fresh project and install FastAPI plus the dependencies the tutorial uses.

mkdir owasp-tutorial && cd owasp-tutorial
python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" "sqlalchemy" "passlib[bcrypt]" "python-jose[cryptography]" "httpx" "pip-audit"

Save the following as app.py. The file is intentionally insecure across the six categories covered later; each endpoint demonstrates a common-mistake pattern the rest of the tutorial fixes. Treat it as a static reference document, not a service to expose anywhere reachable from the public internet.

# WARNING: This file demonstrates common vulnerability patterns
# documented in the OWASP Top 10:2021. Each pattern is rewritten
# securely later in the tutorial. Do NOT deploy this file.

import sqlite3
from fastapi import FastAPI, Request

app = FastAPI(debug=True)
DB_PATH = "users.db"

# A03 example: vulnerable SQL pattern - string-concatenated query
@app.get("/users/search")
def search_users(q: str):
    with sqlite3.connect(DB_PATH) as conn:
        rows = conn.execute(
            f"SELECT id, email FROM users WHERE email LIKE '%{q}%'"
        ).fetchall()
    return {"results": rows}

We do not run this file as a service; we read its endpoints as illustrative-bad-pattern references. The full sequence (vulnerability description, the common-mistake pattern, the secure rewrite) runs once per OWASP category. Each “common-mistake pattern” code block is the static reference; each “secure version” code block is the one you actually save and ship.

The six categories

1. A01:2021 Broken Access Control

OWASP describes broken access control as: “Access control enforces policy such that users cannot act outside of their intended permissions.” The category moved from fifth place in 2017 to first place in 2021, reflecting how common the failure mode is in production. 3 Typical failures: a user fetches /api/orders/<id> and the handler returns any order with that ID, regardless of who owns it; a regular user calls an admin endpoint and the handler does not check the role.

Common-mistake pattern, no ownership check (illustrative bad pattern):

# Vulnerable: returns the order to anyone who knows or guesses its ID.
@app.get("/orders/{order_id}")
def get_order(order_id: int):
    order = db.query(Order).filter(Order.id == order_id).first()
    return order

The handler trusts the URL parameter and returns whatever order has that ID. A logged-in user can iterate 1, 2, 3, ... and read every order in the database. The OWASP cheat sheet for access control calls this pattern “insecure direct object reference” (IDOR).

Secure rewrite: bind the lookup to the authenticated user:

from fastapi import Depends, HTTPException, status

@app.get("/orders/{order_id}")
def get_order(
    order_id: int,
    current_user = Depends(get_current_user),  # auth middleware
):
    order = db.query(Order).filter(
        Order.id == order_id,
        Order.user_id == current_user.id,
    ).first()
    if order is None:
        # Return 404 not 403 - do not leak existence to other users.
        raise HTTPException(status.HTTP_404_NOT_FOUND)
    return order

Three changes. The query filters by user_id == current_user.id so an order belonging to a different user does not match. A missing match returns 404 Not Found, not 403 Forbidden; returning 403 tells an attacker the resource exists but they cannot access it, which is itself a small information leak. And the Depends(get_current_user) parameter forces the handler to receive an authenticated user before any DB query happens; an unauthenticated request gets rejected upstream.

For role-based checks (admin-only endpoints), add a second dependency that asserts the role:

def require_admin(current_user = Depends(get_current_user)):
    if not current_user.is_admin:
        raise HTTPException(status.HTTP_404_NOT_FOUND)
    return current_user

@app.delete("/admin/users/{user_id}")
def delete_user(user_id: int, admin = Depends(require_admin)):
    db.query(User).filter(User.id == user_id).delete()
    db.commit()
    return {"status": "deleted"}

The dependency-injection pattern keeps the access check colocated with the route declaration. A route without a require_admin dependency is visibly missing the check; a route with it is visibly authorised.

2. A03:2021 Injection

OWASP defines injection as: “An application is vulnerable to attack when user-supplied data is not validated, filtered, or sanitized by the application.” 5 The 2021 edition merged the old XSS category into Injection, since both are the same underlying class of failure: untrusted input ends up parsed as code, query, or markup. The OWASP SQL Injection Prevention cheat sheet recommends parameterised queries as the primary defence. 10

Common-mistake pattern, string-formatted SQL (illustrative bad pattern):

# Vulnerable: any quote character in `q` breaks out of the string literal.
@app.get("/users/search")
def search_users(q: str):
    with sqlite3.connect(DB_PATH) as conn:
        rows = conn.execute(
            f"SELECT id, email FROM users WHERE email LIKE '%{q}%'"
        ).fetchall()
    return {"results": rows}

The f-string concatenates the user input directly into the SQL. Any input that contains a SQL meta-character changes the query’s structure. The fix is not “sanitise the input” (allowlists are brittle and reject legitimate inputs); the fix is to never let user input enter the query as syntax in the first place.

Secure rewrite: parameterised query:

@app.get("/users/search")
def search_users(q: str):
    with sqlite3.connect(DB_PATH) as conn:
        conn.row_factory = sqlite3.Row
        rows = conn.execute(
            "SELECT id, email FROM users WHERE email LIKE ?",
            (f"%{q}%",),
        ).fetchall()
    return {"results": [dict(r) for r in rows]}

The query string contains a ? placeholder. The user input is passed as a separate tuple argument. The DB driver handles escaping the value for that placeholder. The user input cannot alter the query’s structure because it never enters the SQL parser as syntax.

For SQLAlchemy, the equivalent pattern uses bound parameters via text():

from sqlalchemy import text

@app.get("/users/search")
def search_users(q: str, db = Depends(get_db)):
    stmt = text("SELECT id, email FROM users WHERE email LIKE :pattern")
    rows = db.execute(stmt, {"pattern": f"%{q}%"}).fetchall()
    return [dict(r._mapping) for r in rows]

The ORM-level .filter() API parameterises automatically; the danger is only in raw SQL. The OWASP cheat sheet flags one important limitation: parameterised queries protect values, not identifiers. Column names, table names, and ORDER BY directions cannot be parameterised, so those have to come from a server-side allowlist if user input drives them. 10

The same parameterisation principle extends beyond SQL. Shell commands built from user input are the OS-command-injection equivalent: use subprocess.run([cmd, arg1, arg2]) with a list, never subprocess.run(f"{cmd} {arg1}", shell=True). LDAP queries, NoSQL queries (MongoDB’s $where clauses), template engines (Jinja2 with autoescape=False) all have the same shape: untrusted input must not end up parsed as syntax.

OWASP Top 10 project repository on GitHub, the canonical source of the A01-A10 category taxonomy this tutorial follows

Image: OWASP/Top10 GitHub repository, used for editorial coverage of the project this tutorial draws its category structure from.

3. A07:2021 Identification and Authentication Failures

OWASP names this category for any failure in confirming user identity, including weak password rules, missing rate limits on login, predictable session tokens, and insecure password storage. 7 The OWASP Password Storage cheat sheet is the operational reference for the storage side: use a memory-hard hash (Argon2id, scrypt, or bcrypt), never a fast hash (MD5, SHA-256), never plain text. 11

Common-mistake pattern, plain-text or MD5-hashed passwords (illustrative bad pattern):

# Vulnerable: passwords stored as fast SHA-256 hashes, no salt, no rate limit.
import hashlib

@app.post("/register")
def register(email: str, password: str):
    pw_hash = hashlib.sha256(password.encode()).hexdigest()
    db.execute("INSERT INTO users (email, pw_hash) VALUES (?, ?)",
               (email, pw_hash))
    db.commit()
    return {"status": "ok"}

@app.post("/login")
def login(email: str, password: str):
    pw_hash = hashlib.sha256(password.encode()).hexdigest()
    user = db.execute("SELECT id FROM users WHERE email = ? AND pw_hash = ?",
                      (email, pw_hash)).fetchone()
    if user:
        return {"token": create_jwt(user["id"])}
    return {"error": "invalid"}, 401

Three problems. SHA-256 is a fast hash; a modern GPU computes billions of SHA-256s per second, so an attacker who steals the database can brute-force common passwords in minutes. There is no salt, so identical passwords produce identical hashes (rainbow-table attack). There is no rate limit on /login, so an attacker can try millions of passwords against one user. And a constant pw_hash == pw_hash comparison via SQL is technically timing-vulnerable too, though the SHA-256 weakness dominates.

Secure rewrite: bcrypt via passlib, with rate limiting:

from passlib.context import CryptContext
from slowapi import Limiter
from slowapi.util import get_remote_address

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
limiter = Limiter(key_func=get_remote_address)

@app.post("/register")
def register(email: str, password: str):
    # Enforce minimum length; longer minimums plus blocklists of known
    # breached passwords are the OWASP-recommended pattern.
    if len(password) < 12:
        raise HTTPException(400, "Password must be at least 12 characters")
    pw_hash = pwd_context.hash(password)
    db.execute("INSERT INTO users (email, pw_hash) VALUES (?, ?)",
               (email, pw_hash))
    db.commit()
    return {"status": "ok"}

@app.post("/login")
@limiter.limit("5/minute")
def login(request: Request, email: str, password: str):
    row = db.execute("SELECT id, pw_hash FROM users WHERE email = ?",
                     (email,)).fetchone()
    # Always run verify even if the user doesn't exist, to keep timing constant.
    fake_hash = "$2b$12$Cqo5p5L8Q4z5fPxN9.iSdeKMqXq3w0Lq3yJxKp2EWqgIYHj.iU8nm"
    pw_hash = row["pw_hash"] if row else fake_hash
    if not pwd_context.verify(password, pw_hash) or row is None:
        raise HTTPException(401, "Invalid credentials")
    return {"token": create_jwt(row["id"])}

The fix has four parts. passlib with bcrypt produces a slow, salted hash. Typical bcrypt configurations take 200-300ms per verify, which is fine for a login endpoint and catastrophic for a brute-force attacker. The slowapi library caps login attempts to 5 per minute per IP. The minimum-length check follows OWASP’s recommendation to favour length over complexity rules. And the constant-time pattern (always call verify, even on a non-existent email, with a dummy hash) prevents a timing oracle that would otherwise leak which emails are registered.

For session tokens, two more rules. Use a cryptographically random token generator (secrets.token_urlsafe(32), not random.choices). Set token expiry to a short window with a refresh mechanism, rather than long-lived bearer tokens.

4. A02:2021 Cryptographic Failures

The category was renamed from “Sensitive Data Exposure” in 2021 to reflect that the root cause is typically the cryptography (or absence of it), not the exposure itself. 4 Common failures: HTTP rather than HTTPS, weak or absent encryption for data at rest, hardcoded secrets in source code, and certificate verification disabled in production code.

Common-mistake pattern, TLS verification disabled (illustrative bad pattern):

# Vulnerable: silently accepts any TLS cert, including a man-in-the-middle's.
import httpx

@app.get("/proxy/{path:path}")
def proxy(path: str):
    response = httpx.get(f"https://upstream.example.com/{path}", verify=False)
    return response.json()

The verify=False parameter disables TLS certificate verification, so the client accepts any certificate the server presents, including a self-signed cert from an attacker intercepting the connection. The line is usually added to silence a CERTIFICATE_VERIFY_FAILED error during development and never removed.

Secure rewrite: verify TLS, configure secrets via environment, encrypt at rest:

import os
import httpx
from cryptography.fernet import Fernet

# Read secret from env, not from source. The .env file is gitignored
# and the prod secret is set via the deployment platform's secret manager.
UPSTREAM_TOKEN = os.environ["UPSTREAM_TOKEN"]
FERNET_KEY = os.environ["FERNET_KEY"].encode()
fernet = Fernet(FERNET_KEY)

@app.get("/proxy/{path:path}")
def proxy(path: str, current_user = Depends(get_current_user)):
    headers = {"Authorization": f"Bearer {UPSTREAM_TOKEN}"}
    # verify defaults to True; passing it explicitly here documents intent.
    response = httpx.get(
        f"https://upstream.example.com/{path}",
        headers=headers,
        verify=True,
        timeout=10.0,
    )
    return response.json()

def store_pii(user_id: int, secret_field: str):
    encrypted = fernet.encrypt(secret_field.encode())
    db.execute(
        "INSERT INTO pii (user_id, blob) VALUES (?, ?)",
        (user_id, encrypted),
    )
    db.commit()

Three changes. verify=True is the default for httpx, but writing it explicitly documents that this was a deliberate decision. Secrets come from os.environ rather than the source file; the deployment platform (Render, Fly.io, Kubernetes, AWS Secrets Manager) injects them as environment variables. And sensitive fields (PII, recovery tokens, anything you would not want in a database backup that leaks) are encrypted before insertion using a symmetric scheme like Fernet, which wraps AES-128-CBC + HMAC-SHA256 in a vetted high-level API.

For data in transit, the TLS rule is simple: deploy behind a managed load balancer or platform that terminates TLS for you (Vercel, Fly.io, Render, Cloudflare). Do not roll your own TLS in production Python code; the operational surface (cert rotation, OCSP stapling, ALPN configuration) is large.

For passwords specifically (covered in A07), the cryptographic-failure rule is to never store passwords with anything but a memory-hard hash, and never log them at any level.

5. A05:2021 Security Misconfiguration

OWASP describes misconfiguration as: “Security misconfiguration is commonly a result of insecure default configurations, incomplete or ad hoc configurations, open cloud storage, misconfigured HTTP headers, and verbose error messages containing sensitive information.” 6 In a FastAPI app the most common misconfigurations are leaving debug=True in production, exposing /docs and /openapi.json on a private API, returning full stack traces on errors, and missing security headers.

Common-mistake pattern, debug mode, no headers, CORS open to everyone (illustrative bad pattern):

# Vulnerable: debug mode, open CORS, stack traces leaked to clients.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(debug=True)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

The allow_origins=["*"] combined with allow_credentials=True is specifically banned by the CORS specification (browsers refuse the combination), but the bigger issue is the configuration’s intent: any origin can call this API with the user’s cookies. debug=True enables verbose error responses that include file paths and partial stack traces.

Secure rewrite: origin allowlist, no debug, security headers, sanitized error handling:

import os
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware

IS_PROD = os.environ.get("APP_ENV") == "production"

app = FastAPI(
    debug=not IS_PROD,
    docs_url=None if IS_PROD else "/docs",
    redoc_url=None if IS_PROD else "/redoc",
)

ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "").split(",")
app.add_middleware(
    CORSMiddleware,
    allow_origins=[o for o in ALLOWED_ORIGINS if o],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "DENY"
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
        response.headers["Strict-Transport-Security"] = (
            "max-age=31536000; includeSubDomains"
        )
        response.headers["Content-Security-Policy"] = "default-src 'self'"
        return response

app.add_middleware(SecurityHeadersMiddleware)


@app.exception_handler(Exception)
async def all_exception_handler(request: Request, exc: Exception):
    # Log the full stack trace internally; return a sanitised message to the client.
    import logging, uuid
    incident_id = str(uuid.uuid4())
    logging.exception("Unhandled exception %s", incident_id)
    return JSONResponse(
        status_code=500,
        content={"error": "internal_error", "incident_id": incident_id},
    )

What changed. debug is False whenever APP_ENV=production. /docs and /redoc are disabled in production (set docs_url=None) so internal API surface does not leak from a public deployment; expose them on staging only. CORS is restricted to a finite list of trusted origins read from environment. Security headers are added by a middleware: X-Content-Type-Options: nosniff blocks MIME sniffing, X-Frame-Options: DENY blocks clickjacking via iframe, HSTS forces HTTPS, CSP restricts what scripts can load. And the catch-all exception handler logs the trace internally and returns a sanitised response with an incident ID, so a support engineer can correlate a user’s report to the server log.

6. A10:2021 Server-Side Request Forgery (SSRF)

OWASP introduced SSRF as a standalone Top 10 category in 2021. The category covers any case where the server makes an HTTP request to a URL derived from user input, with insufficient validation of where the URL points. 9 The classic exploit fetches http://169.254.169.254/latest/meta-data/ (the AWS instance-metadata endpoint) from a server running on EC2, returning IAM credentials. Equivalent endpoints exist on GCP, Azure, and DigitalOcean.

Common-mistake pattern, fetching a URL from a request body without validation (illustrative bad pattern):

# Vulnerable: fetches whatever URL the client supplies, including internal IPs.
import httpx
from pydantic import BaseModel

class FetchRequest(BaseModel):
    url: str

@app.post("/fetch")
def fetch(req: FetchRequest):
    response = httpx.get(req.url, timeout=10.0)
    return {"status": response.status_code, "body": response.text[:1000]}

The endpoint is a typical “URL preview” or “image proxy” feature. A client posts {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"} and the server happily proxies it back, exposing cloud credentials. Local-network URLs (http://10.0.0.0/24, http://localhost:5432) are similarly reachable, exposing internal databases and services.

Secure rewrite: allowlist scheme, resolve host, reject private IPs:

import ipaddress
import socket
from urllib.parse import urlparse
import httpx
from pydantic import BaseModel
from fastapi import HTTPException

ALLOWED_SCHEMES = {"http", "https"}
ALLOWED_PORTS = {80, 443}

class FetchRequest(BaseModel):
    url: str

def is_safe_url(url: str) -> bool:
    parsed = urlparse(url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        return False
    if parsed.port is not None and parsed.port not in ALLOWED_PORTS:
        return False
    hostname = parsed.hostname
    if not hostname:
        return False
    try:
        # Resolve all IPs the hostname points to (defends against DNS rebinding).
        infos = socket.getaddrinfo(hostname, None)
    except socket.gaierror:
        return False
    for info in infos:
        ip_str = info[4][0]
        ip = ipaddress.ip_address(ip_str)
        if (
            ip.is_private
            or ip.is_loopback
            or ip.is_link_local
            or ip.is_reserved
            or ip.is_multicast
        ):
            return False
    return True

@app.post("/fetch")
def fetch(req: FetchRequest, current_user = Depends(get_current_user)):
    if not is_safe_url(req.url):
        raise HTTPException(400, "URL not allowed")
    response = httpx.get(
        req.url,
        timeout=5.0,
        follow_redirects=False,  # critical: a redirect to 169.254.x.x bypasses validation
    )
    return {"status": response.status_code, "body": response.text[:1000]}

Five protections. The scheme is allowlisted to http and https (no file://, no gopher://). The port is allowlisted. The hostname is resolved via getaddrinfo, and every IP the hostname points to is checked against the private / loopback / link-local / reserved / multicast ranges; the link-local check is what blocks 169.254.169.254 specifically. Redirects are disabled, because a redirect to a private IP after the initial check would bypass the protection. And the endpoint requires authentication, raising the cost of probing.

The is_link_local check on the link-local block 169.254.0.0/16 catches the cloud-metadata endpoint on all major providers. The is_private check catches the RFC 1918 private blocks 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. The is_loopback check catches 127.0.0.0/8. Together they cover the common SSRF attack surface for a server running in a typical cloud or on-premise environment.

FastAPI project repository on GitHub at github.com/tiangolo/fastapi, the framework whose secure-coding patterns this tutorial rewrites

Image: FastAPI GitHub repository, used for editorial coverage of the framework the secure-rewrite examples target.

The seventh fix everyone should do: A06 vulnerable components

A06:2021 Vulnerable and Outdated Components is a process issue rather than a code-level fix. The OWASP description: “You are likely vulnerable: If you do not know the versions of all components you use (both client-side and server-side).” 8 The defence is to scan dependencies regularly, ideally on every CI run, against the public vulnerability database.

The PyPA’s pip-audit tool is the canonical Python answer; it checks installed packages against the Python Packaging Advisory Database and the OSV database. 14

pip-audit

The output is a list of installed packages with known CVEs, the affected version range, and the fix version. Add pip-audit to your CI workflow on every push:

# .github/workflows/security.yml
name: security
on: [push, pull_request]
jobs:
  pip-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install -r requirements.txt
      - run: pip install pip-audit
      - run: pip-audit --strict

The --strict flag fails the build on any finding. For projects with known unfixable findings, pip-audit --ignore-vuln CVE-YYYY-NNNNN ignores a specific CVE with a comment explaining why.

A complementary tool, Dependabot on GitHub-hosted repositories, files pull requests against the lockfile when a dependency releases a patched version. The two tools together (pip-audit at CI time, Dependabot at PR time) cover the common case: a vulnerability is published, Dependabot files a PR within 24 hours, pip-audit blocks a merge that ignores the PR.

pip-audit GitHub repository at github.com/pypa/pip-audit, the PyPA-maintained Python dependency-vulnerability scanner this section configures in CI

Image: pip-audit GitHub repository (pypa/pip-audit), used for editorial coverage of the dependency-scanner this section recommends.

Common pitfalls

The first pitfall is treating the Top 10 as a checklist of attack tools rather than a taxonomy of failure modes. The OWASP project itself frames the document as awareness material, not a complete security programme; it covers ten high-impact categories, not every vulnerability that exists. 2 Once each category’s pattern is fixed, the next step is a proper threat model: who can reach this code, what data does it touch, what does success look like for an attacker.

The second pitfall is fixing one endpoint and leaving the rest. SQL injection in one route is the same class of bug as SQL injection in the next route. The grep-based audit (grep -rn "f\"SELECT" ., grep -rn "verify=False" ., grep -rn "shell=True" .) catches the obvious cases across the whole codebase in two minutes; run it before assuming the fix is done.

The third pitfall is relying on input “sanitisation” instead of structural escaping. Sanitisation is a denylist; the attacker only needs to find one bypass. Parameterised queries, contextual output encoding, allowlist input validation: these are the structural defences that survive new attack techniques.

The fourth pitfall is using pickle (or eval, or yaml.load without SafeLoader) on untrusted data. The Python pickle module’s docs are explicit: “Never unpickle data received from an untrusted or unauthenticated source.” A pickle blob can execute arbitrary code on deserialisation. Use JSON for cross-system data, or yaml.safe_load if YAML is required.

The fifth pitfall is logging secrets. Authorization headers, JWT tokens, password fields, API keys: anything that ends up in request.headers or request.json() and gets dumped to a log file at debug level. Configure the logger to redact known-sensitive field names before write; the structlog library has processors for this, and the standard logging library can be wrapped with a custom Formatter.

The sixth pitfall is shipping the fixes once and forgetting they exist. Security regressions happen the same way functional regressions happen: someone adds a new endpoint, forgets the auth dependency, ships it. CI-time linting (Bandit for Python, Semgrep for cross-language) catches the common patterns automatically; both tools have OWASP-aligned rule packs.

Bandit static security analyser GitHub repository at github.com/PyCQA/bandit, the CI-time scanner the pitfalls section recommends for catching common Python security regressions

Image: Bandit GitHub repository (PyCQA/bandit), used for editorial coverage of the static-analysis tool the pitfalls section recommends.

Where to go next

A short list of next directions. The full OWASP Top 10:2021 has four more categories beyond the six covered here: A04 (Insecure Design), A08 (Software and Data Integrity Failures), A09 (Security Logging and Monitoring Failures), and the merged-and-renamed XSS category that’s now part of A03. Each has a category page on owasp.org with examples and remediation. 1

The OWASP Cheat Sheet Series is the operational companion to the Top 10. For each category there’s a focused cheat sheet (Authentication, Authorization, Cross-Site Request Forgery Prevention, JSON Web Token, REST Security, GraphQL). The cheat sheets are the document to consult when implementing a specific control; the Top 10 names the problem, the cheat sheet describes the solution shape.

For automated scanning, the open-source Bandit tool from PyCQA scans Python code for common security issues. Semgrep extends to multi-language rule packs, with OWASP-aligned rule sets curated by the Semgrep team. Both run in CI and catch the patterns this tutorial described before the bad pattern reaches main.

For the broader threat-modelling step, the STRIDE framework (Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege) is the most-cited starting point. Run a threat-model session on the architecture once per major feature; the output is a list of where each STRIDE category applies and what control mitigates it.

For application-level monitoring, the A09 category points at the missing log + alert workflow. The combination of structured logging (one JSON-per-line, with consistent field names for user_id, request_id, route, latency_ms), a queryable log store (Loki, Elastic, Datadog), and alerts on suspicious patterns (5xx spike, sudden failed-login burst, traffic from new IP ranges) is the operational counterpart to the code-level fixes in this tutorial.

Sources

How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.

Sources consulted

Cited Sources

  1. 1. OWASP Top 10:2021 project landing — the canonical document this tutorial follows, with the full A01-A10 category taxonomy and the 2021-edition framing (accessed )
  2. 2. OWASP Top 10 project (www-project-top-ten) — current public edition is 2021, the document's stated intent as awareness material rather than a complete security programme, and the project's update cadence (accessed )
  3. 3. OWASP A01:2021 — Broken Access Control — the top-ranked category in the 2021 edition (moved from fifth in 2017), the IDOR pattern, and the role-check guidance Section 1 follows (accessed )
  4. 4. OWASP A02:2021 — Cryptographic Failures — the 2021 rename from "Sensitive Data Exposure", the typical failure modes (TLS off, hardcoded secrets, missing encryption at rest) Section 4 covers (accessed )
  5. 5. OWASP A03:2021 — Injection — the merged XSS + injection category, the OWASP definition Section 2 quotes, and the parameterised-query remediation pattern (accessed )
  6. 6. OWASP A05:2021 — Security Misconfiguration — the OWASP definition Section 5 quotes, the common misconfigurations list (debug mode, verbose errors, default credentials, missing headers), and the configuration-hardening guidance (accessed )
  7. 7. OWASP A07:2021 — Identification and Authentication Failures — the renamed-and-expanded auth category, the rate-limiting / password-storage / session-token guidance Section 3 follows (accessed )
  8. 8. OWASP A06:2021 — Vulnerable and Outdated Components — the OWASP definition the seventh-fix section quotes, the "do you know all the versions" diagnostic, and the dependency-scanning remediation (accessed )
  9. 9. OWASP A10:2021 — SSRF — the standalone-category introduction in 2021, the cloud-metadata-endpoint attack pattern, and the allowlist-based remediation Section 6 follows (accessed )
  10. 10. OWASP SQL Injection Prevention Cheat Sheet — the parameterised-query-primary-defence guidance, the identifier-vs-value parameterisation limit, and the allowlist-for-identifiers pattern Section 2 follows (accessed )
  11. 11. OWASP Password Storage Cheat Sheet — the Argon2id / scrypt / bcrypt preference, the rejection of fast hashes (MD5, SHA-256), and the minimum-length-over-complexity guidance Section 3 follows (accessed )
  12. 12. FastAPI on PyPI — current release (0.136.1 as of the access date), Python 3.10+ requirement, and the `fastapi[standard]` install pattern this tutorial uses in setup (accessed )
  13. 13. FastAPI security tutorial — the OAuth2 / JWT / dependency-injection patterns the secure-rewrite code blocks build on, plus the canonical `Depends` access-control idiom (accessed )
  14. 14. pip-audit on PyPI — the PyPA-maintained dependency-vulnerability scanner the seventh-fix section recommends, with the `--strict` CI flag and the OSV + PyPA advisory-database backends (accessed )

Anonymous · no cookies set

Report a problem with this article

Articles are produced by an autonomous AI pipeline; mistakes do happen. Tell us what's wrong and the editorial review will revisit the claim.

Category

Found this useful? Share it.