Skip to content

SQL Injection Interview Questions & Answers

15 questions Updated 2026-06-20 Share:

SQL injection interview questions — how attacks work, parameterised queries, ORM safety, stored procedure risks, second-order injection, blind injection, WAFs, and prevention best practices.

Read the in-depth guideSQL Injection — How It Works and How to Prevent It(opens in new tab)
15 of 15

SQL injection (SQLi) is an attack where an adversary inserts malicious SQL syntax into user-supplied input that is concatenated directly into a query. The database executes the injected SQL as if it were written by the developer.

# Vulnerable: string concatenation with user input
username = request.get("username")   # attacker provides: ' OR '1'='1
query = "SELECT * FROM users WHERE username = '" + username + "'"
# Resulting SQL: SELECT * FROM users WHERE username = '' OR '1'='1'
# → returns ALL users! The attacker is logged in without credentials.

Consequences range from data theft (reading all rows), authentication bypass, data destruction (DROP TABLE), to remote code execution via database functions (xp_cmdshell in SQL Server).

Rule of thumb: SQL injection is consistently in OWASP's Top 10 list of critical web application vulnerabilities and is 100 % preventable with parameterised queries. Never concatenate user input into SQL strings.

A parameterised query separates the SQL structure from the data values. The query is sent to the database with placeholders, and the driver sends the data values separately. The database always treats the values as data — never as SQL syntax.

# Python + psycopg2 (Postgres) — SAFE
cur.execute(
    "SELECT * FROM users WHERE username = %s AND password_hash = %s",
    (username, password_hash)   # values sent separately, never interpolated
)
# Even if username = "' OR '1'='1", it is treated as a literal string,
# not SQL. The query finds no user named "' OR '1'='1".

# Node.js + pg — SAFE
const result = await client.query(
    'SELECT * FROM users WHERE id = $1',
    [userId]
);

# Java + JDBC — SAFE
PreparedStatement stmt = conn.prepareStatement(
    "SELECT * FROM orders WHERE customer_id = ?");
stmt.setInt(1, customerId);

Rule of thumb: always use parameterised queries (also called prepared statements) for any query that includes user-supplied data. This is the single most effective prevention against SQL injection.

Most ORM frameworks (SQLAlchemy, Django ORM, ActiveRecord, Hibernate) use parameterised queries by default, making their standard query API injection-safe. However, raw SQL escape hatches in ORMs can re-introduce the vulnerability.

# Django ORM — SAFE (uses parameterised queries internally)
User.objects.filter(username=username)

# Django raw() — UNSAFE if you concatenate input
User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'")

# Django raw() — SAFE with params
User.objects.raw("SELECT * FROM users WHERE username = %s", [username])

# SQLAlchemy — SAFE
session.execute(select(User).where(User.username == username))

# SQLAlchemy text() — UNSAFE if you concatenate
session.execute(text(f"SELECT * FROM users WHERE username = '{username}'"))

# SQLAlchemy text() — SAFE with bindparam
session.execute(text("SELECT * FROM users WHERE username = :u"), {"u": username})

Rule of thumb: use the ORM's type-safe query API wherever possible. When you must write raw SQL, always use parameterised bindings — never f-strings or string concatenation.

  1. In-band SQLi — data is extracted through the same channel as the attack (most common). Includes error-based (reading error messages) and union-based (appending UNION SELECT to leak data).
  2. Blind SQLi — the application does not return data but the attacker infers information from behaviour:
    • Boolean-based: send a true vs false condition; observe response differences.
    • Time-based: use pg_sleep() or SLEEP() to cause a delay if a condition is true.
  3. Out-of-band SQLi — data is exfiltrated via a different channel (DNS lookup, HTTP request) using database features like UTL_HTTP (Oracle) or xp_cmdshell (SQL Server).
-- Union-based example (attacker appends):
-- Original: SELECT name FROM products WHERE id = 1
-- Injected: id = 1 UNION SELECT password FROM users--
-- Result: returns product name AND user passwords

