Security and production workflow · Lesson 30 155 min
Preventing SQL injection
SQL is a language. A form field is data. Injection happens when the app pastes that field into the language, so the field can change the statement. The fix is boring and complete: keep the SQL text fixed, send values through bound parameters, allowlist the few names you cannot bind, and still filter by the current session.
Values are not source code
The engine receives a statement and, separately, a list of values. WHERE email = ? with a bound string always compares email to that string. It never becomes extra clauses, extra statements, or a different table. Concatenating the field into the SQL string throws away that guarantee.
In application code the safe pair looks like this (do not reverse it):
// Keep the SQL text in source control.
const sql = "SELECT customer_id, email FROM customers WHERE email = ?";
// Send the form value on the parameter channel, never inside sql.
db.prepare(sql).get(emailFromForm);
ORMs are safe only when they use this channel. A raw query API that takes a string you built with + is the old problem with extra steps.
SQL BROWSER RUNNER
Look up an email through a params row
params.email stands in for a bound form value. Join, do not paste.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
email TEXTNOTNULLUNIQUE,
display_name TEXTNOTNULL);CREATETABLE products (
sku TEXTPRIMARYKEY,
name TEXTNOTNULL,
listed_cents INTEGERNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGERNOTNULLREFERENCES customers(customer_id),
sku TEXTNOTNULLREFERENCES products(sku),
cents INTEGERNOTNULL);CREATETABLE session_customer (
customer_id INTEGERNOTNULLREFERENCES customers(customer_id));INSERTINTO customers VALUES(1,'ada@example.com','Ada'),(2,'grace@example.com','Grace'),(3,'ada.obrien@example.com','Ada O''Brien');INSERTINTO products VALUES('NB-1','SQL notebook',1200),('PEN-2','Gel pen',400),('MUG-9','Mug',900);INSERTINTO orders VALUES(1,1,'NB-1',1200),(2,2,'PEN-2',400),(3,3,'MUG-9',900);-- Host code binds email. This runner stands in with a params row.CREATETABLE params (
email TEXTNOTNULL);INSERTINTO params VALUES('ada.obrien@example.com');SELECT c.customer_id, c.email, c.display_name
FROM customers AS c
JOIN params AS p ON p.email = c.email;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
An apostrophe is data, not a delimiter you handle by luck
Ada O'Brien's name contains a quote character. In SQL source, a string literal doubles it: 'Ada O''Brien'. That doubling is how you write a literal in a file. It is not a recipe for treating request bodies. The driver binds the name; the engine stores the apostrophe. quote() shows the literal form for dumps and logs. It is not your login form.
SQL BROWSER RUNNER
Store and select a name that contains an apostrophe
Literal in SQL source, then quote() of the stored value.
Edit the query, predict the rows it will return, then run it.
Names you cannot bind go through an allowlist
Placeholders are for values. They do not pick a column for ORDER BY, a table for FROM, or ASC versus DESC. The UI may send sort=name. Your code maps that to one of two statements you already wrote, or to a CASE that only recognizes name and sku. Anything else falls through to a default. The client never chooses SQL grammar.
SQL BROWSER RUNNER
Sort only by allowlisted keys
params.sort_key is name. CASE ignores any other word and uses sku.
Edit the query, predict the rows it will return, then run it.
You own the wildcard; they own the letters
A prefix search is LIKE bound || '%' after you strip pattern characters from the bound text. The percent sign is your operator. If the field keeps % or _, the user is still composing a pattern, not a plain prefix. Replace those characters in the value, then append the wildcard in code you control.
SQL BROWSER RUNNER
Prefix-search emails from a typed param
Strip % and _ from typed, then LIKE that || %.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
email TEXTNOTNULLUNIQUE,
display_name TEXTNOTNULL);CREATETABLE products (
sku TEXTPRIMARYKEY,
name TEXTNOTNULL,
listed_cents INTEGERNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGERNOTNULLREFERENCES customers(customer_id),
sku TEXTNOTNULLREFERENCES products(sku),
cents INTEGERNOTNULL);CREATETABLE session_customer (
customer_id INTEGERNOTNULLREFERENCES customers(customer_id));INSERTINTO customers VALUES(1,'ada@example.com','Ada'),(2,'grace@example.com','Grace'),(3,'ada.obrien@example.com','Ada O''Brien');INSERTINTO products VALUES('NB-1','SQL notebook',1200),('PEN-2','Gel pen',400),('MUG-9','Mug',900);INSERTINTO orders VALUES(1,1,'NB-1',1200),(2,2,'PEN-2',400),(3,3,'MUG-9',900);CREATETABLE params (
typed TEXTNOTNULL);INSERTINTO params VALUES('ada');SELECT c.email
FROM customers AS c
JOIN params AS p ON c.email LIKEreplace(replace(p.typed,'%',''),'_','')||'%'ORDERBY c.email;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
LIMIT is an integer you parsed
Page size arrives as text in HTTP. Parse it, clamp it (here, 1–50), bind the integer. A CHECK on the params table is extra belt-and-suspenders in this catalog. In the app, reject NaN and cap the maximum. Concatenating the query string after LIMIT is the same class of bug as concatenating after WHERE.
SQL BROWSER RUNNER
Page products with a checked page_size
params.page_size is 2. LIMIT uses that integer subquery.
Edit the query, predict the rows it will return, then run it.
A bound id is not permission
Parameters stop the statement from changing shape. They do not add “Ada may only see Ada.” If the URL contains an order id, bind that id and join session_customer. The first SELECT in this runner is the leak from last lesson: correct SQL, wrong audience. The second SELECT is empty for Ada asking for Grace's order id 2.
PostgreSQL row-level security can attach that filter in the engine so a forgotten JOIN cannot happen. Until then, every “get by id” query includes the session.
SQL BROWSER RUNNER
Bind order_id, then require Ada's session
params.order_id is 2. First query unbound to session; second joins session_customer 1.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
email TEXTNOTNULLUNIQUE,
display_name TEXTNOTNULL);CREATETABLE products (
sku TEXTPRIMARYKEY,
name TEXTNOTNULL,
listed_cents INTEGERNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGERNOTNULLREFERENCES customers(customer_id),
sku TEXTNOTNULLREFERENCES products(sku),
cents INTEGERNOTNULL);CREATETABLE session_customer (
customer_id INTEGERNOTNULLREFERENCES customers(customer_id));INSERTINTO customers VALUES(1,'ada@example.com','Ada'),(2,'grace@example.com','Grace'),(3,'ada.obrien@example.com','Ada O''Brien');INSERTINTO products VALUES('NB-1','SQL notebook',1200),('PEN-2','Gel pen',400),('MUG-9','Mug',900);INSERTINTO orders VALUES(1,1,'NB-1',1200),(2,2,'PEN-2',400),(3,3,'MUG-9',900);INSERTINTO session_customer VALUES(1);CREATETABLE params (
order_id INTEGERNOTNULL);INSERTINTO params VALUES(2);-- Bound id, missing session: would return Grace's order.SELECT o.order_id, o.sku
FROM orders AS o
JOIN params AS p ON p.order_id = o.order_id;-- Bound id plus session: Ada cannot read order 2.SELECT o.order_id, o.sku
FROM orders AS o
JOIN params AS p ON p.order_id = o.order_id
JOIN session_customer AS s ON s.customer_id = o.customer_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Pick the statement on the server; do not take SQL from the client
Two searches are two reviewed strings in git, not one template with a hole. Login lookup is statement A. Catalog search is statement B. The browser sends values, never the statement. If you need optional filters, keep them as extra bound ANDs you append from your own code paths—or use a query builder that only emits placeholders.
This runner runs two statements in one script because that is how sql.js works. In an app, that is still two prepared handles, not one string glued from a request.
SQL BROWSER RUNNER
Two fixed statements, one bound email
Lookup Ada, then count products. Both texts are constants.
Edit the query, predict the rows it will return, then run it.
Least privilege caps the blast radius
If a bug ever concatenates anyway, the login should still be last lesson's clerk: catalog_store, not SELECT * on a table with costs, and not DROP. Binds are the lock. Privilege is the small key ring. Both.
SQL BROWSER RUNNER
Bind a sku against the storefront view
catalog_store has no extra columns. params.sku is NB-1.
Edit the query, predict the rows it will return, then run it.
What you should be able to do
Keep SQL text in source control with placeholders.
Bind emails, ids, and search strings; never paste them into the statement.
Allowlist sort keys and other identifiers.
Parse LIMIT as a clamped integer.
Join the session even when the id is bound.
Independent lab: search, sort, and page from params only
The starter dumps ten products. Drive the list from params: a prefix on name or sku (your choice), an allowlisted sort_key, and a checked page_size. Do not interpolate typed or sort_key into the SQL string. Show that a nonsense sort_key still orders by your default.
SQL BROWSER RUNNER
Wire the catalog search to params
typed, sort_key, and page_size are already in params. You write the SELECT.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
email TEXTNOTNULLUNIQUE,
display_name TEXTNOTNULL);CREATETABLE products (
sku TEXTPRIMARYKEY,
name TEXTNOTNULL,
listed_cents INTEGERNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGERNOTNULLREFERENCES customers(customer_id),
sku TEXTNOTNULLREFERENCES products(sku),
cents INTEGERNOTNULL);CREATETABLE session_customer (
customer_id INTEGERNOTNULLREFERENCES customers(customer_id));INSERTINTO customers VALUES(1,'ada@example.com','Ada'),(2,'grace@example.com','Grace'),(3,'ada.obrien@example.com','Ada O''Brien');INSERTINTO products VALUES('NB-1','SQL notebook',1200),('PEN-2','Gel pen',400),('MUG-9','Mug',900);INSERTINTO orders VALUES(1,1,'NB-1',1200),(2,2,'PEN-2',400),(3,3,'MUG-9',900);-- Search box + sort menu + page size, all as params.-- 1. Bind typed text (strip % and _).-- 2. Allowlist sort_key (name or sku only).-- 3. CHECK page_size between 1 and 50.-- 4. Keep results on catalog columns only.CREATETABLE params (
typed TEXTNOTNULL,
sort_key TEXTNOTNULL,
page_size INTEGERNOTNULL);INSERTINTO params VALUES('n','name',10);SELECT sku, name
FROM products
LIMIT10;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Common mistakes to avoid
Building SQL with string join, format, or a template literal that includes the request.
Trusting “we escape quotes” on one code path and forgetting the others.
Binding values but interpolating the table name from the client.
Taking LIMIT, OFFSET, or ASC/DESC from the query string as raw SQL.
Logging bound values next to a statement you then copy-paste and run as concatenated SQL.
Assuming a parameterized query is also authorized for this user.
Giving the web login owner rights “so the ORM can migrate.”
Lesson review
I can explain injection as input becoming SQL grammar.
I can write a prepared statement and bind values.
I can allowlist columns for ORDER BY instead of interpolating names.
I can treat LIKE wildcards and LIMIT as syntax I own.
I can combine binds with a session filter and a tight GRANT.
I can refuse SQL text that arrived from the browser.
KNOWLEDGE CHECK
Check your injection defenses
Answer all ten questions, then reopen the runner whose bind, allowlist, or session filter still feels unclear.