What SQL injection actually is

SQL injection happens when untrusted input is inserted into a SQL statement in a way that lets it change the statement's structure, not just its data. A login form that builds its query like this is the textbook example:

vulnerable.sql
-- Application code builds this string directly from form input:
SELECT * FROM users
WHERE username = '' OR '1'='1' --' AND password = '...';

The input ' OR '1'='1' -- doesn't just fill in a value — it rewrites the WHERE clause into something that's always true and comments out the rest. That's the core problem: user input is being treated as part of the SQL language itself instead of as inert data.

Primary defense: parameterized queries

With a parameterized query, the SQL structure is fixed ahead of time and sent to the database separately from the values. The database knows, before it ever sees the input, exactly where a value belongs and treats it strictly as data — it cannot be reinterpreted as SQL syntax no matter what characters it contains.

parameterized.sql
-- The query shape is fixed; the value is bound as a parameter, not concatenated:
SELECT * FROM users
WHERE username = @username AND password_hash = @password_hash;

-- @username = "' OR '1'='1' --" is now just a literal string value to compare,
-- not a fragment of SQL — the query returns zero rows, as it should.

Every mainstream language and driver supports this: prepared statements in JDBC, ? or named placeholders in PDO/psycopg2, parameterized commands in ADO.NET, and equivalent APIs everywhere else. There is essentially no legitimate reason for new application code in 2026 to build a query by concatenating raw input into a SQL string.

Stored procedures aren't automatically safe

A stored procedure is only as safe as what happens inside it. If a procedure takes a parameter and then concatenates it into a dynamic SQL string before executing that string, it has simply moved the vulnerability one layer deeper:

unsafe-proc.sql
CREATE PROCEDURE search_users (@name NVARCHAR(100))
AS
BEGIN
  DECLARE @sql NVARCHAR(MAX);
  SET @sql = 'SELECT * FROM users WHERE name = ''' + @name + '''';
  EXEC(@sql);  -- still vulnerable: @name is concatenated, not bound
END;

The fix, when dynamic SQL genuinely is required inside a procedure (dynamic table names, optional filter clauses), is to build the query text dynamically but still bind the actual values as parameters via sp_executesql or the equivalent, rather than folding them into the string:

safe-dynamic-proc.sql
CREATE PROCEDURE search_users (@name NVARCHAR(100))
AS
BEGIN
  DECLARE @sql NVARCHAR(MAX) = N'SELECT * FROM users WHERE name = @name_param';
  EXEC sp_executesql @sql, N'@name_param NVARCHAR(100)', @name_param = @name;
END;

Where ORMs quietly reopen the hole

Standard query-builder methods on most ORMs (Django's ORM, SQLAlchemy's query API, Entity Framework, ActiveRecord) parameterize automatically. The risk almost always comes from the raw-SQL escape hatch every ORM also provides, used for things a query builder doesn't handle cleanly — dynamic column sorting, complex full-text search, vendor-specific functions:

orm-raw-escape-hatch.py
# Vulnerable: sort_column comes from a query string, dropped straight into raw SQL
query = f"SELECT * FROM products ORDER BY {sort_column}"
db.execute_raw(query)

# Safe: validate sort_column against an allow-list of real column names first
ALLOWED_SORT_COLUMNS = {"price", "name", "created_at"}
if sort_column not in ALLOWED_SORT_COLUMNS:
    raise ValueError("Invalid sort column")
query = f"SELECT * FROM products ORDER BY {sort_column}"  # now safe: value is from a fixed set

Column and table names can't be passed as bound parameters the way values can — parameter binding is for data, not for SQL identifiers — so dynamic sorting/filtering by column name has to be secured with allow-list validation against known-safe identifiers, exactly as shown above, not with parameterization.

Secondary defense: allow-list input validation

Validation doesn't replace parameterized queries, but it's a genuinely useful second layer: it catches malformed input before it reaches any query logic, and it's essential for the identifier cases above where parameter binding doesn't apply. The key distinction is allow-list versus blocklist:

Blocklisting — trying to detect and strip "dangerous" characters or keywords like OR, --, or quotes — is unreliable. Attackers have many ways to encode, case-vary, or reformat a payload to slip past a blocklist while it still executes once it reaches the database. Validate against what the input should look like (a known set of column names, a numeric ID pattern, an enum of valid statuses) rather than trying to enumerate everything it shouldn't contain.

Escaping: last resort, not a strategy

Manually escaping quotes and special characters before concatenating input into a query is the oldest mitigation and the least reliable one — it's database-specific, easy to get subtly wrong (different encodings, multi-byte characters, second-order injection from data that was "safe" when first stored), and it's exactly what parameterized queries make unnecessary. It has a place only when a specific driver or legacy codebase genuinely offers no parameterization path — and even then, it should be treated as a stopgap while that path gets added.

Least privilege and defense in depth

Parameterization prevents the injection. Everything below limits the damage on the day some other defense fails anyway:

  • Least-privilege database accounts — the application's DB user should only have the permissions it actually needs (SELECT/INSERT/UPDATE on specific tables), never rights to drop tables, alter schema, or read unrelated databases.
  • Generic error messages — returning raw database error text to the client hands attackers a map of the schema; log the detail server-side and return a generic message to the user.
  • A web application firewall — can catch known injection patterns in transit, but it's a net, not a fix; it should never be the only defense for a known-vulnerable query.
  • Regular testing — static analysis (SAST) on the codebase and dynamic testing (tools like sqlmap in a controlled environment) against staging catches concatenated-query mistakes before they ship.

Common mistakes

  • Assuming "we use an ORM" means "we're safe" — the raw-SQL escape hatch is where injection usually creeps back in.
  • Parameterizing values but not validating identifiers — column/table names used in dynamic sorting or filtering need allow-list validation, since they can't be bound as parameters.
  • Treating a stored procedure as inherently safe regardless of what it does internally with its parameters.
  • Relying on a blocklist of "bad" keywords or characters instead of validating against what valid input should actually look like.
  • Running the application under a database account with far more privilege than it needs, turning a contained injection into a full schema compromise.

Key takeaways

  • Parameterized queries (prepared statements) are the primary defense — build the query structure once, bind input as data, never concatenate it into SQL text.
  • Stored procedures and ORMs are only as safe as what happens inside them; both have escape hatches (dynamic SQL, raw query methods) that can reintroduce concatenation.
  • Column and table names can't be parameterized like values — secure dynamic identifiers with allow-list validation instead.
  • Escaping is a last resort for cases with no parameterization path, not a primary strategy.
  • Least privilege, generic error messages, and testing don't prevent injection — they limit the damage when some other defense fails.