Python web framework security is not a contest where Django, Flask, or FastAPI wins once and stays safe forever. It is a set of tradeoffs. Django ships with more built-in protection. Flask gives developers a small core and expects them to assemble the security posture intentionally. FastAPI gives teams a strong API development experience through type hints, dependency injection, generated OpenAPI, Pydantic models, and Starlette middleware, but those strengths do not remove the need for authorization design, dependency management, proxy hardening, and careful deployment.
The mistakes that matter most are rarely exotic. They are usually found where a framework guarantee ends and application-specific logic begins. A Django template escapes most dangerous HTML characters, but mark_safe, safe, custom template tags, and unquoted attributes can still create XSS. Django querysets use parameterized queries, but raw SQL, dynamic aliases, and misused advanced ORM features have still produced SQL injection CVEs. Flask can be deployed securely, but it will not automatically choose CSRF protection, security headers, host validation, rate limits, and every cookie setting for you. FastAPI can validate a JSON body beautifully, but request validation is not object-level authorization.
The practical question is not “Which Python web framework is secure?” The better question is “Which framework boundaries can attackers reach, and which assumptions does the application make on top of those boundaries?”
The security model behind Django, Flask, and FastAPI
Django is the most opinionated of the three. It includes an ORM, template system, authentication, session framework, CSRF middleware, clickjacking protection, host validation through ALLOWED_HOSTS, and several production security settings. The official Django security documentation describes built-in defenses for XSS, CSRF, SQL injection, clickjacking, HTTPS, host header validation, sessions, user-uploaded content, CSP, and related deployment concerns. It also warns that developers must still sanitize user-controlled data and understand the limits of the built-in protections. (Django Project)
Flask is intentionally smaller. The official Flask security page discusses resource limits, XSS, CSRF, JSON security, security headers, cookie options, and host header validation, but the framework leaves much of the final posture to the application and its extensions. Flask’s own documentation recommends settings such as SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLYy SESSION_COOKIE_SAMESITE, and it explains that TRUSTED_HOSTS should be configured in deployment to restrict valid Anfitrión header values. (Flask)
FastAPI is different again. It is API-first, type-hint-driven, and built around dependencies and OpenAPI. FastAPI’s security tutorial shows OAuth2PasswordBearer for bearer-token authentication flows, and the framework’s dependency system can express OAuth2 scopes through Security(). Request bodies are commonly declared with Pydantic models, which gives developers strong parsing and validation ergonomics. FastAPI also uses Starlette capabilities for middleware such as CORS and trusted-host checks, so Starlette security advisories matter directly to many FastAPI deployments. (FastAPI)
| Framework | Security posture | Strong defaults | Common weak spots | Best fit when security process is mature |
|---|---|---|---|---|
| Django | Opinionated, batteries-included | CSRF, ORM parameterization, template escaping, clickjacking middleware, host validation, session framework | Misused raw SQL, mark_safe, csrf_exempt, exposed admin, cache/proxy mistakes, upload handling, unsupported versions | Apps that benefit from a complete framework, admin workflows, content systems, business apps, and teams that want safer defaults |
| Flask | Minimal core, extension-driven | Jinja escaping, signed session cookies, configurable cookie and host settings | Missing CSRF, debug mode, weak SECRET_KEY, incomplete headers, unsafe file paths, inconsistent extension choices, cache/session mistakes | Small services, custom architectures, internal tools, and teams that maintain a hardened Flask baseline |
| FastAPI | API-first, ASGI, dependency-driven | Pydantic request models, dependency injection, OpenAPI, OAuth2 helpers, Starlette middleware | BOLA, CORS mistakes, OpenAPI overexposure, bearer-token misuse, Starlette dependency CVEs, multipart/resource exhaustion, path/proxy assumptions | API platforms, ML/AI services, async workloads, and teams with strong API authorization discipline |
A framework can reduce entire categories of mistakes, but it cannot infer a tenant boundary, understand a paid-plan rule, decide who owns an invoice, or know whether an uploaded SVG should be rendered, stored, sanitized, transformed, or rejected. Those are application rules. Attackers know this, so they spend less time asking whether Django has CSRF and more time asking whether one sensitive view was marked csrf_exempt, whether an API ID can be changed, whether a reverse proxy forwards spoofable headers, or whether a cached private response can leak across users.

The vulnerability patterns that keep coming back
The OWASP Top 10 lists broken access control, cryptographic failures, injection, insecure design, security misconfiguration, vulnerable and outdated components, identification and authentication failures, software and data integrity failures, logging and monitoring failures, and SSRF among the major web application risks. OWASP also notes that broken access control rose to the top category in its 2021 list and includes patterns such as force browsing, parameter tampering, insecure direct object references, and missing access controls on state-changing API methods. (Fundación OWASP)
For APIs, OWASP API1:2023 is Broken Object Level Authorization. The core pattern is simple: an endpoint accepts an object ID in a path, query string, header, or request body, and the server fails to verify that the authenticated user is allowed to act on that object. OWASP emphasizes that every API endpoint receiving an object ID and performing an action on that object should implement object-level authorization checks. (Fundación OWASP)
Those two OWASP ideas explain much of Python web framework security in practice. Django, Flask, and FastAPI all have different tools, but they all expose routes. Routes call code. Code reads request data. Request data influences database queries, templates, file paths, cache keys, redirects, authorization checks, and downstream HTTP requests. Vulnerabilities appear when the application gives user-controlled data more authority than it should.
A realistic review of a Python web framework application should start with these patterns:
| Pattern | Django example | Flask example | FastAPI example | Why it survives framework defaults |
|---|---|---|---|---|
| Broken object authorization | /projects/42/edit/ checks login but not project membership | /api/invoices/<id> returns any invoice for an authenticated user | /users/{user_id}/tokens trusts usuario_id from path | The framework can authenticate a user, but it cannot know ownership rules |
| Inyección SQL | Inseguro RawSQL, extra, or dynamic ORM aliases | Raw SQL string built from query params | SQLAlchemy text query with interpolated sort or filter | ORMs parameterize values, not every dynamic SQL structure |
| XSS | mark_safe, safe, unquoted attributes | Markup, ` | safe`, rendered user HTML | Admin/dashboard template or frontend consuming API data unsafely |
| CSRF | csrf_exempt on state-changing view | Missing CSRF extension on session-cookie app | Cookie-authenticated API without CSRF tokens | CSRF depends on browser credential behavior, not the framework name |
| Host header abuse | Direct request.META["HTTP_HOST"] use | Missing TRUSTED_HOSTS | Missing TrustedHostMiddleware or path checks based on reconstructed URL | Reverse proxies and apps may interpret host/path differently |
| Cache leakage | Private view cached without varying on auth/session | Session response cached without correct Vary manejo de | API gateway caches auth-specific response | Cache keys often live outside handler code |
| Resource exhaustion | Large uploads, parser limits, expensive forms | Large JSON/multipart body, no server-side limits | Starlette multipart parser or async workers exhausted | Limits must exist at proxy, app, and parser layers |
| Dependency CVEs | Django, DRF, auth plugins | Flask, Werkzeug, Jinja, extensions | FastAPI, Starlette, Pydantic, Uvicorn | A secure app can inherit a vulnerable parser, URL builder, or helper |
A bug bounty hunter or red teamer rarely needs a zero-day in the framework itself. More often, they need a single route where a secure primitive is used outside its safe assumptions.

