SQL Injection Prevention Cheat Sheet
Explains how SQL injection works and shows concrete, language-specific techniques to prevent it using parameterized queries and validation.
Vulnerable vs. Safe Query (Python)
String concatenation vs. parameterized queries.
# VULNERABLE: user input concatenated directly into SQLquery = "SELECT * FROM users WHERE username = '" + username + "'"cursor.execute(query)# SAFE: parameterized query, driver handles escapingquery = "SELECT * FROM users WHERE username = %s"cursor.execute(query, (username,))
Parameterized Queries in Other Stacks
Examples using an ORM and Node.js driver.
// Node.js with node-postgres — parameterized queryconst res = await pool.query( 'SELECT * FROM users WHERE email = $1', [email]);// Using an ORM (e.g. Sequelize) — auto-parameterizedconst user = await User.findOne({ where: { email } });
Common SQLi Variants
Different ways SQL injection can manifest.
- In-band (classic)- Attacker gets results directly in the application response
- Union-based- Uses UNION SELECT to combine attacker query with legitimate results
- Blind (boolean)- Infers data by observing true/false differences in responses
- Blind (time-based)- Infers data using SLEEP()/WAITFOR to measure response delay
- Second-order- Malicious input stored first, then executed later in a different query
Defense-in-Depth Layers
Multiple complementary controls to reduce SQLi risk.
- Parameterized queries / prepared statements- Primary defense; separates code from data
- Least-privilege DB accounts- App's DB user should not have DROP/ALTER rights if not needed
- Input validation- Allow-list expected formats (e.g. numeric IDs, email patterns)
- Stored procedures- Can help if they don't internally concatenate input into SQL
- WAF- Web Application Firewall as a detective/compensating control, not a primary fix
- ORMs- Reduce raw SQL usage, but raw/custom queries within them can still be vulnerable
Prepared Statements: Java & Go
Statically-typed languages bind parameters through the driver API rather than string formatting, closing off injection at the type level.
// Java (JDBC) — PreparedStatement pre-compiles the query; values are bound, never concatenatedString sql = "SELECT * FROM orders WHERE customer_id = ? AND status = ?";try (PreparedStatement ps = connection.prepareStatement(sql)) { ps.setLong(1, customerId); ps.setString(2, status); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { /* process row */ } }}// Go (database/sql) — placeholders are driver-specific ($1 for pgx/lib/pq, ? for MySQL)rows, err := db.Query( "SELECT id, amount FROM orders WHERE customer_id = $1 AND status = $2", customerID, status,)
PHP PDO with Named Bound Parameters
PDO's bindValue/bindParam with explicit types prevents both injection and type-confusion issues seen with naive string interpolation.
<?php$pdo = new PDO('mysql:host=localhost;dbname=shop', $user, $pass, [ PDO::ATTR_EMULATE_PREPARES => false, // force real server-side prepares PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,]);$stmt = $pdo->prepare( 'SELECT * FROM orders WHERE customer_id = :cid AND status = :status');$stmt->bindValue(':cid', $customerId, PDO::PARAM_INT);$stmt->bindValue(':status', $status, PDO::PARAM_STR);$stmt->execute();$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);// PDO::ATTR_EMULATE_PREPARES=false matters: emulated prepares build the final// SQL client-side and are more prone to encoding-based injection edge cases.
Safe Dynamic ORDER BY / Identifier Selection
Column and table names can't be bound as parameters — allow-list them against a known set before interpolating.
ALLOWED_SORT_COLUMNS = {"created_at", "amount", "status"}ALLOWED_DIRECTIONS = {"asc", "desc"}def list_orders(customer_id, sort_col="created_at", direction="desc"): if sort_col not in ALLOWED_SORT_COLUMNS: raise ValueError("invalid sort column") if direction.lower() not in ALLOWED_DIRECTIONS: raise ValueError("invalid sort direction") # Identifiers are validated against an allow-list, then safely interpolated; # the actual filter value still goes through a bound parameter. query = f"SELECT * FROM orders WHERE customer_id = %s ORDER BY {sort_col} {direction}" cur.execute(query, (customer_id,)) return cur.fetchall()
ORM Raw-Query Escape Hatches That Reintroduce Risk
ORMs are safe by default, but every major one exposes a raw-SQL escape hatch that silently drops parameterization if misused.
- Sequelize.literal() / Sequelize.query()- Accepts raw SQL strings; string-building user input into either reopens injection despite using an ORM
- Django .raw() / .extra()- .raw() still supports %s params safely, but .extra(where=...) built with f-strings is a classic injection point
- TypeORM QueryBuilder .where(string)- Passing a raw condition string with concatenated values bypasses the parameter binding TypeORM otherwise provides
- SQLAlchemy text()- text("... :param") with bindparams() is safe; text(f"...{value}") is not — the function itself doesn't parameterize automatically
- Hibernate createNativeQuery()- Native/raw HQL queries built via string concatenation bypass HQL's own injection protections
- Prisma $queryRawUnsafe- Name is a deliberate warning; only $queryRaw with tagged templates is safely parameterized
Defensive Detection Signatures (Authorized Testing/Monitoring)
Patterns a WAF, IDS, or log-review process should flag as likely injection attempts against your own systems.
- Error-based fingerprints- Responses leaking DB engine errors (e.g. 'you have an error in your SQL syntax') indicate verbose error handling that should be suppressed in production
- Time-based blind signatures- Requests containing SLEEP(), pg_sleep(), WAITFOR DELAY correlated with abnormal response latency for that endpoint
- UNION SELECT column-count probing- Sequential requests varying the number of NULLs in a UNION SELECT, typical of manual column-count enumeration
- Encoded/obfuscated payloads- Double URL-encoding, inline comments (/**/), or alternate casing (SeLeCt) used to evade naive signature-based filters
- Stacked query attempts- Semicolon-separated statements in a single input field, relevant for drivers/engines that allow multi-statement execution
Parameterized queries must bind actual values, not table/column names — those can't be parameterized by the driver, so for dynamic identifiers use a strict allow-list instead of user input, ever.