SovranCode
SQL: Query, Model, and Analyze Data Preventing SQL injection
This device
Course contentsPreventing SQL injection · 32 topics

Relational foundations

Databases, SQL, and the relational modelTables, rows, columns, and schemasData types and NULLPrimary keys, foreign keys, and relationships

Reading and filtering data

Your first SELECT queryConditions with WHERESorting, limiting, and distinct valuesText, dates, patterns, and conditional results

Reports, aggregation, and analytics

Aggregate functionsGROUP BY and HAVINGWindow functionsCommon table expressions

Combining related data

INNER, LEFT, RIGHT, and FULL joinsSelf joins and many-to-many dataSubqueries and correlated subqueriesUNION, INTERSECT, and EXCEPT

Designing reliable schemas

CREATE TABLE and schema designConstraints and data integrityNormalization and intentional denormalizationViews and materialized views

Writing and protecting data

INSERT, UPDATE, and DELETEUpserts and conflict handlingTransactions and savepointsIsolation, locks, and concurrency

Performance and administration

Indexes and access pathsEXPLAIN and query plansQuery tuning patternsBackups, restores, and migrations

Security and production workflow

Users, roles, and least privilegePreventing SQL injectionStored procedures, functions, and triggersProduction data project
SQL: Query, Model, and Analyze Data32 complete · 0 planned

Relational foundations

Databases, SQL, and the relational modelTables, rows, columns, and schemasData types and NULLPrimary keys, foreign keys, and relationships

Reading and filtering data

Your first SELECT queryConditions with WHERESorting, limiting, and distinct valuesText, dates, patterns, and conditional results

Reports, aggregation, and analytics

Aggregate functionsGROUP BY and HAVINGWindow functionsCommon table expressions

Combining related data

INNER, LEFT, RIGHT, and FULL joinsSelf joins and many-to-many dataSubqueries and correlated subqueriesUNION, INTERSECT, and EXCEPT

Designing reliable schemas

CREATE TABLE and schema designConstraints and data integrityNormalization and intentional denormalizationViews and materialized views

Writing and protecting data

INSERT, UPDATE, and DELETEUpserts and conflict handlingTransactions and savepointsIsolation, locks, and concurrency

Performance and administration

Indexes and access pathsEXPLAIN and query plansQuery tuning patternsBackups, restores, and migrations

Security and production workflow

Users, roles, and least privilegePreventing SQL injectionStored procedures, functions, and triggersProduction data project
PREVIOUS LESSONUsers, roles, and least privilege
NEXT LESSONStored procedures, functions, and triggers
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.

This lesson does not practice attacks

You will not assemble malicious SQL here. You will practice the shape that makes those attempts fail: parameters, allowlists, integer limits, and last lesson's session row. This runner also cannot bind host variables, so a one-row params table stands in for “the driver sent this value separately.”

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.

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL
);

CREATE TABLE products (
  sku TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  listed_cents INTEGER NOT NULL
);

CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
  sku TEXT NOT NULL REFERENCES products(sku),
  cents INTEGER NOT NULL
);

CREATE TABLE session_customer (
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id)
);

INSERT INTO customers VALUES
  (1, 'ada@example.com', 'Ada'),
  (2, 'grace@example.com', 'Grace'),
  (3, 'ada.obrien@example.com', 'Ada O''Brien');

INSERT INTO products VALUES
  ('NB-1', 'SQL notebook', 1200),
  ('PEN-2', 'Gel pen', 400),
  ('MUG-9', 'Mug', 900);

INSERT INTO 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.
CREATE TABLE params (
  email TEXT NOT NULL
);

INSERT INTO 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.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Which display_name returned, and where would the host language put that email instead of a params table?

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.

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL
);

CREATE TABLE products (
  sku TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  listed_cents INTEGER NOT NULL
);

CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
  sku TEXT NOT NULL REFERENCES products(sku),
  cents INTEGER NOT NULL
);

CREATE TABLE session_customer (
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id)
);

INSERT INTO customers VALUES
  (1, 'ada@example.com', 'Ada'),
  (2, 'grace@example.com', 'Grace'),
  (3, 'ada.obrien@example.com', 'Ada O''Brien');

INSERT INTO products VALUES
  ('NB-1', 'SQL notebook', 1200),
  ('PEN-2', 'Gel pen', 400),
  ('MUG-9', 'Mug', 900);

INSERT INTO orders VALUES
  (1, 1, 'NB-1', 1200),
  (2, 2, 'PEN-2', 400),
  (3, 3, 'MUG-9', 900);

SELECT customer_id, display_name
FROM customers
WHERE display_name = 'Ada O''Brien';