Django security, strong defaults with sharp edges
Django gives teams a safer starting point than many frameworks because it ships many protections by default or in common project templates. Its template engine escapes dangerous HTML characters, its querysets are built using query parameterization, its CSRF middleware checks a secret in POST requests, its clickjacking middleware can set X-Frame-Options, and its host validation model relies on ALLOWED_HOSTS when developers call request.get_host(). (Django Project)
That does not mean Django apps are safe by default in production. The official docs explicitly warn that Django’s template protection is not foolproof. An unquoted attribute can become dangerous, and developers must be careful with safe, mark_safe, custom template tags marked as safe, and disabled autoescaping. The docs also caution that Django’s raw SQL capabilities should be used sparingly and that parameters controlled by users must be escaped correctly. (Django Project)
A hardened Django production baseline usually starts in settings.py:
# settings.py
DEBUG = False
ALLOWED_HOSTS = [
"example.com",
"www.example.com",
]
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_HTTPONLY = False # Often False when frontend JS must read the CSRF token.
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SAMESITE = "Lax"
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
SECURE_REFERRER_POLICY = "same-origin"
CSRF_TRUSTED_ORIGINS = [
"https://example.com",
"https://www.example.com",
]
These settings do not replace application review. They are guardrails. ALLOWED_HOSTS helps only if application code uses Django’s host helpers instead of trusting request.META["HTTP_HOST"]. HTTPS settings help only if the proxy forwards the right scheme and the app is not confused by spoofable headers. CSRF middleware helps only where it is not bypassed and where the frontend sends tokens correctly. Secure cookies help only if secrets are not leaked and session lifetime is appropriate.
The official Django documentation is blunt about deployment responsibilities. It says HTTPS is better for security, recommends secure cookies and HSTS, warns that SECURE_PROXY_SSL_HEADER mistakes can result in CSRF vulnerabilities, and says uploaded file size should also be limited in the web server configuration rather than relying only on Django’s upload memory settings. (Django Project)
Django CVEs that teach the right lesson
Recent Django security advisories are useful because they show where mature framework protections can still fail in edge cases. CVE-2025-64459 was a potential SQL injection issue involving QuerySet.filter(), exclude(), get()y Q() when a crafted dictionary was expanded into the _connector argument. Django fixed the issue in supported branches, including 5.2.8, 5.1.14, and 4.2.26. The important lesson is not that Django’s ORM is unsafe; the lesson is that advanced dynamic query construction deserves the same review as raw SQL when untrusted dictionaries or field names influence query shape. (Django Project)
CVE-2026-3902 is another good example. Django’s ASGI request handling normalized header names in a way that mapped hyphens and underscores into the same form, which meant a security-sensitive header stripped by a proxy under one spelling could potentially be spoofed under another spelling. Django fixed the issue in 6.0.4, 5.2.13, and 4.2.30. The practical lesson is that ASGI/WSGI adapters, reverse proxies, and application code form one trust chain. If different layers interpret headers differently, authorization and routing logic can break. (Django Project)
CVE-2026-35193 shows another boundary: caching. Django’s UpdateCacheMiddleware y cache_page decorator could allow responses to requests carrying an Authorization header to be cached without varying on Authorization, unless Cache-Control: public was present. NVD describes the impact as private cached responses being readable through unauthenticated requests to the same URL. The fix varied those responses on Authorization. The lesson is that cache keys are part of the security model. (Django Project)
These are not reasons to avoid Django. They are reasons to patch quickly, keep unsupported series out of production, and audit the parts of the app that dynamically construct queries, depend on proxy-provided headers, or cache authenticated content.
Flask security, freedom with fewer automatic rails
Flask is often chosen because it is small, flexible, and easy to reason about. That simplicity is real, but it moves more responsibility to the application. A Flask app using browser sessions needs CSRF protection. A Flask app behind a proxy needs host validation and scheme handling. A Flask app returning user-specific pages behind a CDN needs correct cache headers. A Flask app with file downloads needs safe path handling. A Flask app with SQLAlchemy still needs safe query construction.
Flask’s official security page recommends cookie settings that map directly to common attacks: Asegure limits cookies to HTTPS, HttpOnly prevents JavaScript from reading cookie contents, and SameSite controls whether cookies are sent on cross-site requests. It also recommends setting TRUSTED_HOSTS in deployment because attackers outside normal browser flows may send arbitrary Anfitrión headers. (Flask)
A production Flask baseline might look like this:
# app.py
from flask import Flask
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
app.config.update(
DEBUG=False,
TESTING=False,
SECRET_KEY="${SET_FROM_SECRET_MANAGER}",
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
PERMANENT_SESSION_LIFETIME=600,
TRUSTED_HOSTS=["example.com", "www.example.com"],
MAX_CONTENT_LENGTH=10 * 1024 * 1024,
)
csrf = CSRFProtect(app)
@app.after_request
def set_security_headers(response):
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'"
)
return response
The example is intentionally conservative. Real applications may need a more complex CSP, a different SameSite policy, a session lifetime aligned with user expectations, and separate CSRF handling for JSON APIs. The point is that Flask will not force this shape. Teams must define it.
Flask’s flexibility also makes extension hygiene important. A Flask app may use Flask-Login, Flask-WTF, Flask-Security-Too, SQLAlchemy, Marshmallow, Flask-CORS, custom auth middleware, or company-specific wrappers. Security review must include those libraries, their configuration, and their update cadence. “We use Flask” does not describe the security posture. “We use Flask with these extensions, these cookie settings, this CSRF model, this host policy, this proxy policy, this authz layer, and this patch process” does.
Flask CVEs that point to real operational risk
CVE-2023-30861 involved possible disclosure of permanent session cookies due to a missing Vary: Cookie header. The GitHub advisory explains that vulnerable Flask versions only set Vary: Cookie when the session was accessed or modified, not when it was refreshed to update expiration. NVD lists Flask 2.2.5 and 2.3.2 release notes and related patches among the references. This is a cache-boundary issue, not a template issue or SQL issue. (GitHub)
A later Flask advisory in February 2026 described another Vary: Cookie edge case where some forms of session access, such as the Python en operator, did not set Vary: Cookie. The advisory states that the risk depends on application session use and cache behavior, including whether the app sits behind a caching proxy that does not ignore responses with cookies and whether the app fails to set private/no-cache headers. (GitHub)
CVE-2025-47278, tracked through the Flask advisory for fallback signing keys, affected Flask 3.1.0 when SECRET_KEY_FALLBACKS was used. The advisory says Flask constructed the key list in reverse, causing stale fallback keys to be used for signing rather than the current signing key. Sessions remained signed, but key rotation did not work as intended. The lesson is that key rotation is a security feature only if the framework and configuration actually retire old signing authority. (GitHub)
Werkzeug matters because Flask depends on it. CVE-2024-49766 affected Werkzeug’s safe_join() on Windows with Python versions before 3.11 because os.path.isabs() did not catch UNC paths such as //server/share. NVD states that applications using Python 3.11 or later, or not using Windows, were not vulnerable, and that Werkzeug 3.0.6 contained a patch. This is a good reminder that file path safety depends on the operating system and runtime version, not just the helper name. (NVD)
FastAPI security, API speed does not equal API authorization
FastAPI’s strengths are exactly why it has become common for APIs, internal platforms, AI services, and ML inference systems. It encourages typed request models, makes dependency injection ergonomic, generates OpenAPI documentation, supports OAuth2 flows, and runs on ASGI. Those features help developers build quickly, but they also create security assumptions that need to be checked.
Pydantic request models validate shape and type. They do not decide whether the caller owns the object. A bearer token proves possession of a token. It does not prove the token carries the correct scope, tenant, role, or relationship to the requested object. An OpenAPI schema documents the API. It does not guarantee every documented route should be visible to the internet. CORS middleware controls browser cross-origin behavior. It does not secure server-to-server requests. Starlette middleware can enforce trusted hosts. It does not help if the app uses a vulnerable Starlette version or builds authorization logic from the wrong request attribute.
FastAPI’s CORS documentation explains that an origin is the combination of scheme, domain, and port, and that wildcard origins exclude credentialed requests such as cookies and authorization headers. It recommends explicitly specifying allowed origins for correct behavior, and shows CORSMiddleware settings for origins, credentials, methods, and headers. (FastAPI)
A safer FastAPI baseline usually includes explicit CORS, trusted host enforcement, authentication dependencies, and object-level authorization inside the route or service layer:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
from starlette.middleware.trustedhost import TrustedHostMiddleware
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["api.example.com"],
)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="/auth/token",
scopes={
"projects:read": "Read projects",
"projects:write": "Modify projects",
},
)
class User:
def __init__(self, user_id: str, scopes: set[str], tenant_id: str):
self.user_id = user_id
self.scopes = scopes
self.tenant_id = tenant_id
async def get_current_user(
security_scopes: SecurityScopes,
token: Annotated[str, Depends(oauth2_scheme)],
) -> User:
user = verify_and_decode_token(token)
missing = set(security_scopes.scopes) - user.scopes
if missing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing required scope",
)
return user
async def assert_project_access(project_id: str, user: User) -> None:
project = await load_project(project_id)
if not project or project.tenant_id != user.tenant_id:
raise HTTPException(status_code=404, detail="Project not found")
@app.get("/projects/{project_id}")
async def read_project(
project_id: str,
user: Annotated[User, Security(get_current_user, scopes=["projects:read"])],
):
await assert_project_access(project_id, user)
return await serialize_project(project_id)
The important line is not the OAuth2 declaration. It is the object-level check. A route protected by Security(get_current_user) can still leak data if project_id is attacker-controlled and the handler does not verify tenant ownership. FastAPI makes it easy to compose the check. It does not write the policy.
Starlette CVEs matter to FastAPI teams
FastAPI applications commonly depend on Starlette behavior. Starlette’s own middleware documentation includes TrustedHostMiddleware, which enforces correctly set Anfitrión headers to guard against Host header attacks, and HTTPSRedirectMiddleware, which redirects incoming HTTP or WebSocket requests to secure schemes. (Starlette)
CVE-2024-47874 was a Starlette denial-of-service vulnerability involving multipart/form-data. The GitHub advisory says the issue affected applications built with Starlette or FastAPI that accept form requests, and rated it high with a CVSS score of 8.7. NVD describes the risk as large multipart form fields causing excessive memory allocation and making a service practically unusable even when reasonable reverse-proxy request-size limits are present. Starlette 0.40.0 fixed the issue. (GitHub)
CVE-2026-48710, also known as BadHost, is especially relevant to FastAPI because it lives in Starlette URL reconstruction behavior. Penligent’s analysis describes it as a Host header parsing flaw in Starlette before 1.0.1 that could make request.url.path differ from the path used for routing when a malformed Anfitrión header reached the application. The article correctly narrows the risk: it is not universal remote code execution and not proof that every FastAPI app is exploitable, but it becomes dangerous when application code makes authorization, allowlist, blocklist, tenant routing, SSRF guard, or admin protection decisions from request.url o request.url.path. (Penligente)
A vulnerable authorization pattern looks like this:
@app.middleware("http")
async def fragile_path_gate(request, call_next):
# Dangerous pattern if request.url.path can diverge from the routed path.
if request.url.path.startswith("/admin"):
user = await authenticate(request)
if not user or not user.is_admin:
return JSONResponse({"detail": "Forbidden"}, status_code=403)
return await call_next(request)
A safer pattern avoids reconstructing security decisions from a mutable URL view. It attaches authorization checks directly to protected routes or routers, uses trusted-host middleware, patches Starlette, and rejects malformed inputs as early as possible:
admin_router = APIRouter(
prefix="/admin",
dependencies=[Security(require_admin_user, scopes=["admin"])],
)
@admin_router.get("/reports")
async def admin_reports():
return await load_admin_reports()
app.include_router(admin_router)
The broader lesson is simple: middleware is useful, but path-based authorization in middleware is fragile when routing, URL reconstruction, proxies, and host parsing do not share exactly the same interpretation.
SQL injection, ORM safety, and dynamic query traps
Django’s ORM, SQLAlchemy, and SQLModel all encourage parameterized queries. That is a major improvement over string-concatenated SQL. But SQL injection in Python web framework applications rarely appears only as "... WHERE id = " + request.args["id"] anymore. It often appears in dynamic field names, dynamic order clauses, raw filters, JSON-to-query translators, report builders, admin search, GraphQL resolvers, tenant filters, analytics endpoints, and “advanced search” forms.
The safest mindset is to separate values from structure. User-controlled values can usually be parameterized. User-controlled SQL structure must be allowlisted.
Dangerous Flask or FastAPI SQLAlchemy pattern:
# Dangerous: user controls SQL structure.
sort = request.args.get("sort", "created_at")
direction = request.args.get("direction", "desc")
rows = db.execute(
text(f"SELECT * FROM invoices ORDER BY {sort} {direction}")
).fetchall()
Safer pattern:
ALLOWED_SORTS = {
"created_at": Invoice.created_at,
"total": Invoice.total,
"status": Invoice.status,
}
ALLOWED_DIRECTIONS = {"asc", "desc"}
sort_key = request.args.get("sort", "created_at")
direction = request.args.get("direction", "desc")
if sort_key not in ALLOWED_SORTS or direction not in ALLOWED_DIRECTIONS:
raise BadRequest("Invalid sort")
column = ALLOWED_SORTS[sort_key]
query = select(Invoice).order_by(column.asc() if direction == "asc" else column.desc())
rows = db.session.execute(query).scalars().all()
Dangerous Django pattern:
# Dangerous when untrusted data controls raw SQL or dynamic ORM internals.
field = request.GET.get("field")
value = request.GET.get("value")
users = User.objects.extra(
where=[f"{field} = '{value}'"]
)
Safer Django pattern:
ALLOWED_FILTERS = {
"email": "email__iexact",
"status": "status",
"created_after": "created_at__gte",
}
field = request.GET.get("field")
value = request.GET.get("value")
lookup = ALLOWED_FILTERS.get(field)
if not lookup:
raise SuspiciousOperation("Unsupported filter")
users = User.objects.filter(**{lookup: value})
Even the safer Django example deserves review if the mapping grows complex. Allowlisting is only safe when it maps user choices to developer-controlled ORM expressions. Do not accept arbitrary nested dictionaries from a client and pass them into filter(**payload) just because it feels elegant.
| Risk source | Why it is dangerous | Safer approach | Test idea |
|---|---|---|---|
Dynamic ORDENAR POR | Query parameters become SQL structure | Map user choices to known ORM columns | Try sort=id desc--, sort=1, unusual Unicode |
| Dynamic filter dictionaries | Client controls lookup path or connector | Allowlist lookup names and validate value types | Try nested objects, _connector, relation traversals |
| Raw SQL reports | Report builder bypasses ORM safety | Parameterize values and hardcode SQL structure | Review every en bruto, texto, execute, extra, RawSQL |
| Tenant filters | Missing tenant_id condition leaks cross-tenant rows | Enforce tenant scope in service/repository layer | Change object IDs across accounts |
| Search syntax | Custom query language maps to SQL | Parse to an AST and compile only supported operations | Fuzz operators, quotes, comments, wildcards |
| GraphQL resolvers | Flexible client selection hides auth gaps | Apply field and object authorization per resolver | Query nested objects from another tenant |
CVE-2025-64459 reinforces this point. Django’s normal queryset parameterization is strong, but a crafted dictionary expanded into an internal connector argument created a SQL injection path. Security reviews should flag any endpoint where client-supplied JSON is transformed into ORM keyword arguments, especially if the code supports arbitrary filters, relation paths, annotations, aliases, or search operators. (Django Project)
XSS, autoescaping, and browser context
Django and Flask both rely heavily on template escaping. Django’s docs state that its templates escape specific characters that are dangerous in HTML, while also warning that the protection is not foolproof across every context. The official example of an unquoted attribute shows why: HTML escaping alone may not save a template when untrusted data is placed in a syntactically unsafe location. (Django Project)
Jinja templates, used commonly with Flask and sometimes with FastAPI/Starlette apps, also escape by context when configured properly. But developers still create XSS by marking strings safe, rendering user-controlled HTML, building JavaScript snippets with string interpolation, embedding JSON into script tags incorrectly, or placing user data into CSS and URL contexts without the right encoding.
Dangerous template pattern:
<!-- Dangerous: unquoted attribute and user-controlled class value -->
<div class="{{" profile.theme_class }}>
{{ profile.display_name }}
</div>
Safer pattern:
<div class="{{ profile.theme_class|e }}">
{{ profile.display_name }}
</div>
Better still, do not let arbitrary class names come from a user profile. Map user choices to known classes:
ALLOWED_THEMES = {
"light": "theme-light",
"dark": "theme-dark",
"contrast": "theme-contrast",
}
theme_class = ALLOWED_THEMES.get(user.theme, "theme-light")
Rendering user-generated rich text is a separate problem. Escaping everything prevents HTML rendering, but allowing HTML creates XSS risk. The safe pattern is to sanitize with a library designed for HTML sanitization, enforce an allowlist of tags and attributes, strip event handlers, block dangerous protocols such as javascript:, and serve untrusted uploads from a separate domain when possible.
Content Security Policy helps, but it is not a replacement for output encoding. Django’s security documentation describes CSP as an important and recommended layer while also warning that policy behavior varies across browsers and complex deployments require testing. A CSP can reduce the blast radius of XSS, but a weak policy with unsafe-inline, broad script sources, or JSONP-compatible endpoints may provide much less protection than expected. (Django Project)
CSRF, CORS, cookies, and bearer tokens
CSRF is not a Django-only topic. It is a browser credential topic. If a browser automatically sends credentials such as cookies to a site, another site may be able to trigger a state-changing request unless CSRF protections are in place. Django ships CSRF middleware and explains that it checks for a user-specific secret in POST requests; it also warns that disabling the module globally or marking views csrf_exempt should be done only with care. (Django Project)
Flask does not impose one CSRF model on every app. Many Flask applications use Flask-WTF or a custom CSRF approach. That flexibility is reasonable for APIs, but it creates a common bug: an app starts with server-rendered forms and cookie sessions, adds JSON endpoints over time, and never defines a consistent CSRF policy.
FastAPI applications often use bearer tokens in the Authorization header. FastAPI’s tutorial describes the client sending Authorization: Bearer <token> for protected endpoints and notes that tokens usually expire after some time to reduce the risk if stolen. (FastAPI)
Bearer tokens reduce CSRF risk when they are stored outside cookies and manually attached by JavaScript, but they introduce different risks: XSS can steal tokens from local storage, long-lived tokens become portable secrets, overly broad tokens turn small endpoints into large blast-radius bugs, and refresh-token rotation becomes a key part of the design. If a FastAPI app uses cookies for auth, CSRF is still relevant.
CORS is often misunderstood as an API access control system. It is not. CORS tells browsers whether frontend JavaScript from another origin may read responses. It does not stop curl, mobile apps, server-side requests, or malicious clients from calling an API directly. FastAPI’s documentation notes that wildcard origins exclude credentialed requests and recommends explicit allowed origins when credentials are involved. (FastAPI)
A safer CORS configuration is narrow:
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://app.example.com",
"https://admin.example.com",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token"],
)
A risky configuration is broad:
# Risky for most production APIs.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Some frameworks or middleware will reject invalid wildcard-plus-credentials combinations, but the security smell remains: if credentials are involved, origins should be explicit and reviewed.
Host headers, proxy headers, URL generation, and cache poisoning