-- Time-based blind example:
-- id = 1; IF (SELECT COUNT(*) FROM users WHERE username='admin') > 0
--           BEGIN WAITFOR DELAY '0:0:5' END--
-- If the response is delayed 5 s, an 'admin' user exists.

Rule of thumb: parameterised queries prevent all forms of in-band and most out-of-band injection. Separately, disable dangerous stored procedures (xp_cmdshell, UTL_HTTP) unless explicitly required.

Yes — stored procedures that build dynamic SQL internally via string concatenation are still vulnerable. The parameter is safe from injection at the call site, but the SQL built inside the procedure is not.

-- SQL Server stored procedure — UNSAFE (dynamic SQL with concatenation)
CREATE PROCEDURE SearchProducts @SearchTerm NVARCHAR(100)
AS
BEGIN
  EXEC('SELECT * FROM products WHERE name LIKE ''%' + @SearchTerm + '%''')
  -- Attacker passes: '; DROP TABLE products; --
  -- Becomes: SELECT * FROM products WHERE name LIKE '%'; DROP TABLE products; --%'
END;

-- SAFE: use sp_executesql with parameters
CREATE PROCEDURE SearchProducts @SearchTerm NVARCHAR(100)
AS
BEGIN
  DECLARE @sql NVARCHAR(500) = N'SELECT * FROM products WHERE name LIKE @term';
  EXEC sp_executesql @sql, N'@term NVARCHAR(102)', @term = '%' + @SearchTerm + '%';
END;

Rule of thumb: dynamic SQL inside stored procedures must use sp_executesql with bound parameters (SQL Server), EXECUTE USING with $1 placeholders (Postgres PL/pgSQL), or PREPARE/EXECUTE equivalents. Never concatenate user input into a dynamic SQL string, even inside a stored procedure.

Second-order injection occurs in two steps:

  1. Malicious input is stored safely in the database (the initial insertion is parameterised and appears safe).
  2. Later, that stored value is retrieved and concatenated into a new SQL query without parameterisation — causing injection on the second use.
# Step 1: user registers with username = "admin'--"
# This INSERT is parameterised — safe at registration:
cur.execute("INSERT INTO users (username) VALUES (%s)", (username,))
# username = "admin'--" is stored harmlessly.

# Step 2: admin panel retrieves the username and uses it unsafely:
admin_username = fetch_user(user_id)["username"]  # → "admin'--"
cur.execute(f"SELECT * FROM audit_log WHERE actor = '{admin_username}'")
# → SELECT * FROM audit_log WHERE actor = 'admin'--'
# The -- comments out the rest → dumps all audit log rows

Rule of thumb: data from the database must be treated as untrusted when used in a new query — even if you stored it safely. Always use parameterised queries for every database query, including queries that use data retrieved from the database itself.

Input validation is a useful defence-in-depth measure but is not sufficient on its own to prevent SQL injection. Allowlists (accepting only known-good patterns) are more reliable than denylists (rejecting known-bad strings), but both can be bypassed by clever encoding or unexpected input formats.

# Denylist — INSUFFICIENT (easily bypassed with encoding)
if "'" in user_input or ";" in user_input:
    raise ValueError("Invalid input")
# Attacker uses URL encoding, Unicode lookalikes, or multi-byte tricks to bypass

# Allowlist — better but still not sufficient alone
import re
if not re.match(r'^[a-zA-Z0-9_]+$', username):
    raise ValueError("Invalid username")
# Better — but parameterised queries are STILL required as the primary defence

The correct stack:

  1. Parameterised queries — primary defence (mandatory)
  2. Input validation/allowlists — secondary, reduces attack surface
  3. Least privilege — limits blast radius if injection occurs
  4. WAF — tertiary, may block some automated scans

Rule of thumb: validate inputs AND use parameterised queries. Input validation is not a substitute for parameterisation — it is an additional layer. If you have to choose one, choose parameterised queries.