SELECT quote(display_name) AS sql_literal
FROM customers
WHERE customer_id = 3;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: What is display_name, and why is quote() a logger's tool rather than a form handler?

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.

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL
);

CREATE TABLE products (
  sku TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  listed_cents INTEGER NOT NULL
);

CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
  sku TEXT NOT NULL REFERENCES products(sku),
  cents INTEGER NOT NULL
);

CREATE TABLE session_customer (
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id)
);

INSERT INTO customers VALUES
  (1, 'ada@example.com', 'Ada'),
  (2, 'grace@example.com', 'Grace'),
  (3, 'ada.obrien@example.com', 'Ada O''Brien');

INSERT INTO products VALUES
  ('NB-1', 'SQL notebook', 1200),
  ('PEN-2', 'Gel pen', 400),
  ('MUG-9', 'Mug', 900);

INSERT INTO orders VALUES
  (1, 1, 'NB-1', 1200),
  (2, 2, 'PEN-2', 400),
  (3, 3, 'MUG-9', 900);

CREATE TABLE params (
  sort_key TEXT NOT NULL
);

INSERT INTO params VALUES ('name');

SELECT sku, name, listed_cents
FROM products
ORDER BY CASE (SELECT sort_key FROM params)
  WHEN 'name' THEN name
  WHEN 'sku' THEN sku
  ELSE sku
END;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: What order do the product names appear in, and what happens if you change sort_key to a word that is not name or sku?

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 || %.

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL
);

CREATE TABLE products (
  sku TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  listed_cents INTEGER NOT NULL
);

CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
  sku TEXT NOT NULL REFERENCES products(sku),
  cents INTEGER NOT NULL
);

CREATE TABLE session_customer (
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id)
);

INSERT INTO customers VALUES
  (1, 'ada@example.com', 'Ada'),
  (2, 'grace@example.com', 'Grace'),
  (3, 'ada.obrien@example.com', 'Ada O''Brien');

INSERT INTO products VALUES
  ('NB-1', 'SQL notebook', 1200),
  ('PEN-2', 'Gel pen', 400),
  ('MUG-9', 'Mug', 900);

INSERT INTO orders VALUES
  (1, 1, 'NB-1', 1200),
  (2, 2, 'PEN-2', 400),
  (3, 3, 'MUG-9', 900);

CREATE TABLE params (
  typed TEXT NOT NULL
);

INSERT INTO params VALUES ('ada');

SELECT c.email
FROM customers AS c
JOIN params AS p ON c.email LIKE replace(replace(p.typed, '%', ''), '_', '') || '%'
ORDER BY c.email;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Which emails start with ada, and why strip wildcards before appending %?

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.

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL
);

CREATE TABLE products (
  sku TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  listed_cents INTEGER NOT NULL
);

CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
  sku TEXT NOT NULL REFERENCES products(sku),
  cents INTEGER NOT NULL
);

CREATE TABLE session_customer (
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id)
);

INSERT INTO customers VALUES
  (1, 'ada@example.com', 'Ada'),
  (2, 'grace@example.com', 'Grace'),
  (3, 'ada.obrien@example.com', 'Ada O''Brien');

INSERT INTO products VALUES
  ('NB-1', 'SQL notebook', 1200),
  ('PEN-2', 'Gel pen', 400),
  ('MUG-9', 'Mug', 900);

INSERT INTO orders VALUES
  (1, 1, 'NB-1', 1200),
  (2, 2, 'PEN-2', 400),
  (3, 3, 'MUG-9', 900);

CREATE TABLE params (
  page_size INTEGER NOT NULL CHECK (page_size BETWEEN 1 AND 50)
);

INSERT INTO params VALUES (2);

SELECT sku, name
FROM products
ORDER BY sku
LIMIT (SELECT page_size FROM params);
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: How many product rows returned, and what would CHECK reject if page_size were 0?

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.

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL
);

CREATE TABLE products (
  sku TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  listed_cents INTEGER NOT NULL
);

CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
  sku TEXT NOT NULL REFERENCES products(sku),
  cents INTEGER NOT NULL
);

CREATE TABLE session_customer (
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id)
);

INSERT INTO customers VALUES
  (1, 'ada@example.com', 'Ada'),
  (2, 'grace@example.com', 'Grace'),
  (3, 'ada.obrien@example.com', 'Ada O''Brien');

INSERT INTO products VALUES
  ('NB-1', 'SQL notebook', 1200),
  ('PEN-2', 'Gel pen', 400),
  ('MUG-9', 'Mug', 900);

INSERT INTO orders VALUES
  (1, 1, 'NB-1', 1200),
  (2, 2, 'PEN-2', 400),
  (3, 3, 'MUG-9', 900);

