Flask Security Bugs AI Coders Write — and BrassCoders Catches
Four Flask security patterns AI coding assistants get wrong: missing CSRF protection, debug=True in production, hardcoded secret_key, and SQL injection via f-string. Verified Bandit rule IDs included.
Flask has no built-in CSRF protection, no production-mode enforcement, and no secret-management scaffolding. None. AI coding assistants trained on Flask tutorials fill in those gaps with the shortest working code — which is usually the insecure code. Four patterns appear repeatedly across AI-generated Flask codebases. BrassCoders, the bug scanner for AI coders, catches three with verified Bandit rule IDs and flags the fourth through its auth-pattern analyzer.
Why Flask Needs Extra Attention From AI
BrassCoders was designed around a specific structural observation: AI assistants produce code that is correct by syntax and wrong by design, and Flask’s minimalist API makes this failure mode worse than in batteries-included frameworks.
Django ships with CsrfViewMiddleware enabled in MIDDLEWARE by default. FastAPI includes Pydantic validation and explicit dependency injection that makes missing auth visible. Flask ships with routing and request. Everything else — CSRF tokens, session-secret management, production-mode enforcement — is an explicit import the developer adds. An AI assistant generating a Flask route sees no API surface that signals what’s missing, so it produces the route without the protection layer. The code runs. The review passes. The vulnerability ships.
Four patterns follow directly from this structural gap. Three have Bandit rule IDs; all four leave BrassCoders findings in the YAML output.
Missing CSRF: The Route Flask Doesn’t Protect
BrassCoders surfaces missing CSRF protection on Flask POST routes through its auth-pattern analyzer, which flags @app.route decorators with methods=['POST'] where no rate-limiting or protection middleware pattern appears in the surrounding code.
Flask provides no CSRF middleware. The recommended solution is Flask-WTF, which adds form-level CSRF tokens backed by the app’s SECRET_KEY. AI assistants generating Flask form handlers rarely include the Flask-WTF import. Nothing in the Flask API surface tells the AI it’s missing — request.form is callable, the POST works, and the route passes all functional tests.
Here’s the pattern AI assistants write:
# routes.py — AI-generated form handler
from flask import Flask, request, redirect, url_for
app = Flask(__name__)
app.secret_key = "dev-secret"
@app.route('/submit', methods=['POST'])
def submit_form():
username = request.form.get('username')
email = request.form.get('email')
# Process the form submission
save_user(username, email)
return redirect(url_for('success'))
Any page on any origin can POST to this endpoint. An attacker hosts a form that submits to your /submit with a logged-in user’s session cookie attached. The server processes it. OWASP CSRF is listed under OWASP Top 10 A01:2021 as broken access control. The fix adds Flask-WTF and a token check:
from flask_wtf import FlaskForm
from wtforms import StringField, EmailField
from wtforms.validators import DataRequired, Email
class SubmitForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
email = EmailField('Email', validators=[DataRequired(), Email()])
@app.route('/submit', methods=['GET', 'POST'])
def submit_form():
form = SubmitForm()
if form.validate_on_submit():
save_user(form.username.data, form.email.data)
return redirect(url_for('success'))
return render_template('submit.html', form=form)
validate_on_submit() checks the CSRF token automatically. No token, no processing.
Debug Mode in Production: One Flag, High Risk
BrassCoders detects app.run(debug=True) via Bandit rule B201 (flask_debug_true), which fires at HIGH severity and maps to CWE-94 (Code Injection). The rule fires on any call to app.run() where debug=True is passed as a keyword argument.
The danger: Flask’s debug mode activates the Werkzeug interactive debugger. When an unhandled exception fires, the debugger opens in the browser and lets the user evaluate arbitrary Python expressions on the server. That’s useful locally. In production, it gives any visitor with a network path to an exception page a Python REPL running as your server process.
AI assistants copy this pattern from every Flask quickstart tutorial ever written:
# app.py — AI-generated entry point
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
return 'Hello, World!'
if __name__ == '__main__':
app.run(debug=True) # B201: HIGH severity
BrassCoders emits this finding:
- rule_id: B201
severity: HIGH
confidence: MEDIUM
file: app.py
line: 9
message: >
A Flask app appears to be run with debug=True, which exposes the
Werkzeug debugger and allows the execution of arbitrary code.
cwe: CWE-94
scanner: bandit
The fix reads the mode from an environment variable, never from a literal:
import os
if __name__ == '__main__':
debug_mode = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
app.run(debug=debug_mode)
Production environments set FLASK_DEBUG=false (or omit it entirely, defaulting to false). Local dev sets FLASK_DEBUG=true. The literal never reaches source control.
The Hardcoded Secret Key
BrassCoders catches hardcoded secret_key values through two independent scanner paths: Bandit rule B105 (hardcoded_password_string) and the custom auth-pattern analyzer, which matches the regex secret\s*=\s*["\'][^"\']+["\'].
Flask’s session system signs cookies with app.secret_key. Without a secret key, Flask raises a RuntimeError. With a hardcoded one, the secret is in version control, shared with every developer, and visible in any repository exposure incident. Session forgery and cookie tampering follow directly.
AI assistants write this because tutorials write this:
app = Flask(__name__)
app.secret_key = "my-super-secret-key-12345" # B105: LOW, hardcoded_password_string
Bandit flags the assignment. The auth-pattern analyzer flags it as a hardcoded secret. BrassCoders surfaces both signals in the YAML output.
- rule_id: B105
severity: LOW
confidence: MEDIUM
file: app.py
line: 2
message: >
Possible hardcoded password: 'my-super-secret-key-12345'
cwe: CWE-259
scanner: bandit
The right approach generates the secret at deploy time and reads it from the environment:
import os
app = Flask(__name__)
app.secret_key = os.environ['FLASK_SECRET_KEY']
If FLASK_SECRET_KEY isn’t set, the app raises KeyError on startup — an explicit failure that forces the deployment to provide a real secret rather than silently using a placeholder. For key generation, Python’s secrets module produces a cryptographically strong key: python3 -c "import secrets; print(secrets.token_hex(32))".
SQL Injection in Flask Route Handlers
BrassCoders detects SQL injection in Flask route handlers via Bandit rule B608 (hardcoded_sql_expressions), which fires at MEDIUM severity and maps to CWE-89 (SQL Injection). B608 triggers on any string detected as a SQL expression where user-controlled values are interpolated via f-string, %s substitution, or .format().
Flask has no ORM layer. Django has the ORM’s parameterized .filter(). FastAPI commonly pairs with SQLAlchemy. Flask routes are raw Python, and the fastest way to query a database in raw Python is an f-string:
# routes.py — AI-generated search endpoint
import sqlite3
from flask import Flask, request
app = Flask(__name__)
@app.route('/search')
def search():
name = request.args.get('name')
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
# AI assistant generated this. B608 fires here.
cursor.execute(f"SELECT * FROM users WHERE name = '{name}'")
results = cursor.fetchall()
return {'users': results}
name is attacker-controlled. Passing ' OR '1'='1 dumps the table. BrassCoders output:
- rule_id: B608
severity: MEDIUM
confidence: MEDIUM
file: routes.py
line: 11
message: >
Possible SQL injection vector through string-based query construction.
cwe: CWE-89
scanner: bandit
The fix uses parameterized queries regardless of the database library:
@app.route('/search')
def search():
name = request.args.get('name')
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE name = ?", (name,))
results = cursor.fetchall()
return {'users': results}
The ? placeholder is handled by the database driver. No concatenation, no injection surface. SQLAlchemy’s ORM and text() with bindparams provide the same guarantee at a higher abstraction level.
How to Run the Scan
BrassCoders 2.0.10 ships all four detection rules in the OSS core — Bandit (B201, B105, B608), detect-secrets, and the auth-pattern analyzer — Apache 2.0 licensed, no account required.
pip install brasscoders
brasscoders scan /path/to/your/flask/project
The scan emits a YAML file at .brass/ai_instructions.yaml with findings keyed by severity, rule ID, file path, and line number. Feed it to Claude Code or Cursor as context; the AI triage layer then reads the findings, verifies them against source, and produces fix-ready advice. BrassCoders is the pattern reporter. The AI assistant is the context-aware triage layer. The division is load-bearing: BrassCoders reports what the patterns say; the AI decides what the patterns mean in your specific codebase.
For a purely offline scan — no network calls, no gateway traffic:
brasscoders --offline scan /path/to/your/flask/project
Offline mode skips the Paid-plan enrichment pass and runs all 12 OSS-core scanners locally. The four patterns described above fire in offline mode. No paid subscription needed to catch B201, B105, B608, or missing CSRF.
Frequently Asked Questions
Does Flask have built-in CSRF protection?
No. Flask ships no CSRF middleware. The recommended extension is Flask-WTF, which wraps WTForms and adds per-form tokens. AI coding assistants generating Flask form routes rarely import Flask-WTF because the base Flask API gives no signal that protection is missing — the route works without it.
Is running Flask with debug=True a security issue?
Yes. Bandit rule B201 flags it at HIGH severity. Flask's debug mode activates the Werkzeug interactive debugger, which lets anyone with browser access to an unhandled exception execute arbitrary Python on the server. The CWE is CWE-94: Code Injection.
Why do AI assistants hardcode Flask secret_key?
The fastest working example of a Flask session uses a literal string for app.secret_key. Tutorial code does this. Stack Overflow answers do this. AI assistants trained on that corpus reproduce it. Bandit rule B105 flags the assignment as a hardcoded password string, and BrassCoders's auth-pattern scanner adds a second signal via its hardcoded-secrets regex.
Can BrassCoders catch all Flask security issues?
No. BrassCoders is a structural pattern reporter. It catches issues that appear as patterns in source code: SQL strings with f-string interpolation, debug=True literals, hardcoded secret strings, and POST routes without visible rate-limiting patterns. Context inference — deciding whether a given pattern is actually exploitable given the runtime environment — is the job of the AI triage layer consuming the YAML output.
How do I reproduce the Flask scan?
Install BrassCoders with pip install brasscoders, then run brasscoders scan . from your Flask project root. For the OSS-core offline mode, use brasscoders --offline scan . — no account, no network calls, Apache 2.0 licensed.