SQL Injection Across Languages
SQL injection is one of those vulnerability classes that refuses to go away, no matter how much the industry talks about it. I’ve been digging into how it manifests across different languages, Python, Java, Go, and JavaScript, and the root cause is always the same: untrusted input reaches a SQL query without proper parameterization. But the way developers introduce it varies wildly depending on the framework, ORM, and idioms of each language. In this post, I want to walk through real examples across these four languages, showing both the obvious patterns that any reviewer would catch and the subtle ones that slip through code review more often than you’d expect.
Why SQL Injection Still Happens
Here’s the thing, every web framework provides parameterized query support. Every security guide says “use prepared statements.” Yet SQL injection consistently ranks in the OWASP Top 10, and from what I can tell, it’s because the vulnerable pattern is often more intuitive than the safe one. String concatenation feels natural. Format strings are convenient. And when a query gets complex enough, dynamic filters, sorting, pagination, developers reach for string building because parameterized queries feel clumsy.
The real danger isn’t the textbook SELECT * FROM users WHERE id = ' + input + ' example. What I find far more concerning is the query builder that parameterizes some inputs but concatenates others, or the ORM escape hatch that bypasses the safety net. Those are the patterns worth studying closely.
The Easy-to-Spot Version
This is the pattern every developer learns about in their first security training. Direct string concatenation of user input into a SQL query. It still shows up in production code more often than you’d think, I’ve run into it myself during code reviews.
Python (Flask + SQLite)
@app.route("/api/products/search", methods=["GET"])
def search_products():
keyword = request.args.get("q", "")
db = get_db()
query = "SELECT * FROM products WHERE name LIKE '%" + keyword + "%'"
cursor = db.execute(query)
results = [dict(row) for row in cursor.fetchall()]
return jsonify(results)
The keyword parameter goes straight from the query string into the SQL statement. An attacker sends q=' OR 1=1-- and the query becomes SELECT * FROM products WHERE name LIKE '%' OR 1=1--%', returning every row in the table. Worse, they could use UNION SELECT to extract data from other tables entirely. When I dug into how this gets exploited, the attack path turned out to be more straightforward than I initially expected.
Java (Spring Boot + JDBC)
@GetMapping("/products/search")
public ResponseEntity<?> searchProducts(@RequestParam String q) {
String sql = "SELECT * FROM products WHERE name LIKE '%" + q + "%'";
List<Map<String, Object>> results = jdbc.queryForList(sql);
return ResponseEntity.ok(results);
}
Same pattern, different language. Spring’s JdbcTemplate.queryForList will happily execute whatever string you hand it. The @RequestParam annotation handles URL decoding but does nothing for SQL safety.
Exploitation: GET /api/products/search?q=' UNION SELECT username, password_hash, null, null, null FROM customers--
These examples are straightforward to detect. Any SAST tool with basic taint tracking will flag string concatenation flowing into db.execute() or jdbc.queryForList(). In a manual review, scanning for + operators adjacent to SQL keywords catches them quickly.
The Hard-to-Spot Version
Now here’s where it gets interesting. The dangerous SQL injections, the ones that are genuinely fascinating to study, are the ones that look partially safe. The developer clearly knows about parameterized queries and uses them for some parameters, but misses others. This pattern shows up constantly in public vulnerability disclosures, and I’ve run into it in code reviews too.
Python: Mixed Parameterization
def build_order_filter(filters):
clauses = []
if "status" in filters:
clauses.append("status = '{}'".format(filters["status"]))
if "min_quantity" in filters:
clauses.append("quantity >= {}".format(filters["min_quantity"]))
return " AND ".join(clauses) if clauses else "1=1"
@app.route("/api/orders", methods=["GET"])
def list_orders():
db = get_db()
customer_id = request.args.get("customer_id")
filters = {}
if request.args.get("status"):
filters["status"] = request.args.get("status")
if request.args.get("min_quantity"):
filters["min_quantity"] = request.args.get("min_quantity")
where_clause = build_order_filter(filters)
query = (
"SELECT o.*, p.name as product_name FROM orders o "
"JOIN products p ON o.product_id = p.id "
"WHERE o.customer_id = ? AND " + where_clause
)
cursor = db.execute(query, (customer_id,))
orders = [dict(row) for row in cursor.fetchall()]
return jsonify(orders)
Notice how customer_id is properly parameterized with ?, but the status and min_quantity filters are built using .format(). Here’s what clicked for me about why this is so dangerous: a reviewer scanning the db.execute(query, (customer_id,)) line sees a parameterized query and might move on. The injection point is hidden inside build_order_filter, a helper function that could be in a different file entirely. This is the kind of bug that taught me to always trace the full query string, not just check the execution call.
Exploitation: GET /api/orders?customer_id=1&status=pending' OR '1'='1
Java: Partial Parameterization in StringBuilder
@GetMapping("/orders")
public ResponseEntity<?> listOrders(
@RequestParam int customerId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String dateFrom,
@RequestParam(required = false) String dateTo) {
StringBuilder sql = new StringBuilder(
"SELECT o.*, p.name as product_name FROM orders o "
+ "JOIN products p ON o.product_id = p.id "
+ "WHERE o.customer_id = ?");
List<Object> params = new ArrayList<>();
params.add(customerId);
if (status != null && !status.isEmpty()) {
sql.append(" AND o.status = '").append(status).append("'");
}
if (dateFrom != null && !dateFrom.isEmpty()) {
sql.append(" AND o.created_at >= '").append(dateFrom).append("'");
}
if (dateTo != null && !dateTo.isEmpty()) {
sql.append(" AND o.created_at <= '").append(dateTo).append("'");
}
List<Map<String, Object>> orders = jdbc.queryForList(
sql.toString(), params.toArray());
return ResponseEntity.ok(orders);
}
The customerId uses a proper ? placeholder. But status, dateFrom, and dateTo are appended directly into the SQL string. What I find particularly interesting about this pattern is that the code demonstrates awareness of parameterized queries, it just doesn’t apply them consistently. Reading through public vulnerability reports, this turns out to be one of the most common forms of SQL injection in mature codebases. The developer knew the right thing to do and did it for one parameter, then got inconsistent with the rest.
Exploitation: GET /api/orders?customerId=1&status=pending' UNION SELECT username, password_hash, null, null, null, null, null FROM customers--
Go: ORDER BY Injection
sortBy := c.DefaultQuery("sort", "name")
order := c.DefaultQuery("order", "ASC")
allowedColumns := map[string]bool{"name": true, "price": true, "stock": true}
if !allowedColumns[sortBy] {
sortBy = "name"
}
// sort is validated, but order is not
query := fmt.Sprintf("SELECT * FROM products WHERE category = ? ORDER BY %s %s", sortBy, order)
rows, err := db.Query(query, category)
This example is great because the developer did the right thing for sortBy, validated it against an allowlist. But order is assumed to be either ASC or DESC and is concatenated directly. An attacker can inject arbitrary SQL through the order parameter: GET /api/products?category=electronics&sort=name&order=ASC; DROP TABLE products--
This pattern is especially hard to catch because ORDER BY clauses cannot be parameterized in most SQL drivers, so developers often resort to string formatting. The fix is validating order against an allowlist of {"ASC", "DESC"}, a two-line fix that eliminates the risk entirely.
JavaScript: Template Literal Injection
app.get('/api/products', (req, res) => {
const category = req.query.category;
const sort = req.query.sort || 'name';
const order = req.query.order || 'ASC';
const allowedSorts = ['name', 'price', 'stock', 'category'];
const safeSort = allowedSorts.includes(sort) ? sort : 'name';
let query;
if (category) {
query = `SELECT * FROM products WHERE category = '${category}' ORDER BY ${safeSort} ${order}`;
} else {
query = `SELECT * FROM products ORDER BY ${safeSort} ${order}`;
}
db.all(query, [], (err, rows) => {
if (err) return res.status(500).json({ error: 'Query failed' });
res.json(rows);
});
});
Something that struck me while researching JavaScript SQL injection is how template literals make string interpolation feel clean and modern, which can mask the danger. The category value is embedded directly in the query using ${category}, and order is unvalidated. The backtick syntax looks different from traditional concatenation, and it doesn’t pattern-match to the “string concatenation” that reviewers are trained to look for.
Detection Strategies
Static Analysis (SAST)
Most SAST tools handle the easy cases well. Bandit (Python), SpotBugs (Java), gosec (Go), and eslint-plugin-security (JavaScript) all flag direct string concatenation in SQL queries. But the hard cases are where things fall apart:
- Helper functions: When the concatenation happens in a utility function and the SQL execution happens elsewhere, taint tracking must follow the data across function boundaries. Many lightweight linters miss this, which is a real gap for teams relying solely on automated scanning.
- Partial parameterization: Tools that check “is this query parameterized?” may see the
?placeholder and stop checking. More sophisticated tools track whether all user inputs are parameterized, but not all tools are that thorough. - ORDER BY / GROUP BY: These clauses legitimately cannot use parameter placeholders in most drivers, so tools must distinguish between validated and unvalidated dynamic values. This seems to be a blind spot for most SAST tools based on what I’ve tested.
Manual Code Review
When reviewing for SQL injection, here’s what I focus on:
- Search for raw SQL execution methods:
db.execute(),jdbc.queryForList(),db.Query(),db.all(), then trace every string argument backward to its source. - Check dynamic query builders: Any function that returns a SQL fragment or WHERE clause is a high-risk target. Verify that every user-controlled value in the fragment is parameterized.
- Audit ORDER BY and LIMIT clauses: These are the most common places where developers skip parameterization because the driver doesn’t support it. Look for allowlist validation.
- Watch for mixed patterns: If a query uses
?for some parameters and string formatting for others, the formatted ones are almost certainly vulnerable. This is the single most reliable heuristic I’ve found.
Remediation
The fix is consistent parameterization, with allowlist validation for structural elements like column names and sort directions.
Python, Fixed
@app.route("/api/products/search", methods=["GET"])
def search_products():
keyword = request.args.get("q", "")
db = get_db()
cursor = db.execute(
"SELECT * FROM products WHERE name LIKE ?",
("%" + keyword + "%",)
)
results = [dict(row) for row in cursor.fetchall()]
return jsonify(results)
Java, Fixed
@GetMapping("/products/search")
public ResponseEntity<?> searchProducts(@RequestParam String q) {
String sql = "SELECT * FROM products WHERE name LIKE ?";
List<Map<String, Object>> results = jdbc.queryForList(sql, "%" + q + "%");
return ResponseEntity.ok(results);
}
For ORDER BY clauses, use allowlist validation:
ALLOWED_ORDERS = {"ASC", "DESC"}
order = request.args.get("order", "ASC").upper()
if order not in ALLOWED_ORDERS:
order = "ASC"
For dynamic filter builders, pass parameters through:
def build_order_filter(filters):
clauses = []
params = []
if "status" in filters:
clauses.append("status = ?")
params.append(filters["status"])
if "min_quantity" in filters:
clauses.append("quantity >= ?")
params.append(filters["min_quantity"])
where = " AND ".join(clauses) if clauses else "1=1"
return where, params
The key principle here: every value that comes from outside the application boundary must go through a parameter placeholder. Structural SQL elements (column names, sort directions, table names) must be validated against an explicit allowlist, never parameterized, never concatenated raw. Once that rule clicks, it catches the vast majority of SQL injection before it ever reaches production.