Flask is simple on purpose. It gives you routing, request handling, templates, and just enough glue to build almost anything.

That flexibility is also the security problem.

Unlike Django, Flask does not wrap most dangerous operations in opinionated framework abstractions. Your app code decides how SQL is executed, how files are served, how templates are rendered, how subprocesses are invoked, and how external URLs are fetched.

That means Flask security scanning is mostly about catching dangerous Python patterns inside Flask request flows, not auditing Flask itself.

Here are 7 Flask vulnerability patterns static analysis can catch, where generic tools usually stop, and what to test on your own codebase.

TL;DR

Vulnerability patternBanditSemgrepSkylos
Raw SQL with string interpolationPartialYesYes
Server-side template injection (render_template_string)NoPartialYes (SKY-D349)
Path traversal in file download helpersNoPartialYes (SKY-D215)
Unsafe subprocess or shell executionYesYesYes
SSRF from user-controlled URLsNoYes (p/flask ssrf-requests)Yes
Hardcoded secrets or insecure configYesYesYes for SECRET_KEY and DEBUG; not the database URI password
Unsafe file upload handlingNoPartialNo for FileStorage.save

The Skylos column reflects behavior reproduced on Skylos 4.47.1 (2026-10-09) for the exact examples below. The Bandit and Semgrep cells reflect their documented rules and rulesets, checked on 2026-10-09; verify them against the versions you use.


Pattern 1: SQL injection in raw SQL and ad-hoc query helpers

Flask does not give you an ORM by default. Many apps use SQLAlchemy, psycopg, sqlite3, or internal helpers directly from request handlers.

# VULNERABLE: user input interpolated into raw SQL
from flask import Flask, request
import sqlite3

app = Flask(__name__)

@app.get("/users")
def get_user():
    email = request.args["email"]
    conn = sqlite3.connect("app.db")
    row = conn.execute(
        f"SELECT id, email FROM users WHERE email = '{email}'"
    ).fetchone()
    return {"row": row}

This is the classic Flask failure mode: request data goes straight into a query string.

# SAFE: parameterized query
row = conn.execute(
    "SELECT id, email FROM users WHERE email = ?",
    (email,),
).fetchone()

Bandit can catch some SQL string formatting patterns, but it does not understand every database wrapper.

Semgrep is stronger when the query call matches a rule it knows, especially for common SQLAlchemy and Python DB APIs.

Skylos traces taint from Flask request sources such as request.args, request.form, and request.json through to raw query execution and flags the dataflow.


Pattern 2: Server-side template injection through render_template_string

Flask makes it easy to render templates dynamically, which is exactly why teams accidentally hand user input to Jinja at runtime.

# VULNERABLE: user-controlled template string
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.post("/preview")
def preview_template():
    body = request.form["body"]
    return render_template_string(body)

If the user controls the template string, they control Jinja evaluation. That turns a harmless preview feature into a template injection surface.

Even “lightly sanitized” versions are risky:

template = "<div>" + request.form["body"] + "</div>"
return render_template_string(template)

The dangerous sink is still render_template_string.

Bandit has no check for render_template_string (plugin index), so expect no finding on either example.

Semgrep can catch direct render_template_string misuse if the dataflow is short and the ruleset includes SSTI patterns.

Skylos reports both versions as SKY-D349 (server-side template injection, CRITICAL): it follows request.form into the template source passed to render_template_string(...). The fix is to pass user input as a template variable, not as template text. Keep a security test for this sink as well.


Pattern 3: Path traversal in download and export endpoints

Flask apps often expose file downloads, exports, backups, invoices, and report generation endpoints. The risky version looks deceptively clean:

# VULNERABLE: path traversal
from flask import Flask, request, send_file
import os

app = Flask(__name__)

@app.get("/download")
def download():
    name = request.args["name"]
    return send_file(os.path.join("/srv/reports", name))

If name is ../../etc/passwd, the joined path escapes the expected directory.

The Flask-friendly safe version is to validate against an allowlist or use helpers that enforce a base directory boundary.

Bandit usually will not flag this.

Semgrep sometimes catches traversal when the path building is obvious, but framework-specific handler context is uneven.

Skylos models Flask send_file(...) as a path sink and reports this example as SKY-D215; os.path.join is not a guard. It accepts a resolved path checked against the base directory, safe_join, or basename. Test traversal payloads as well.


Pattern 4: Unsafe subprocess and shell usage in admin endpoints

Flask apps often grow operational endpoints for image processing, git sync, PDF conversion, cache warming, or debugging. That is where shell injection appears.

# VULNERABLE: shell injection from a Flask form field
from flask import Flask, request
import subprocess

app = Flask(__name__)

@app.post("/admin/reindex")
def reindex():
    target = request.form["target"]
    subprocess.run(f"python tools/reindex.py {target}", shell=True)
    return {"ok": True}

This is one of the easiest classes for static analysis to catch.

Bandit is good at direct dangerous subprocess usage like shell=True.

Semgrep is good at this too when the API usage is explicit.

Skylos reports the shell=True call as SKY-D209, and because it traces request.form["target"] into the command, also as SKY-D212 (command injection).