Verbose database error messages expose the query structure, table names, column names, and database version to an attacker — information used to refine an injection attack (error-based SQLi).

# BAD: returning the raw database error to the client
except Exception as e:
    return {"error": str(e)}
# Attacker sees: 'column "passwrd" does not exist' → typo in column name revealed
# Or: 'relation "users" does not exist' → table name confirmed

# GOOD: log the full error server-side; return a generic message to the client
import logging
except Exception as e:
    logging.error("Database error: %s", e, exc_info=True)
    return {"error": "An internal error occurred. Please try again."}

Rule of thumb: never expose raw database error messages to end users. Log them server-side with full stack traces and return a generic "internal error" to the client. Use different log levels (DEBUG in development, ERROR in production) to ensure errors are visible to developers but not attackers.

A WAF inspects HTTP requests and blocks patterns that look like SQL injection attempts (quotes, SQL keywords in unusual positions, encoded payloads). It provides a useful additional layer but should not be the primary defence.

Limitations of WAFs:

  • They can be bypassed with obfuscation (encoding, case variation, comments).
  • They may produce false positives, blocking legitimate requests.
  • They do nothing for second-order injection (the attack comes from the database, not HTTP).
  • They are a perimeter control — if bypassed, no protection remains.
Defence-in-depth layers (innermost = most important):
4. WAF             — blocks automated scans, buys time
3. Input validation — reduces attack surface
2. Least privilege  — limits blast radius
1. Parameterised queries — PRIMARY DEFENCE (cannot be bypassed)

Rule of thumb: deploy a WAF as a defence-in-depth measure, not as a substitute for parameterised queries. A WAF buys you protection against automated tools and script kiddies; a determined attacker will bypass it.

SQL injection is specific to SQL databases, but analogous NoSQL injection attacks exist. MongoDB, for example, is vulnerable to operator injection when user input is used directly in a query object.

// MongoDB — UNSAFE: user controls the query operator
const username = req.body.username;  // attacker sends: { "$ne": null }
const user = await User.findOne({ username: username });
// Becomes: db.users.findOne({ username: { $ne: null } })
// → returns the FIRST user in the collection, bypassing login!

// SAFE: validate that username is a string before using it
if (typeof username !== 'string') throw new Error('Invalid input');
const user = await User.findOne({ username: username });

The prevention principle is the same: never allow untrusted input to control query structure. In MongoDB, validate types strictly; in Redis, never concatenate user input into Lua scripts.

Rule of thumb: the injection principle extends beyond SQL — any query language that mixes structure and data is potentially vulnerable when user input influences the structure. Validate types and use library-provided safe query builders for every database technology.

Mass assignment is not SQL injection, but is an ORM-related vulnerability where an attacker sets database columns they should not control by sending extra fields in an HTTP request body.

# Django — VULNERABLE to mass assignment
# Attacker sends POST: { "username": "alice", "is_admin": true }
user = User(**request.POST.dict())  # copies ALL fields including is_admin!
user.save()

# SAFE: use an explicit allowlist of fields
user = User(
    username=request.POST['username'],
    email=request.POST['email'],
    # is_admin NOT included — cannot be set by the user
)

# Django Forms provide this automatically:
form = UserRegistrationForm(request.POST)  # only processes declared fields
if form.is_valid():
    form.save()

Rule of thumb: never pass raw request data directly to ORM constructors or update() calls. Always explicitly allowlist the fields that users are permitted to set, and never expose internal fields like is_admin, role, or account_balance to user-controlled input.

# 1. Code review: grep for string interpolation into SQL
# Dangerous patterns in Python:
# f"SELECT ... {user_input}"
# "SELECT ... " + variable
# "SELECT ... %s" % variable   ← % formatting bypasses parameterisation!
# cursor.execute("... " + x)

# 2. Automated scanning tools:
# - sqlmap (black-box: tests a live endpoint for injection)
# - Bandit (Python SAST: flags unsafe DB calls)
# - Semgrep rules for SQL injection patterns
# - OWASP ZAP (web app scanner)