INSERT INTO session_customer VALUES (1);

CREATE TABLE params (
  order_id INTEGER NOT NULL
);

INSERT INTO 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.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: What does the first SELECT return, and why is the second result empty?

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.

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL
);

CREATE TABLE products (
  sku TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  listed_cents INTEGER NOT NULL
);

CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
  sku TEXT NOT NULL REFERENCES products(sku),
  cents INTEGER NOT NULL
);

CREATE TABLE session_customer (
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id)
);

INSERT INTO customers VALUES
  (1, 'ada@example.com', 'Ada'),
  (2, 'grace@example.com', 'Grace'),
  (3, 'ada.obrien@example.com', 'Ada O''Brien');

INSERT INTO products VALUES
  ('NB-1', 'SQL notebook', 1200),
  ('PEN-2', 'Gel pen', 400),
  ('MUG-9', 'Mug', 900);

INSERT INTO orders VALUES
  (1, 1, 'NB-1', 1200),
  (2, 2, 'PEN-2', 400),
  (3, 3, 'MUG-9', 900);

CREATE TABLE params (
  email TEXT NOT NULL
);

INSERT INTO params VALUES ('ada@example.com');

SELECT customer_id, email
FROM customers
WHERE email = (SELECT email FROM params);

SELECT COUNT(*) AS product_count
FROM products;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: How many statements did you review, and which value came from params?

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.

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL
);

CREATE TABLE products (
  sku TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  listed_cents INTEGER NOT NULL
);

CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
  sku TEXT NOT NULL REFERENCES products(sku),
  cents INTEGER NOT NULL
);

CREATE TABLE session_customer (
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id)
);

INSERT INTO customers VALUES
  (1, 'ada@example.com', 'Ada'),
  (2, 'grace@example.com', 'Grace'),
  (3, 'ada.obrien@example.com', 'Ada O''Brien');

INSERT INTO products VALUES
  ('NB-1', 'SQL notebook', 1200),
  ('PEN-2', 'Gel pen', 400),
  ('MUG-9', 'Mug', 900);

INSERT INTO orders VALUES
  (1, 1, 'NB-1', 1200),
  (2, 2, 'PEN-2', 400),
  (3, 3, 'MUG-9', 900);

CREATE VIEW catalog_store AS
SELECT sku, name, listed_cents
FROM products;

CREATE TABLE params (
  sku TEXT NOT NULL
);

INSERT INTO params VALUES ('NB-1');

SELECT sku, name, listed_cents
FROM catalog_store
JOIN params USING (sku);
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Which columns came back, and which table would you refuse to GRANT to the web login?

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.

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  display_name TEXT NOT NULL
);

CREATE TABLE products (
  sku TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  listed_cents INTEGER NOT NULL
);

CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
  sku TEXT NOT NULL REFERENCES products(sku),
  cents INTEGER NOT NULL
);

CREATE TABLE session_customer (
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id)
);

INSERT INTO customers VALUES
  (1, 'ada@example.com', 'Ada'),
  (2, 'grace@example.com', 'Grace'),
  (3, 'ada.obrien@example.com', 'Ada O''Brien');

INSERT INTO products VALUES
  ('NB-1', 'SQL notebook', 1200),
  ('PEN-2', 'Gel pen', 400),
  ('MUG-9', 'Mug', 900);

INSERT INTO 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.

CREATE TABLE params (
  typed TEXT NOT NULL,
  sort_key TEXT NOT NULL,
  page_size INTEGER NOT NULL
);

INSERT INTO params VALUES ('n', 'name', 10);

SELECT sku, name
FROM products
LIMIT 10;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.

Each run starts a fresh SQLite database in an isolated browser worker. Your edited SQL is saved on this device, but the database and its rows are discarded after the run.

Try this next: Which rows match prefix n, which ORDER BY ran, and what did you do with a sort_key the CASE does not name?

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.

01What is SQL injection, in one sentence?
02What is the primary fix?
03Why is quoting by hand (quote(), doubling apostrophes) a weak substitute?
04Which identifiers cannot be bound as values?
05How should a LIKE prefix search treat the user's typing?
06How do you paginate a LIMIT from a query string?
07Why keep last lesson's session filter even with parameters?
08How does least privilege limit injection damage?
09Where should SQL text live?
10A user wants to sort by an arbitrary column name from a form. What do you do?
PREVIOUS LESSONUsers, roles, and least privilege
NEXT LESSONStored procedures, functions, and triggers
ON THIS PAGEData vs codeApostrophesAllowlistsLIKE prefixesInteger LIMITSession plus bindFixed statementsPrivilegeIndependent labCommon mistakesKnowledge check
Course contents