- Security
- SQL
- OWASP
Preventing SQL Injection in Node.js, Python and Go
Vulnerable and fixed SQL snippets for pg, Prisma, psycopg, SQLAlchemy and Go database/sql, plus safe dynamic ORDER BY clauses and IN lists. Mapped to CWE-89.
· 7 min read · Lina Source LLC
SQL injection (CWE-89) is one of the oldest bugs on the web and still one of the most damaging. The cause has never changed: user input is pasted into the text of a query, so the database cannot tell data from code. The fix has not changed either: send the query and the values separately, and let the driver bind them.
What has changed is where the bug lives. Most teams use an ORM for everyday queries, so injection now shows up in the escape hatches: the raw query written for a report, the search endpoint with a dynamic sort, the migration script, the admin tool. This guide walks through the vulnerable and fixed version in three ecosystems, then covers the two cases that parameters alone do not solve.
The impact is rarely limited to one table. A single injectable query usually runs with the application's full database privileges, so it can read every customer's data, including password hashes and API tokens, modify rows, and on some databases reach the file system or other servers. Blind techniques extract data one bit at a time even when the query's result is never shown to the user, so an endpoint that only returns true or false is still exploitable.
Why parameters work and escaping does not
With a parameterized query, the driver sends the SQL text with placeholders, and the values travel as separate data. The database parses the statement before it ever sees the value, so a quote or a semicolon in the input is just a character in a string. Manual escaping tries to make a value safe to paste into SQL text, and it fails on encodings, numeric contexts, identifiers and every edge case you did not think of. Do not escape; bind.
Node.js: pg and Prisma
With node-postgres, the vulnerable pattern is a template literal passed to query. The fix is a $1 placeholder and a values array. Prisma is safe by default, but it has two raw APIs, and only one of them is safe with interpolation.
import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";
const pool = new Pool();
const prisma = new PrismaClient();
// Vulnerable: input becomes part of the SQL text
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);
// Fixed: value is bound as a parameter
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);
// Prisma: $queryRaw is a tagged template, interpolations become parameters
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;
// Prisma: $queryRawUnsafe with concatenation is injectable
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = '" + email + "'"
);
// If you must use $queryRawUnsafe, pass values as arguments
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = $1",
email
);A subtle Prisma trap: building the query string first and passing it to $queryRaw loses the protection. The safety comes from the tagged template syntax. If you need to compose fragments, use Prisma.sql and Prisma.join, which keep values as parameters. The same rule applies to other tagged-template libraries such as postgres.js and slonik: the tag is what makes interpolation safe, so a query string assembled beforehand and passed in as a plain value bypasses it.
Python: psycopg and SQLAlchemy
In Python the dangerous tools are f-strings, the % operator and str.format applied to SQL. psycopg uses %s placeholders, which look like string formatting but are not: the values go in the second argument to execute, never through the % operator. SQLAlchemy's text() construct is safe when you use named bind parameters and unsafe when you format values into the string.
One psycopg detail catches people out: the placeholder is %s (or %(name)s for named values) regardless of the column type, and you never add quotes around it. Writing '%s' with quotes turns the parameter back into part of a string literal and breaks the query. The same goes for named parameters in SQLAlchemy: :status, not ':status'.
from sqlalchemy import text
# psycopg: vulnerable
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")
# psycopg: fixed, values passed separately
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))
# SQLAlchemy text(): vulnerable
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))
# SQLAlchemy text(): fixed with a bind parameter
conn.execute(
text("SELECT id FROM orders WHERE status = :status"),
{"status": status},
)Go: database/sql
Go's database/sql supports placeholders natively, but the placeholder syntax depends on the driver: $1 for PostgreSQL drivers such as pgx, ? for MySQL and SQLite. The vulnerable pattern is fmt.Sprintf or string concatenation to build the WHERE clause. Because Go is statically typed, it is tempting to assume an int parameter cannot be dangerous. That holds for the value itself, but the habit of building queries with Sprintf spreads to the string parameters next to it, so keep every query on placeholders.
// Vulnerable: string concatenation
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)
// Fixed: placeholder plus argument (PostgreSQL syntax; MySQL uses ?)
var u User
err = db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
// not found
}The ORM escape hatches
ORMs protect the query builder, not every method on the ORM. These are the places to look first in any codebase:
- Prisma: $queryRawUnsafe and $executeRawUnsafe, and any $queryRaw call that receives a prebuilt string instead of a tagged template.
- Sequelize and TypeORM: sequelize.query, query builder .where() calls given a concatenated string, and raw order or group clauses.
- Knex: knex.raw and whereRaw with interpolated values instead of ? bindings.
- SQLAlchemy: text() with f-strings, and literal_column() or column names taken from input.
- Django: .raw(), .extra() and cursor.execute with formatted strings.
- GORM: Where, Order and Raw called with fmt.Sprintf output instead of arguments.
Dynamic ORDER BY: parameters cannot help
Placeholders bind values, not identifiers. You cannot write ORDER BY $1 and pass a column name; the database would sort by a constant. So a sortable table tends to push developers back into string building, and that is where injection comes back. The same limitation applies to table names, schema names and SQL keywords such as ASC and DESC.
The safe pattern is an allowlist that maps user-facing sort keys to known column names. The input selects an entry; it never becomes SQL. The direction gets the same treatment: map to a fixed ASC or DESC.
var sortColumns = map[string]string{
"created": "created_at",
"name": "name",
"price": "price_cents",
}
col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
col = "created_at"
}
dir := "ASC"
if r.URL.Query().Get("dir") == "desc" {
dir = "DESC"
}
// Safe: col and dir can only be values from the code above
query := fmt.Sprintf(
"SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1",
col, dir,
)
rows, err := db.QueryContext(ctx, query, limit)If you genuinely need a dynamic identifier that cannot be allowlisted, use the driver's identifier quoting rather than your own. In psycopg that is sql.SQL(...).format(sql.Identifier(name)); in pgx it is pgx.Identifier{name}.Sanitize(). An allowlist is still better, because quoting makes the name safe but not necessarily valid or permitted. A quoted identifier still lets the caller sort or filter by any column in the table, including ones you never meant to expose, such as a password hash or an internal score. Sorting by a hidden column can leak its values through the order of the results, one comparison at a time.
IN lists without string building
Filtering by a list of IDs is the other common excuse for concatenation. Every major stack has a safe way to do it:
- PostgreSQL with pg or pgx: pass the whole list as one array parameter and write WHERE id = ANY($1). The driver sends it as a Postgres array.
- Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` expands to one placeholder per element.
- SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), then pass a list.
- Go with MySQL: generate the placeholder string from the list length (strings.Repeat("?,", n) trimmed) and pass the values as arguments. Only the placeholders are generated, never the values.
- Reject empty lists before the query runs, and cap the list size so the endpoint cannot be used to build huge statements.
Defense in depth
Parameterization is the fix. These reduce the blast radius when one query slips through:
- Connect with a database role that has only the privileges the app needs. The web app rarely needs DROP, ALTER or access to other schemas.
- Do not return raw database errors to clients. Error-based injection relies on seeing them.
- Validate types at the boundary: an ID that should be an integer or UUID should be parsed as one before it reaches the data layer.
- Set a statement timeout, which limits time-based blind injection and runaway queries alike.
Finding the ones you already have
Start with a search for the escape hatches listed above, then for SQL keywords near string formatting: SELECT or WHERE inside template literals, f-strings, Sprintf or + concatenation. Each hit is either bound correctly, built from an allowlist, or a bug. Do not skip code paths that look internal: CSV importers, cron jobs and admin dashboards often take input that originally came from a user, just one step removed. Values read back from your own database can carry a payload that was stored safely earlier and then pasted into a query later, which is known as second-order injection. CodeAuditAgent does this tracing across files for public GitHub repositories and pasted code, and reports CWE-89 findings with the quoted query, how input reaches it and a patched version.
The rule to leave with is short: values go in parameters, identifiers come from an allowlist, and nothing from the request is ever pasted into SQL text.