# 3. Database query logs: look for queries with unusual quoting
# Postgres: log_min_duration_statement = 0 + pg_stat_statements

# 4. Unit tests for injection payloads
def test_no_injection():
    result = search_products("' OR '1'='1")
    assert len(result) == 0  # should find nothing, not all products

Rule of thumb: include injection payload tests in your test suite for every query that accepts user input. Run sqlmap or a similar scanner against staging environments before release. Add a Semgrep or Bandit check to CI to catch string concatenation into SQL at code-review time.

ORDER BY column names and directions cannot be passed as bind parameters — only values can. This means dynamic sorting is a common injection vector when developers concatenate the sort column directly from user input.

# UNSAFE: attacker controls column name
column = request.args.get("sort")  # attacker sends: "1; DROP TABLE orders; --"
cur.execute(f"SELECT * FROM orders ORDER BY {column}")  # injection!

# SAFE: allowlist of permitted column names
ALLOWED_SORT_COLUMNS = {"id", "created_at", "total", "status"}
ALLOWED_DIRECTIONS  = {"ASC", "DESC"}

column    = request.args.get("sort",      "created_at")
direction = request.args.get("direction", "DESC").upper()

if column not in ALLOWED_SORT_COLUMNS:
    column = "created_at"   # fall back to safe default
if direction not in ALLOWED_DIRECTIONS:
    direction = "DESC"

# Now safe to interpolate — values are from a known-good set
cur.execute(f"SELECT * FROM orders ORDER BY {column} {direction}")

Rule of thumb: for any dynamic SQL identifier (column name, table name, schema name), use an explicit allowlist — never accept the raw user value. Bind parameters cannot protect identifiers, only values.

Escaping modifies the input string to neutralise special characters (e.g., replacing ' with '') before interpolating it into SQL. Parameterising sends the SQL structure and data values to the database as separate payloads — the driver handles quoting internally.

# Escaping — FRAGILE (easily bypassed with multi-byte character tricks)
name = user_input.replace("'", "''")
cur.execute(f"SELECT * FROM users WHERE name = '{name}'")

# Parameterising — CORRECT
cur.execute("SELECT * FROM users WHERE name = %s", (user_input,))

Why escaping is unreliable:

  • Requires knowing all dangerous characters for the current charset.
  • Multi-byte encodings (GBK, BIG5) can hide a ' byte inside a two-byte sequence, making replace("'", "''") ineffective.
  • A single missed escape anywhere in a large codebase is a vulnerability.

Rule of thumb: never escape and interpolate — always parameterise. Escaping is a last resort when a driver or ORM provides no parameterisation option and you must write raw SQL. Even then, use the driver's official escaping function (psycopg2.extensions.adapt, mysqli_real_escape_string) — never roll your own.

If injection does occur despite parameterisation (e.g., via a legacy code path), least privilege limits the blast radius — the attacker can only do what the compromised database account can do.

-- BAD: application uses a superuser or DBA account
-- Attacker can: DROP TABLE, read pg_shadow (password hashes), run COPY TO,
--               call xp_cmdshell (SQL Server), access all schemas

-- GOOD: application uses a restricted role
CREATE ROLE app_runtime NOINHERIT;
GRANT SELECT, INSERT, UPDATE, DELETE ON orders    TO app_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE ON customers TO app_runtime;
GRANT SELECT ON products TO app_runtime;
-- NOT granted: DROP TABLE, ALTER, TRUNCATE, CREATE, COPY TO/FROM
-- NOT granted: pg_read_file, pg_execute_server_program
-- NOT granted: access to other schemas

-- Even with injection, attacker can only DML on those three tables

Rule of thumb: least privilege is not a substitute for parameterised queries, but it is an essential backstop. A successful injection against a read-only reporting account is far less damaging than one against a DBA account. Always pair both controls.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel