Applications and operations
Parameterize PostgreSQL queries
Pass values separately with $1 placeholders and return generated data in the same statement.
Building SQL by gluing user input into a string is how SQL injection happens. If a note title arrives as '); DROP TABLE app.notes; --, string concatenation turns your insert into two statements, and the second one is the attacker’s.
The fix is not escaping. It is keeping SQL and data on separate channels. Pass untrusted values separately:
const result = await pool.query(
`INSERT INTO app.notes (title, body)
VALUES ($1, $2)
RETURNING id, title`,
['Plan the week', 'Choose three priorities']
)
console.log(result.rows[0])
// { id: 7, title: 'Plan the week' }
$1 and $2 are placeholders. The driver sends the SQL text and the values array to PostgreSQL as separate parts of the protocol. The server parses the statement first, then binds the values. By the time your data arrives, the statement’s structure is fixed, so no input can add a second statement or a sneaky OR 1=1. The hostile title above just becomes a note with a weird name.
This is not a sanitization step you must remember to apply. It is the normal way to run queries with pg, and it should be the only way values reach your SQL.
RETURNING gives the application the generated identifier without a second query, so the insert and its result stay in one round trip.
What placeholders cannot do
Placeholders represent values. They cannot represent table names, column names, or sort directions. Try it and PostgreSQL rejects the statement with a syntax error, because those parts define the query’s structure, and the structure must be known at parse time.
When the application needs a dynamic column or direction, choose those from a fixed allowlist:
const sortable = { created: 'created_at', title: 'title' }
const column = sortable[req.query.sort] ?? 'created_at'
const dir = req.query.dir === 'asc' ? 'ASC' : 'DESC'
const notes = await pool.query(
`SELECT id, title FROM app.notes
ORDER BY ${column} ${dir}
LIMIT $1`,
[20]
)
The interpolated pieces never contain user input, only values your own code chose from a closed set. The user picks a key; your code picks the SQL.
Verify the discipline holds with a search: any query in the codebase that concatenates a request value into SQL text is a finding, even when it “looks safe today”.
Lesson completed