If your Flask app has maintenance routes, cron-trigger endpoints, or internal admin actions, scan these paths aggressively.


Pattern 5: SSRF in webhook testers and URL-fetch endpoints

Flask apps frequently fetch external resources: webhook verifiers, URL preview tools, avatar downloads, screenshot jobs, and metadata scrapers.

# VULNERABLE: user-controlled URL fetched server-side
from flask import Flask, request
import requests

app = Flask(__name__)

@app.post("/webhook/test")
def test_webhook():
    url = request.form["url"]
    response = requests.get(url, timeout=5)
    return {"status": response.status_code}

This is SSRF if the caller can point the server at internal services, cloud metadata endpoints, or localhost-only admin ports.

Bandit generally does not catch SSRF flows.

Semgrep's p/flask ruleset includes ssrf-requests, which flags Flask request data flowing into requests calls like this one.

Skylos also follows user-controlled request data into outbound HTTP sinks across common Python clients (requests, httpx, aiohttp) and reports SKY-D216.

This is one of the highest-value categories for Flask scanning because the endpoints often look like harmless utilities during review.


Pattern 6: Hardcoded secrets and insecure config defaults

Flask apps often centralize secrets and debug behavior in simple config modules. That makes secrets scanning and config scanning useful even on smaller codebases.

# VULNERABLE: hardcoded secret and insecure debug config
SECRET_KEY = "dev-secret-change-me"
DEBUG = True
SQLALCHEMY_DATABASE_URI = "postgresql://admin:password@db.internal/app"

Hardcoded credentials, debug mode defaults, and insecure cookie/session settings are straightforward static wins.

Bandit catches a meaningful slice of hardcoded secret patterns.

Semgrep catches many secret and weak-config patterns depending on the ruleset.

Skylos covers the obvious config issues in this example with --quality: SECRET_KEY as a hardcoded credential (SKY-L014) and DEBUG = True (SKY-L011). It did not report the password inside SQLALCHEMY_DATABASE_URI in our 4.47.1 run. --secrets adds provider-token detection (SKY-S101), and -a runs both. It can surface these in the same scan as your request-flow findings, which matters when Flask security problems span config plus code.


Pattern 7: Unsafe file upload handling

File upload handlers look harmless until user-controlled file names or content handling crosses into the filesystem.

# VULNERABLE: user-controlled filename written directly
from flask import Flask, request
import os

app = Flask(__name__)

UPLOAD_DIR = "/srv/uploads"

@app.post("/upload")
def upload():
    file = request.files["file"]
    file.save(os.path.join(UPLOAD_DIR, file.filename))
    return {"ok": True}

Problems here:

  • file.filename can carry traversal payloads
  • file type validation is usually missing
  • apps often save executable or user-accessible files in predictable locations

The safer version sanitizes the name, validates type/content, and keeps uploads outside any executable or public path until processing is complete.

Bandit has no check for this upload flow.

Semgrep may catch parts of the flow, especially if the sink is direct.

Current Skylos does not model this FileStorage.save(...) call as a path sink, so it does not report the exact upload example. Keep filename normalization, containment tests, and a rule for upload handlers: a free Skylos YAML rule (scaffold one with skylos rules init, copy it into ~/.skylos/rules/, and scan with --quality) or a Semgrep rule.


What Flask makes harder for scanners

Flask itself is not the problem. The challenge is that Flask apps push a lot of security-critical behavior into:

  • thin route handlers
  • helper modules
  • decorators
  • blueprint-local utilities
  • ad-hoc CLI and admin endpoints

That means a useful Flask scanner needs to do more than pattern-match dangerous functions. It needs to understand that request.args, request.form, request.files, and JSON bodies are real untrusted sources inside a request flow.

This is also why Flask apps often feel noisier to generic tools than Django apps. Django centralizes more behavior in ORM, forms, middleware, and framework conventions. Flask leaves more of the dangerous decisions to app code.


How to test Flask security scanning on your own repo

Start with a local scan:

pip install skylos
skylos . -a

Rule IDs and flags are listed in the rules reference.

Then wire it into pull requests. skylos cicd init writes a GitHub Actions pull-request gate that needs no Skylos account or API key. By default it also adds a "Skylos Cloud upload" job for pushes to your default branch, which fails until the Skylos GitHub App is installed on the repository. Without Cloud, run:

skylos cicd init --no-upload

The highest-value Flask review zones are:

  1. request handlers that touch SQL, subprocesses, files, or outbound HTTP
  2. any use of render_template_string
  3. upload and download routes
  4. config files with secrets, debug flags, or session settings
  5. internal admin endpoints that were never designed with hostile input in mind

Bottom line

Flask security scanning works best when the scanner follows request data into dangerous sinks, not when it treats a Flask app as generic Python code.

If you only scan for broad lint issues, you will miss the highest-leverage Flask risks:

  • raw SQL built from request parameters
  • render_template_string with untrusted input
  • path traversal in file routes
  • shell injection in operational endpoints
  • SSRF in utility fetchers
  • unsafe upload handling

That is the real value of framework-aware static analysis on Flask: the framework is small, but the app-level security surface is large.