How SQL Injection Can Be Prevented: The Complete Engineering Guide
This article is available in English. Browse translated guides in the language menu.
SQL Injection (SQLi) has consistently dominated the OWASP Top 10 for more than two decades. Despite widespread awareness, SQL injection vulnerabilities remain one of the most devastating attack vectors in software engineering—responsible for catastrophic data breaches, unauthorized privilege escalation, full database compromise, and remote code execution (RCE).
The persistence of SQL injection is rarely due to a lack of security tooling; it stems from a fundamental misunderstanding of how database parsers interpret structured code versus untrusted data.
This engineering guide provides an authoritative, deep-dive examination into how SQL injection works at the engine and Abstract Syntax Tree (AST) layer, why parameterized queries mathematically eliminate the vulnerability, how to safely handle complex edge cases (such as dynamic table names and ORDER BY clauses), and how to architect defense-in-depth across the entire infrastructure stack.
1. Root Cause Analysis: The Conflation of Code and Data
To eliminate SQL injection permanently, engineers must first understand its core theoretical origin: the conflation of executable code and input data within an interpreted language channel.
A relational database engine processes SQL commands through an internal compiler pipeline:
- Lexical Analysis (Lexing): Converts raw character strings into atomic language tokens (
SELECT, identifiers, string literals, operators). - Syntactic Analysis (Parsing): Evaluates tokens against database grammar rules to build an Abstract Syntax Tree (AST).
- Query Optimization & Planning: Analyzes table statistics, index availability, and join algorithms to produce a physical execution plan.
- Execution Engine: Evaluates the plan against data pages in the storage buffer or on disk.
Unsafe Path (String Concatenation):
[User Input: "admin' --"] ──> [String Interpolation: "SELECT * FROM users WHERE user = '" + input + "'"]
│
▼
[SQL Lexer & Parser]
│
▼
[AST Node Mutated: 'admin' parsed as Literal,
'--' parsed as Comment, bypassing password logic!]
Safe Path (Prepared Statement / Parameterization):
[SQL Template: "SELECT * FROM users WHERE user = $1"] ──> [SQL Lexer & Parser] ──> [Immutable AST Root]
│
[User Input: "admin' --"] ─────────────────── Sent via Wire Protocol Frame ───────────────>│
▼
[Executor treats parameter
STRICTLY as atomic literal data]
The AST Mutation in Action
Consider a classic authentication query written with dynamic string interpolation:
-- Application template
SELECT user_id, email, role
FROM accounts
WHERE username = '{USER_INPUT}' AND password_hash = '{HASH}';
If the application passes untrusted input directly into {USER_INPUT}:
admin' OR '1'='1
The string sent across the wire becomes:
SELECT user_id, email, role
FROM accounts
WHERE username = 'admin' OR '1'='1' AND password_hash = '...';
Because the single quote (') is a syntax delimiter in SQL grammar, the parser terminates the string literal early and interprets OR as a logical operator node in the AST. The operator precedence evaluates the boolean expression to TRUE, returning records without verifying the password hash.
SQL injection is not an “input sanitation” bug. It is an architectural flaw where untrusted data escapes its lexical boundary and redefines the execution grammar of the program.
2. The Primary Defense: Parameterized Queries (Prepared Statements)
The universal, mathematically sound solution to SQL injection is parameterization, implemented via Prepared Statements.
How Prepared Statements Work at the Protocol Level
When an application executes a parameterized query, the database driver and server communicate across two distinct phases over the database binary wire protocol (such as PostgreSQL frontend/backend protocol v3.0 or MySQL client/server protocol):
Phase 1: Parse and Prepare (Compile Step)
The client sends the SQL command template with formal parameter place-markers ($1, $2 in Postgres, ? in SQLite/MySQL) to the database:
PREPARE auth_statement AS
SELECT user_id, email, role
FROM accounts
WHERE username = $1 AND password_hash = $2;
The database lexer and parser process this statement once. The engine generates the AST and execution plan. At this stage, the structure of the query is frozen.
Phase 2: Bind and Execute (Evaluation Step)
The client transmits the variable values in a separate binary protocol packet.
Bind Parameters:
$1 = "admin' OR '1'='1"
$2 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
The database engine feeds these binary values directly into the execution nodes designated for $1 and $2.
Because the parsing phase concluded in Phase 1, the parser never touches the bound parameters. Even if the parameter contains single quotes, semicolons, comments, or nested SQL commands, the engine treats it purely as a scalar literal value. The attacker’s payload is rendered completely inert.
+---------------------------------------------------------------------------------+
| DATABASE ENGINE MEMORY BOUNDARY |
| |
| +------------------------------------+ |
| | Prepared Statement (Pre-compiled) | |
| | AST: Filter(username == [Param 1] | |
| | AND hash == [Param 2]) | |
| +------------------------------------+ |
| ▲ |
| │ (Values injected directly into execution leaf nodes) |
| +----------------┴-------------------+ |
| | Bound Parameters Packet | |
| | [Param 1] = "admin' OR 1=1;--" | <-- Treated as raw 16-byte literal. |
| | [Param 2] = "hash_hex_bytes" | Cannot create new AST branches. |
| +------------------------------------+ |
+---------------------------------------------------------------------------------+
3. Production Implementations Across Common Tech Stacks
Modern enterprise architectures use a variety of programming languages and database interfaces. Below are production-ready implementations demonstrating both unsafe patterns and their secure, parameterized counterparts.
A. TypeScript / Node.js
1. Native PostgreSQL Client (pg)
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
});
// VULNERABLE: Direct template literal interpolation
export async function unsafeGetUser(reqUsername: string) {
const query = `SELECT id, email, created_at FROM users WHERE username = '${reqUsername}'`;
const result = await pool.query(query); // CRITICAL: SQL Injection vector
return result.rows[0];
}
// SECURE: Explicit bind parameters ($1)
export async function secureGetUser(reqUsername: string) {
const query = `
SELECT id, email, created_at
FROM users
WHERE username = $1
LIMIT 1;
`;
const result = await pool.query(query, [reqUsername]);
return result.rows[0] ?? null;
}
2. Prisma ORM: The Safe vs Unsafe API
A common misconception is that using an ORM automatically guarantees SQLi immunity. In reality, modern ORMs offer raw query escape hatches that can reintroduce severe vulnerabilities if misused.
import { PrismaClient, Prisma } from '@prisma/client';
const prisma = new PrismaClient();
// VULNERABLE: $queryRawUnsafe allows raw unparameterized string concatenation
export async function unsafePrismaSearch(searchTerm: string) {
// HIGH RISK: Direct string concatenation bypasses Prisma's parameterization
return await prisma.$queryRawUnsafe(
`SELECT * FROM products WHERE name LIKE '%${searchTerm}%'`
);
}
// SECURE: $queryRaw with Tagged Template Literals
export async function securePrismaSearch(searchTerm: string) {
// Prisma's tagged template literal converts ${...} into parameterized placeholders automatically
const wildcard = `%${searchTerm}%`;
return await prisma.$queryRaw<Array<{ id: number; name: string }>>`
SELECT id, name, price
FROM products
WHERE name ILIKE ${wildcard}
`;
}
B. Python
1. psycopg3 (PostgreSQL Driver)
import psycopg
from typing import Optional, Dict, Any
# VULNERABLE: Python f-string or % formatting creates a merged SQL string
def unsafe_authenticate(conn: psycopg.Connection, user_input: str) -> Optional[Dict[str, Any]]:
with conn.cursor() as cur:
# DO NOT DO THIS: Python evaluates f-string BEFORE passing to the driver
query = f"SELECT id, role FROM accounts WHERE api_key = '{user_input}'"
cur.execute(query)
return cur.fetchone()
# SECURE: Database-level parameter placeholder (%s)
def secure_authenticate(conn: psycopg.Connection, user_input: str) -> Optional[Dict[str, Any]]:
with conn.cursor() as cur:
query = """
SELECT id, role
FROM accounts
WHERE api_key = %s
LIMIT 1;
"""
# Pass parameters as a separate tuple; driver binds them safely
cur.execute(query, (user_input,))
row = cur.fetchone()
if not row:
return None
return {"id": row[0], "role": row[1]}
2. SQLAlchemy (Core / ORM)
from sqlalchemy import text
from sqlalchemy.engine import Engine
# VULNERABLE: Passing concatenated string into text()
def unsafe_lookup(engine: Engine, tenant_id: str):
with engine.connect() as conn:
stmt = text(f"SELECT * FROM invoices WHERE tenant_id = '{tenant_id}'")
return conn.execute(stmt).fetchall()
# SECURE: Using bound parameters (:param)
def secure_lookup(engine: Engine, tenant_id: str):
with engine.connect() as conn:
stmt = text("SELECT id, amount, status FROM invoices WHERE tenant_id = :tenant")
result = conn.execute(stmt, {"tenant": tenant_id})
return result.mappings().all()
C. Go (database/sql)
package main
import (
"context"
"database/sql"
"errors"
"time"
_ "github.com/lib/pq"
)
type Account struct {
ID int64
Email string
}
// VULNERABLE: fmt.Sprintf concatenates user string into SQL text
func UnsafeFindAccount(ctx context.Context, db *sql.DB, email string) (*Account, error) {
// CRITICAL SECURITY RISK
query := "SELECT id, email FROM accounts WHERE email = '" + email + "' LIMIT 1;"
row := db.QueryRowContext(ctx, query)
var acc Account
if err := row.Scan(&acc.ID, &acc.Email); err != nil {
return nil, err
}
return &acc, nil
}
// SECURE: Positional parameters passed to QueryRowContext
func SecureFindAccount(ctx context.Context, db *sql.DB, email string) (*Account, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
const query = `
SELECT id, email
FROM accounts
WHERE email = $1
LIMIT 1;
`
var acc Account
err := db.QueryRowContext(ctx, query, email).Scan(&acc.ID, &acc.Email)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &acc, nil
}
D. Java (JDBC & Spring Data)
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class SecurityRepository {
// VULNERABLE: Statement + String concatenation
public boolean unsafeCheckStatus(Connection conn, String tenantCode) throws SQLException {
var stmt = conn.createStatement();
String sql = "SELECT active FROM tenants WHERE code = '" + tenantCode + "'";
var rs = stmt.executeQuery(sql); // EXPLOITABLE
return rs.next() && rs.getBoolean("active");
}
// SECURE: PreparedStatement with strongly typed parameter setters
public boolean secureCheckStatus(Connection conn, String tenantCode) throws SQLException {
final String sql = "SELECT active FROM tenants WHERE code = ? LIMIT 1";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, tenantCode);
try (ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
return rs.getBoolean("active");
}
return false;
}
}
}
}
4. Complex Edge Cases: Where Prepared Statements Cannot Be Used
One of the most dangerous blind spots in database security occurs when developers encounter SQL constructs that cannot be parameterized.
Database engines only support bind parameters where a scalar literal data value is expected (in WHERE, VALUES, SET, or HAVING clauses). The SQL grammar specification forbids bind parameters in place of:
- Table names (
FROM $1) - Column names (
SELECT $1 FROM ...) - Sort directions (
ORDER BY name $1) - Structural keywords (
SELECT * FROM users $1)
Attempting to parameterize an identifier results in a syntax error:
-- Fails with syntax error:
PREPARE bad_statement AS SELECT * FROM $1;
When engineers realize parameters fail on identifiers, they frequently revert to string concatenation, opening a severe vulnerability. Here is how to handle these edge cases securely.
Edge Case 1: Dynamic Table and Column Identifiers (Strict Allowlisting)
To allow dynamic sorting or dynamic table selection, implement Strict Allowlisting combined with database identifier quoting:
// Type-safe domain definition of allowable sort columns
export const SORTABLE_COLUMNS = {
created_at: 'created_at',
user_email: 'email',
total_spend: 'total_spend',
} as const;
export type SortableColumn = keyof typeof SORTABLE_COLUMNS;
export type SortDirection = 'ASC' | 'DESC';
export async function fetchUsersSorted(
pool: Pool,
requestedSortCol: string,
requestedDirection: string,
limit: number
) {
// 1. Validate against strict allowlist (Dictionary Lookup)
if (!Object.prototype.hasOwnProperty.call(SORTABLE_COLUMNS, requestedSortCol)) {
throw new Error(`Invalid sort column: ${requestedSortCol}`);
}
// 2. Validate sort direction against allowed enumeration
const normalizedDir: SortDirection = requestedDirection.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const safeColumnIdentifier = SORTABLE_COLUMNS[requestedSortCol as SortableColumn];
// 3. The identifier is now guaranteed to be a deterministic static token.
// Bind scalar values ($1) while injecting the verified identifier.
const query = `
SELECT id, email, created_at, total_spend
FROM user_profiles
ORDER BY "${safeColumnIdentifier}" ${normalizedDir}
LIMIT $1;
`;
const result = await pool.query(query, [limit]);
return result.rows;
}
In PostgreSQL PL/pgSQL: Use quote_ident()
If writing dynamic queries inside PostgreSQL functions, use format() with %I (identifier escaping) and %L (literal escaping):
CREATE OR REPLACE FUNCTION get_table_count(target_table text)
RETURNS bigint AS $$
DECLARE
total bigint;
BEGIN
-- %I safely quotes and escapes table/schema identifiers
EXECUTE format('SELECT count(*) FROM %I', target_table) INTO total;
RETURN total;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Edge Case 2: The LIKE Clause Wildcard Trap
While parameterized queries prevent SQL injection inside a LIKE clause, unescaped wildcards allow attackers to perform resource exhaustion (ReDoS / Full Table Scans) or logic probing.
In SQL, % matches zero or more characters, and _ matches exactly one character. If a user queries for _ or %, the database executes an unintended full pattern match.
export function sanitizeLikePattern(input: string): string {
// Escape backslash first, then escape wildcards % and _
return input
.replace(/\\/g, '\\\\')
.replace(/%/g, '\\%')
.replace(/_/g, '\\_');
}
// Usage in query:
export async function searchByPrefix(pool: Pool, userPrefix: string) {
const safeWildcard = `${sanitizeLikePattern(userPrefix)}%`;
// Parameterized query ensures SQL structure is safe;
// Wildcard sanitization ensures search semantics are safe.
return await pool.query(
'SELECT id, name FROM inventory WHERE sku LIKE $1 ESCAPE \'\\\'',
[safeWildcard]
);
}
Edge Case 3: Dynamic IN (...) Lists
Constructing dynamic WHERE id IN (?) queries with an arbitrary number of parameters often leads developers to construct raw comma-separated strings.
The Safe Approach in PostgreSQL: Array Comparison (ANY)
PostgreSQL natively allows binding an entire array as a single scalar parameter:
// SECURE: Single bind parameter containing an array of integers
export async function fetchAccountsByIds(pool: Pool, ids: number[]) {
const query = `
SELECT id, email, balance
FROM accounts
WHERE id = ANY($1::bigint[]);
`;
return (await pool.query(query, [ids])).rows;
}
The Safe Approach in SQLite / MySQL: Dynamic Placeholder Mapping
For databases without native array parameters (such as SQLite or MySQL), generate an exact sequence of parameter markers matching the array length:
export async function fetchRecordsByKeys(db: any, keys: string[]) {
if (keys.length === 0) return [];
// Generate placeholder string: "$1, $2, $3" or "?, ?, ?"
const placeholders = keys.map((_, idx) => `$${idx + 1}`).join(', ');
const query = `SELECT id, name FROM items WHERE key IN (${placeholders})`;
// Pass array items directly as bind parameters
return await db.query(query, keys);
}
Edge Case 4: Second-Order SQL Injection
Second-Order SQL Injection occurs when untrusted input is stored safely in the database (via parameterized insert), but is later retrieved and concatenated unsafely into a second query by a background job, reporting worker, or migration script.
Step 1 (Safe Ingestion):
User registers with username: "admin' --"
Query: INSERT INTO users (username) VALUES ($1); <-- Safe! Stored in DB as literal string.
Step 2 (Vulnerable Consumption):
Nightly analytics worker queries user records:
for user in get_all_users():
# VULNERABLE: Developer assumes data inside their own DB is trusted!
db.execute(f"UPDATE analytics SET score = 100 WHERE username = '{user.username}'")
# Truncates the query and compromises admin account analytics!
Rule: Treat data retrieved from your own database with the exact same zero-trust model as raw HTTP request input. Always parameterize queries across batch jobs, worker services, and internal dashboards.
5. Architectural Defense-in-Depth
Relying on application code alone leaves systems vulnerable to human error. A robust security posture implements defense-in-depth across the infrastructure, network, and database configuration layers.
+-------------------------------------------------------------------------------+
| LAYER 1: PERIMETER & NETWORK |
| - Web Application Firewall (AWS WAF / Cloudflare) detecting anomalous inputs |
| - Strict VPC isolation (No public IP assigned to Database Instances) |
+-------------------------------------------------------------------------------+
│
▼
+-------------------------------------------------------------------------------+
| LAYER 2: APPLICATION RUNTIME |
| - Mandatory Parameterized Queries (PreparedStatement / ORM type-safety) |
| - Input Validation & Schema Enforcement (Zod, Pydantic) |
| - Dynamic Identifier Allowlists (Zero untrusted token in SQL grammar) |
+-------------------------------------------------------------------------------+
│
▼
+-------------------------------------------------------------------------------+
| LAYER 3: DATABASE ACCESS & ROLES |
| - Least Privilege Roles: Revoke DDL (CREATE, DROP, ALTER) from App User |
| - Segregated Read-Only and Read-Write Service Accounts |
| - Disabled Administrative Extensions (xp_cmdshell, pg_read_file) |
+-------------------------------------------------------------------------------+
│
▼
+-------------------------------------------------------------------------------+
| LAYER 4: CI/CD & AUDIT LOGGING |
| - SAST scanning (Semgrep, CodeQL) blocking raw SQL string interpolation |
| - Database Audit Logging (pgAudit) alerting on syntax errors & spikes |
+-------------------------------------------------------------------------------+
1. Principle of Least Privilege in Database Permissions
Never connect your web backend to the database using postgres, root, or any administrative account. If an application user account is compromised, administrative rights allow attackers to read arbitrary files, write webshells to disk, or execute operating system commands.
Configure dedicated database roles with the minimum necessary table-level privileges:
-- 1. Create a dedicated application role
CREATE ROLE app_runtime WITH LOGIN PASSWORD 'strong_unique_production_secret';
-- 2. Revoke default public schema privileges
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO app_runtime;
-- 3. Grant specific DML permissions ONLY on required business tables
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE orders, customers, products TO app_runtime;
-- 4. Explicitly deny DDL operations
-- Do not grant CREATE, DROP, ALTER, TRUNCATE, or SUPERUSER
REVOKE ALL ON TABLE migrations, system_logs FROM app_runtime;
2. Disable High-Risk Operating System Extensions
Attackers use specific database features to convert SQL injection into full system takeover:
- PostgreSQL: Ensure
COPY ... PROGRAMis restricted strictly to superusers and untrusted roles cannot accesspg_read_file()or load foreign data wrappers. - MySQL: Set
local_infile = 0inmy.cnfto block arbitrary client-side file reading, and configuresecure_file_privto restrict file export directories. - Microsoft SQL Server: Disable
xp_cmdshellusingsp_configure.
3. Automated Static Analysis in CI/CD (Semgrep Rules)
Catch unparameterized SQL before it reaches production by enforcing static analysis checks in your pull request pipeline.
Below is an actionable Semgrep rule that detects string interpolation inside PostgreSQL query calls:
rules:
- id: prevent-raw-sql-interpolation
languages: [typescript, javascript]
message: "Detected potential SQL injection: pool.query called with concatenated or template string. Use parameterized queries ($1, $2)."
severity: ERROR
patterns:
- pattern-either:
- pattern: $POOL.query(`...${...}...`, ...)
- pattern: $POOL.query($X + $Y, ...)
- pattern: $PRISMA.$queryRawUnsafe(...)
6. Try It Yourself: Interactive SQL Lab Simulation
To understand how single quotes affect statement boundaries in SQLite, test the queries below inside the SQL Lab.
Step 1: Database Setup
Copy and paste this script into SQL Lab and run it once to initialize the test tables:
CREATE TABLE blog_vulnerable_users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
role TEXT NOT NULL,
balance INTEGER NOT NULL
);
INSERT INTO blog_vulnerable_users VALUES
(1, 'alice', 'admin', 5000),
(2, 'bob', 'user', 150),
(3, 'charlie', 'user', 200);
Step 2: The Attack Simulation
If an application constructs the query via concatenation:
-- Normal intended query:
SELECT username, balance FROM blog_vulnerable_users WHERE username = 'alice';
Expected Result (Single User):
| username | balance |
|---|---|
| alice | 5000 |
When an attacker inputs ' OR 1=1 --:
SELECT username, balance FROM blog_vulnerable_users WHERE username = '' OR 1=1 --';
Exploited Result (All Data Leaked):
| username | balance |
|---|---|
| alice | 5000 |
| bob | 150 |
| charlie | 200 |
Step 3: Parameterized Defense in SQLite
In prepared statements, SQLite compiles the statement once:
-- SQLite engine treats the entire input string as a literal match
SELECT username, balance
FROM blog_vulnerable_users
WHERE username = ''' OR 1=1 --';
Result: Zero rows returned. The injection is completely neutralised.
7. Common SQL Injection Myths & Misconceptions
| Myth | Reality | Technical Reason |
|---|---|---|
| ”Stored Procedures automatically prevent SQLi” | FALSE | If the stored procedure constructs dynamic SQL using EXECUTE format(...) or sp_executesql with concatenated arguments, it is just as vulnerable as application-level string concatenation. |
| ”ORMs make our application 100% immune” | FALSE | ORM methods like findUnique() are safe, but raw queries ($queryRawUnsafe, sequelize.literal, extra()) bypass ORM safeguards and frequently cause breaches. |
| ”Input validation replaces prepared statements” | FALSE | Validation (e.g. checking if an input is an email) is good practice, but complex text inputs (addresses, comments, names with apostrophes like O'Connor) require single quotes. Parameterization handles all characters safely without data loss. |
| ”Escaping quotes with backslashes is sufficient” | FALSE | Character encoding mismatches (such as multi-byte GBK / Big5 wide character injections where 0xbf27 eats the backslash) bypass naive quote escaping. Only parameterization is immune. |
| ”WAFs fully protect against SQL injection” | FALSE | WAFs rely on signature matching and heuristics. Attackers consistently bypass WAF rules using inline SQL comments (UN/**/ION), alternative encodings, and whitespace manipulation. |
8. Frequently Asked Questions (FAQ)
What is the single most effective way to prevent SQL injection?
The single most effective defense is parameterized queries (prepared statements). Parameterized queries enforce strict separation between code and data at the database protocol level, ensuring untrusted user input cannot alter the query’s execution plan.
Can table names or column names be parameterized in SQL?
No. SQL standards and database query planners require identifiers (table and column names) during the compilation/parsing phase to locate schemas and build access plans. To safely handle dynamic identifiers, validate user inputs against a strict server-side allowlist of pre-approved column names, or use dialect-specific identifier quoting functions such as PostgreSQL’s quote_ident().
How does second-order SQL injection differ from first-order SQL injection?
In a first-order attack, the malicious payload is executed immediately in the query processing the request. In a second-order attack, the payload is safely stored in the database first (e.g., via a parameterized query), but is later read by another service, scheduled job, or administrative dashboard and concatenated unsafely into a second SQL query.
Does input escaping (e.g., addslashes or replacing ' with '') completely prevent SQL injection?
No. Escaping is error-prone and vulnerable to character set encoding vulnerabilities (such as wide-character multi-byte attacks), numeric field injections (where quotes are not required to alter query logic), and developer oversights. Parameterization is the only mathematically proven prevention mechanism.
What database permissions should application service accounts have?
Application service accounts should operate under the Principle of Least Privilege:
- Grant only
SELECT,INSERT,UPDATE, andDELETEon specific application tables. - Revoke all DDL permissions (
CREATE,DROP,ALTER,TRUNCATE). - Disallow access to database administrative features (
xp_cmdshell, file system read/write utilities). - Maintain separate read-only connection pools for read-heavy reporting queries.
Summary Checklist for Software Architects
- 100% Parameterization: Every dynamic query using user input uses prepared statements (
$1,?, or named bind variables). - Audit ORM Raw Escapes: Identify and refactor all occurrences of
queryRawUnsafe,sequelize.literal, and native unparameterized queries. - Strict Allowlisting for Identifiers: All dynamic sorting columns and table references match strict server-side enumerations.
- Zero-Trust Second-Order Processing: Internal workers and batch jobs use parameterized queries when reading stored data.
- Least Privilege Database Roles: Applications run under non-root, non-superuser accounts stripped of DDL privileges.
- Automated CI/CD Guardrails: Semgrep, CodeQL, and SAST linters fail builds if unparameterized query methods are introduced.
Ready to Level Up Your SQL?
Don't just read about databases. Write queries, build schemas, and practice real-world interview questions interactively.
Start Learning for Free