Host header issues are easy to underestimate because browsers normally set Anfitrión correctly. Attackers are not limited to browser behavior. They can send raw HTTP requests, interact through proxies, target password reset flows, poison caches, or exploit internal services that trust reconstructed URLs.
Django utiliza Anfitrión header in some URL construction paths, but validates hosts against ALLOWED_HOSTS when get_host() is used. Its documentation warns that directly accessing the Anfitrión header through request.META bypasses that protection, and it explains that fake host values can contribute to CSRF, cache poisoning, and poisoned email links. (Django Project)
Flask’s documentation makes the same deployment point from a different angle: by default, the app does not know which hosts are allowed, so deployed applications should set TRUSTED_HOSTS to restrict valid Anfitrión values. (Flask)
Starlette provides TrustedHostMiddleware for the same class of problem. Its documentation says the middleware enforces correctly set Anfitrión headers to guard against HTTP Host Header attacks and supports explicit allowed hosts and wildcard subdomains. (Starlette)
A simple verification command can catch obvious host validation gaps:
curl -i https://example.com/ \
-H 'Host: attacker.example'
Expected behavior depends on the stack, but a hardened application should not generate password reset links, absolute redirects, canonical URLs, cache keys, or tenant decisions from an untrusted host.
Proxy headers deserve the same suspicion. If an app trusts X-Forwarded-Proto, X-Forwarded-Host, X-Real-IP, or custom identity headers, the reverse proxy must strip attacker-supplied versions and set its own canonical values. CVE-2026-3902 in Django’s ASGI handling demonstrates how subtle header-name normalization differences can undermine assumptions about what the proxy stripped before traffic reached the app. (Django Project)
Cache poisoning and cache leakage are related but distinct. Poisoning means an attacker causes a cache to store attacker-influenced content for other users. Leakage means private content is cached and served to the wrong user. Django’s CVE-2026-35193 and Flask’s Vary: Cookie advisories both show how headers such as Authorization, Cookiey Vary become security-critical when shared caches sit in front of framework code. (NVD)
| Boundary | Modo de fallo | Detection signal | Mitigación |
|---|---|---|---|
Anfitrión header | Poisoned links, tenant confusion, cache poisoning | Absolute URLs reflect attacker host | Django ALLOWED_HOSTS, Flask TRUSTED_HOSTS, Starlette TrustedHostMiddleware |
| Proxy scheme | App thinks HTTPS request is HTTP, or trusts spoofed HTTPS | Secure redirects loop or disappear, CSRF origin checks fail | Configure proxy header trust only from known proxy |
| Authorization cache | Private response served to unauthenticated user | Same URL returns user-specific data after cache warmup | Cache-Control: private, no-store where needed, correct Vary |
| Cookie cache | Session-setting response reused | Set-Cookie appears in cached public response | Correct Vary: Cookie, avoid caching session-mutating public pages |
| Reconstructed URL path | Middleware checks a path different from routed endpoint | Middleware and endpoint disagree on route identity | Route-level auth, patched Starlette, reject malformed hosts |
File upload, multipart parsing, and resource exhaustion
File upload security combines several problems: size limits, parser behavior, path handling, content sniffing, malware scanning, media processing, storage permissions, and download behavior. Django’s security documentation recommends limiting uploads at the web server configuration level and not relying solely on Django’s memory upload settings. It also warns that user-uploaded files should be handled carefully and, where possible, served from separate storage or a CDN rather than directly from the application runtime. (Django Project)
A typical Nginx layer might enforce a size limit before the request reaches Python:
server {
server_name example.com;
client_max_body_size 10m;
location / {
proxy_pass http://django_or_fastapi_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Application-level limits are still needed. A reverse proxy may limit total request size, but the parser may still be vulnerable to memory amplification or per-field behavior. CVE-2024-47874 in Starlette is a clear example: the advisory describes a multipart/form-data denial-of-service issue affecting Starlette or FastAPI applications accepting form requests, with availability impact rated high. (GitHub)
File paths require allowlisting and normalization. Do not join user input into paths and assume a helper makes every operating system safe. The Werkzeug CVE-2024-49766 case shows that safe path handling can depend on Python version and Windows path semantics. (NVD)
A safer download pattern uses database IDs, not file paths:
@app.get("/files/{file_id}")
async def download_file(file_id: str, user: User = Depends(get_current_user)):
file_record = await load_file_record(file_id)
if not file_record or file_record.tenant_id != user.tenant_id:
raise HTTPException(status_code=404)
return FileResponse(
path=file_record.storage_path,
filename=file_record.original_filename,
media_type=file_record.media_type,
)
The handler never accepts ../../etc/passwd, never lets the user choose a filesystem path, and performs object-level authorization before returning the file.
Authentication is not authorization, and authorization is not ownership
Authentication answers “Who are you?” Authorization answers “What are you allowed to do?” Object-level authorization answers “Are you allowed to do this action to this specific object?” Most serious Python web framework bugs in APIs fall into the third category.
Django permissions, Flask decorators, and FastAPI dependencies can all express coarse rules. They can require login. They can require a role. They can require a scope. But they cannot automatically know whether invoice inv_9Yf belongs to tenant acme, whether user 123 is allowed to rotate user 456’s API key, or whether a support engineer’s temporary access expired yesterday.
The following FastAPI route is authenticated but vulnerable:
@app.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: str, user: User = Depends(get_current_user)):
invoice = await db.invoices.get(invoice_id)
return invoice
A safe route checks ownership or a specific delegated permission:
@app.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: str, user: User = Depends(get_current_user)):
invoice = await db.invoices.get(invoice_id)
if not invoice or invoice.tenant_id != user.tenant_id:
raise HTTPException(status_code=404, detail="Invoice not found")
if "billing:read" not in user.scopes:
raise HTTPException(status_code=403, detail="Forbidden")
return invoice
The same idea applies to Django:
@login_required
def invoice_detail(request, invoice_id):
invoice = get_object_or_404(
Invoice,
id=invoice_id,
tenant_id=request.user.tenant_id,
)
if not request.user.has_perm("billing.view_invoice", invoice):
raise PermissionDenied
return render(request, "billing/invoice.html", {"invoice": invoice})
And Flask:
@app.get("/api/invoices/<invoice_id>")
@login_required
def invoice_detail(invoice_id):
invoice = Invoice.query.filter_by(
id=invoice_id,
tenant_id=current_user.tenant_id,
).one_or_none()
if invoice is None:
abort(404)
if not current_user.can("billing:read"):
abort(403)
return jsonify(invoice.to_dict())
A recurring mistake is returning 403 for unauthorized objects and 404 for nonexistent objects. That can reveal which IDs exist. Many applications intentionally return 404 for objects outside the caller’s tenant, then use 403 only when the object is visible but the action is disallowed. The exact behavior depends on product requirements, but it should be intentional.
Debug mode and developer conveniences
Debug mode is one of the oldest and still most damaging deployment mistakes. Django’s DEBUG=True can expose sensitive settings and stack traces. Flask’s debugger is powerful and should not be internet-exposed. FastAPI’s interactive docs are useful, but exposing /docs, /redocy /openapi.json on a public production API may disclose internal routes, schemas, field names, and auth flows that make testing easier for attackers.
None of these tools are bad. The problem is environment drift. A staging profile becomes production. A temporary debug flag stays on. An internal admin route is accidentally added to the public router. A CI/CD variable defaults to development mode when a secret is missing.
Production checks should include:
# Check common framework documentation endpoints.
curl -i https://api.example.com/docs
curl -i https://api.example.com/redoc
curl -i https://api.example.com/openapi.json
# Check common debug leakage paths.
curl -i https://example.com/__debugger__
curl -i https://example.com/admin/
curl -i https://example.com/.env
A 200 response is not always a vulnerability. An intentionally public API may expose OpenAPI docs. A public Django admin login page may be acceptable if protected by SSO, MFA, IP allowlisting, rate limits, logging, and strong operational controls. But every exposed developer convenience should have a reason.
Dependency security in the Python web stack
A Python web framework application is not just django, flasko fastapi en requirements.txt. It may include djangorestframework, django-cors-headers, celery, redis, gunicorn, uvicornio, werkzeug, jinja2, itsdangerous, starlette, pydantic, sqlalchemy, python-multipart, flask-login, flask-wtf, flask-cors, cryptography, and dozens of internal packages.
That means patching strategy must cover direct and transitive dependencies:
python -m pip install --upgrade pip
# Generate a dependency tree.
python -m pip install pipdeptree
pipdeptree
# Audit installed dependencies for known vulnerabilities.
python -m pip install pip-audit
pip-audit
# Show outdated packages.
python -m pip list --outdated
For production systems, pin dependencies and update them intentionally:
# requirements.in
Django>=5.2,<5.3
gunicorn>=23,<24
psycopg[binary]>=3.2,<3.3
django-cors-headers>=4.7,<5
Then compile a lockfile with hashes:
python -m pip install pip-tools
pip-compile --generate-hashes requirements.in
The goal is not to blindly upgrade everything every day. The goal is to know what is deployed, know what is vulnerable, test updates quickly, and avoid running unsupported framework branches. Django’s security archive notes that affected-version lists usually include only stable, security-supported releases at disclosure time; older unsupported series may also be affected but not evaluated. (Django Project)
A practical testing workflow for Django, Flask, and FastAPI apps
A useful Python web framework security review starts broad, then narrows. It does not begin with payload spraying. It begins with mapping trust boundaries.
Start with versions and exposed surfaces:
# Fingerprint headers and obvious framework behavior.
curl -i https://example.com/
# Check security headers.
curl -I https://example.com/ | sed -n \
-e '/strict-transport-security/Ip' \
-e '/content-security-policy/Ip' \
-e '/x-frame-options/Ip' \
-e '/x-content-type-options/Ip' \
-e '/referrer-policy/Ip'
# Check OpenAPI exposure.
curl -i https://api.example.com/openapi.json
Then check host and proxy behavior:
curl -i https://example.com/ \
-H 'Host: attacker.example'
curl -i https://example.com/password-reset \
-H 'Host: attacker.example'
curl -i https://example.com/ \
-H 'X-Forwarded-Host: attacker.example' \
-H 'X-Forwarded-Proto: http'
Then check authentication and authorization, especially object IDs:
# With user A token
curl -s https://api.example.com/projects/proj_A \
-H "Authorization: Bearer $USER_A_TOKEN"
# Try user B's object with user A token
curl -s https://api.example.com/projects/proj_B \
-H "Authorization: Bearer $USER_A_TOKEN"
Then test CORS:
curl -i https://api.example.com/account \
-H "Origin: https://evil.example" \
-H "Authorization: Bearer $TOKEN"
Busque Access-Control-Allow-Origin: https://evil.example combinado con Access-Control-Allow-Credentials: true, or dynamic reflection that accepts untrusted origins.
Then review CSRF behavior for cookie-authenticated actions:
curl -i -X POST https://example.com/settings/email \
-H "Content-Type: application/x-www-form-urlencoded" \
-b "session=$SESSION_COOKIE" \
--data "email=attacker@example.com"
A protected Django view should reject missing or invalid CSRF tokens unless intentionally exempt. A Flask or FastAPI cookie-authenticated app should have an equivalent CSRF model.
Then review upload and parser boundaries:
# Safe size-boundary test, not a destructive DoS test.
python - <<'PY'
from pathlib import Path
Path("test_upload.bin").write_bytes(b"A" * 1024 * 1024)
PY
curl -i https://example.com/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@test_upload.bin"
Do not run high-volume or resource-exhaustion tests against systems without explicit authorization and agreed safety limits. For authorized work, coordinate thresholds, windows, monitoring, and rollback paths.
For teams that need repeatable authorized testing across Django, Flask, and FastAPI applications, an agentic workflow can help connect reconnaissance, route discovery, auth-state testing, evidence capture, retesting, and report generation. Penligent’s public site positions the product as an AI-powered penetration testing tool for authorized testing, and that distinction matters: automation is useful only when scope, permission, evidence, and operator control are explicit. (Penligente)
The most valuable automation is not blind payload volume. It is controlled verification: identify an endpoint, preserve session state, mutate object IDs, compare responses across roles, record evidence, retest after a fix, and produce a report that engineering can reproduce.
Framework-specific hardening checklist
A checklist should not replace threat modeling, but it helps catch repeatable mistakes.
| Zona | Django | Flask | FastAPI |
|---|---|---|---|
| Production mode | DEBUG=False | DEBUG=False, no Werkzeug debugger | Disable public docs if not intended |
| Host validation | ALLOWED_HOSTS y get_host() | TRUSTED_HOSTS | TrustedHostMiddleware |
| HTTPS | SECURE_SSL_REDIRECT, secure cookies, HSTS | Reverse proxy redirect, secure cookies, HSTS headers | HTTPS redirect at proxy or middleware |
| CSRF | Keep middleware enabled, avoid broad csrf_exempt | Use Flask-WTF or equivalent for cookie flows | Required when browser cookies authenticate state-changing requests |
| CORS | Use explicit origins when needed | Avoid broad Flask-CORS defaults | Explicit origins, methods, headers |
| SQL | Avoid unsafe en bruto, extra, dynamic aliases | Avoid string SQL and untrusted sort/filter structure | Avoid interpolated SQLAlchemy text queries |
| XSS | Evite safe, mark_safe, unquoted attrs | Evite Markup, ` | safe`, user HTML |
| Uploads | Limit at proxy and app, separate storage | MAX_CONTENT_LENGTH, safe filenames, no user paths | Proxy limits, parser updates, safe storage |
| Cache | Vary on auth/session, private/no-store for sensitive pages | Avoid caching session-specific responses | Ensure API gateway cache keys include auth where appropriate |
| Dependencies | Track Django support branch and advisories | Track Flask, Werkzeug, Jinja, extensions | Track FastAPI, Starlette, Pydantic, multipart parser |
Choosing the right Python web framework for safer systems
Django is often the safer default for teams that need a full web application quickly and want a mature set of built-in protections. It is especially strong for admin-heavy apps, content systems, business workflows, and products where the framework’s integrated ORM, forms, templates, auth, and middleware reduce the number of security decisions the team must make from scratch. The tradeoff is that Django’s abstraction layer can become complex, and advanced features need careful review.
Flask is a good choice when the team values a small core, custom architecture, and explicit control. It is not inherently insecure. The risk is that teams treat “minimal” as “production-ready by default.” A secure Flask service needs a standard baseline for CSRF, cookies, CORS, host validation, security headers, auth, rate limiting, file handling, logging, and dependency updates.
FastAPI is strong for modern APIs, async services, ML platforms, and internal developer platforms. It gives teams excellent request modeling and documentation. It also makes it easy to build many routes quickly, which can multiply authorization mistakes. FastAPI teams should put extra weight on object-level authorization, OpenAPI exposure, CORS, bearer-token scope design, Starlette patching, and trusted-host/proxy behavior.
A team with weak security process should not choose Flask just because it is simple. A team building a public multi-tenant API should not choose FastAPI and assume Pydantic solved authorization. A team using Django should not assume built-in security eliminates review of raw SQL, caching, uploads, and custom middleware.
PREGUNTAS FRECUENTES
Is Django safer than Flask or FastAPI?
- Django usually provides the strongest security defaults because it includes CSRF protection, ORM parameterization, template escaping, clickjacking protection, host validation, sessions, and production security settings in one ecosystem.
- Flask can be just as secure when the team deliberately adds and configures CSRF protection, host validation, cookie settings, headers, auth, rate limits, and safe database access.
- FastAPI is strong for API development, but API security depends heavily on object-level authorization, token design, CORS configuration, dependency updates, and Starlette middleware behavior.
- The safest choice depends on team discipline, deployment model, dependency maintenance, and how much custom security code the application needs.
Can a Python web framework prevent SQL injection automatically?
- Frameworks and ORMs reduce SQL injection risk by parameterizing values.
- They do not automatically protect dynamic SQL structure such as column names, sort directions, aliases, raw fragments, or arbitrary filter dictionaries.
- Django CVE-2025-64459 shows that even mature ORM features can have edge-case SQL injection risk when advanced dynamic query construction is involved. (Django Project)
- The safest pattern is to allowlist fields, operators, and sort keys, then pass only values as parameters.
Does FastAPI request validation stop malicious input?
- FastAPI request validation checks shape, type, and declared constraints through Pydantic models.
- It does not decide whether the user is allowed to access a specific object.
- It does not sanitize data for every output context, such as HTML, JavaScript, SQL structure, shell commands, file paths, or downstream HTTP requests.
- Treat validation as the first gate, then apply authorization, business rules, output encoding, and downstream sink-specific safety.
Do Flask apps need CSRF protection?
- Flask apps need CSRF protection when browser cookies authenticate state-changing requests.
- APIs that use bearer tokens in the
Authorizationheader may not have the same CSRF exposure, but they still need XSS protection, token storage decisions, CORS controls, and scope enforcement. - Flask’s flexibility means the CSRF model must be chosen and documented by the application team.
- A common production mistake is adding JSON endpoints to a cookie-authenticated Flask app without extending CSRF protection to those endpoints.
Why do Host header bugs matter in Django, Flask, and FastAPI?
- Applications often use host and URL data to generate password reset links, redirects, canonical URLs, tenant routing, cache keys, and security decisions.
- Django warns that fake host values can contribute to CSRF, cache poisoning, and poisoned email links, and that direct
request.METAaccess can bypassALLOWED_HOSTSvalidation. (Django Project) - Flask recommends
TRUSTED_HOSTSbecause deployed apps otherwise may accept attacker-supplied host values. (Flask) - Starlette provides
TrustedHostMiddleware, and CVE-2026-48710 shows how malformed Host handling can become serious when app code uses reconstructed URL paths for security decisions. (Starlette)
What should teams test first in a Python web framework security review?
- Verify framework and dependency versions against known advisories.
- Confirm production mode, host validation, HTTPS, secure cookies, and security headers.
- Test object-level authorization by changing IDs across users and tenants.
- Review CORS and CSRF behavior based on whether the app uses cookies or bearer tokens.
- Audit raw SQL, dynamic filters, file uploads, cache headers, proxy headers, and exposed developer tools.
- Confirm that logs capture failed authorization, suspicious header values, upload rejections, and high-risk admin actions.
How should bug bounty hunters approach Python framework targets?
- Identify the framework, but do not stop at fingerprinting.
- Focus on routes where authenticated users can act on object IDs, upload files, trigger background jobs, generate exports, invite users, or change billing/security settings.
- Test framework-specific mistakes: Django
csrf_exemptviews and admin exposure, Flask missing CSRF or host validation, FastAPI overbroad CORS and OpenAPI exposure. - Treat dependency CVEs as starting points, then prove whether the target actually uses the vulnerable code path.
- Avoid destructive resource-exhaustion tests unless the program explicitly permits them and provides safe limits.
Closing judgment
Python web framework security is strongest when teams respect both sides of the contract. Django, Flask, and FastAPI each provide useful security primitives, but primitives are not policies. The framework can escape a template variable, parameterize a value, parse a request body, reject a bad host, or expose a security dependency. It cannot know who owns a project, whether a cache should vary on a token, whether a proxy stripped the right header, whether a report export contains another tenant’s data, or whether a file upload should become executable content.
The practical priority is clear: keep supported framework versions patched, lock down host and proxy behavior, define cookie and token rules intentionally, make object-level authorization unavoidable, avoid dynamic SQL structure, limit uploads at multiple layers, and test the application the way it is actually deployed. The safest Python web framework is the one whose boundaries your team understands well enough to verify.

