How to fix SQL injection
SQL injection has been the most-known web vulnerability for 25 years and still ships to production constantly — usually because of edge cases ORMs don't cover, dynamic ORDER BY clauses, and string interpolation that 'looks safe'. This guide covers the fix, the gotchas, and the tests.
What is SQL Injection?
SQL injection happens when user-controlled input is concatenated into a SQL query as code rather than treated as data. The textbook example: SELECT * FROM users WHERE name = '" + userInput + "'. If userInput is admin' OR '1'='1, the database receives SELECT * FROM users WHERE name = 'admin' OR '1'='1' — and returns every user row.
The textbook fix has been known for 25 years: parameterized queries (also called prepared statements). Send the SQL template and the parameter values separately. The database treats parameters as values, never as code, no matter what they contain. So why does SQL injection still account for OWASP's #3 vulnerability category in 2026?
Because there are corners that parameterization doesn't cover. Dynamic table names, dynamic column names in ORDER BY, raw SQL inside ORM string-builders, query interpolation in stored procedures, NoSQL operator injection (which looks nothing like SQLi but plays out the same way), and most commonly — the one query where the developer thought 'this input is safe' and used string concatenation to save five minutes.
SQL injection is also a force multiplier for other vulnerabilities. SQLi can read your password hashes (then crack them offline), exfiltrate API keys from configuration tables, escalate to RCE through xp_cmdshell or COPY FROM PROGRAM, or simply dump every table including the auth ones. Unlike many bugs, SQLi often has 100% impact — full database compromise.
What an attacker can do
The concrete impact of leaving SQL Injection unpatched.
Full database read access
UNION-based SQLi extracts every row of every table — users, orders, payment data, internal admin tables. One query = entire database dump.
Authentication bypass
OR '1'='1 in login forms returns the admin row regardless of password. Classic but still ships to production constantly in custom auth code.
Data tampering and destruction
DROP TABLE, UPDATE all rows, INSERT fake admin accounts. SQLi often allows write where the original endpoint was read-only.
RCE via database features
MSSQL xp_cmdshell, PostgreSQL COPY FROM PROGRAM, MySQL UDF — pivot from SQLi to shell on the database server. Frequent in misconfigured legacy DBs.
Reading server filesystem
LOAD_FILE() in MySQL, pg_read_file in Postgres. Dump /etc/passwd, AWS credentials, .env files via SQLi where the DB user has filesystem access.
Bypass of every higher-level access control
BOLA, BFLA, ORM-level scopes — all bypassed by SQLi because the attacker is now writing the query directly. SQLi defeats all the controls above it.
How do I know if I'm vulnerable?
Manual: in any input field that hits the database — search box, login, filter dropdown, URL parameter — try ' OR '1'='1 or ' UNION SELECT 1,2,3 -- . If you get errors mentioning syntax, table names or row counts, you have SQLi. If the page returns way more results than expected (or the login succeeds with no password), you have SQLi. If the page renders normally, try time-based: '; SELECT pg_sleep(5); -- . A 5-second response delay = blind SQLi.
Automated: AuditCore runs sqlmap with --batch and a safe profile (--technique=BEU, no time-based blind that can hang your DB). Detects SQLi in URL parameters, POST bodies, JSON fields, and headers. Free Trial covers your homepage; Growth and Business tiers extend across full crawl.
Code review pattern: search for SQL queries built via string concatenation, template literals (backticks with ${} substitution), or .format(). Anything matching SELECT.*\+.*req\. or db.query\(`.*\${.*}.*`\) is suspect. ORMs are mostly safe but check every .raw() and .literal() call — those bypass parameterization. Stored procedures with dynamic SQL (EXEC sp_execute_sql) are the biggest hidden source.
How to fix SQL Injection
6 ordered steps. Apply them in order — each builds on the previous.
- 1
Use parameterized queries (prepared statements) everywhere
The textbook fix. Send SQL template and values separately — the database treats values as data, never as code, regardless of content.
Every modern database driver supports parameterization. Replace every string concatenation in SQL with parameter placeholders. The performance difference is negligible (often slightly faster due to query plan caching). The security difference is total — parameterized queries cannot be SQL injected, period.
Node.js / pgStep 1// VULNERABLE const result = await db.query( `SELECT * FROM users WHERE email = '${req.body.email}'` ); // SECURE — parameterized const result = await db.query( 'SELECT * FROM users WHERE email = $1', [req.body.email] ); - 2
Use a query builder or ORM — but watch the escape hatches
Knex, Prisma, SQLAlchemy, Sequelize, Active Record, GORM — all parameterize by default. But every ORM has 'raw' methods that opt out.
ORMs are SQLi-safe in their default mode. Where they leak: .raw('SELECT...' + input), .where("name = '" + input + "'"), Prisma's $queryRawUnsafe, SQLAlchemy's text() with string interpolation. These are escape hatches that ORM authors warn about — but developers reach for them when something is hard to express idiomatically. Audit every usage.
Python / SQLAlchemyStep 2from sqlalchemy import text # VULNERABLE — string interpolation in raw SQL result = db.execute(text(f"SELECT * FROM users WHERE email = '{email}'")) # SECURE — bound parameters in raw SQL result = db.execute( text("SELECT * FROM users WHERE email = :email"), {"email": email}, ) # SAFE — ORM idiomatic result = db.query(User).filter(User.email == email).all() - 3
Allow-list dynamic identifiers (table, column, ORDER BY)
Parameterization only covers VALUES. If you need dynamic table/column names or ORDER BY direction, use an allow-list — never user input directly.
This is the #1 case where parameterization isn't enough. Common pattern: a table that supports ?sortBy=email&direction=desc. Developers concatenate sortBy and direction into the SQL ('ORDER BY ' + sortBy + ' ' + direction'). Even with parameterization for everything else, this single concatenation is full SQLi. Fix: define an allow-list of permitted column names and directions; reject anything else.
TypeScriptStep 3const SORTABLE = new Set(['email', 'name', 'created_at']); const DIRECTIONS = new Set(['asc', 'desc']); function buildOrderBy(sortBy: string, direction: string): string { if (!SORTABLE.has(sortBy)) sortBy = 'created_at'; if (!DIRECTIONS.has(direction.toLowerCase())) direction = 'desc'; return `ORDER BY ${sortBy} ${direction}`; // safe — both checked } - 4
Apply principle of least privilege at the database level
The database user your app connects as should have ONLY the privileges it needs. No DROP, no superuser, no filesystem access.
Defense in depth — even if SQLi gets through, what can the attacker do? If your app's DB user can only SELECT/INSERT/UPDATE on application tables, SQLi stays contained to those tables. They can't drop tables, can't read pg_authid, can't COPY FROM PROGRAM. Run separate users for migrations (more privileges) and runtime (minimal privileges). Disable xp_cmdshell on MSSQL. Don't run queries as root.
PostgreSQLStep 4-- App runtime user — minimal privileges CREATE ROLE app_runtime LOGIN PASSWORD '...'; GRANT CONNECT ON DATABASE myapp TO app_runtime; GRANT USAGE ON SCHEMA public TO app_runtime; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_runtime; -- NO superuser, NO CREATE, NO DROP, NO COPY -- Separate migration user with elevated privileges CREATE ROLE app_migrate LOGIN PASSWORD '...'; GRANT ALL PRIVILEGES ON DATABASE myapp TO app_migrate; - 5
Validate input shape before it reaches the query layer
Defense in depth #2. If a field should be a UUID, reject anything that isn't a valid UUID before it reaches the database.
Use Zod, Joi, Pydantic, validator.js — your stack's input validation library. Reject malformed input at the API boundary. This doesn't replace parameterization but adds a layer that catches the most common SQLi payloads before they reach the database. Single quotes, semicolons, SQL keywords are fine in a string description; not fine in a UUID field.
TypeScript / ZodStep 5import { z } from 'zod'; const OrderRequest = z.object({ orderId: z.string().uuid(), status: z.enum(['pending', 'shipped', 'delivered']), page: z.number().int().min(1).max(1000), }); // In your handler const parsed = OrderRequest.safeParse(req.body); if (!parsed.success) return res.status(400).json(parsed.error); // Now everything is type-checked AND shape-validated before queries - 6
Add unit tests with malicious payloads
Every input that hits the database needs a test asserting that classic SQLi payloads return 400/404, not data.
Tests are how you prevent regression. Pattern: for each endpoint, send the standard SQLi payloads (' OR '1'='1, '; DROP TABLE users--, ' UNION SELECT). Assert the response is 400 (validation rejected) or empty (parameterization treated it as a literal value). Run on every PR. Catches the day someone refactors and accidentally introduces concatenation.
JestStep 6const SQLI_PAYLOADS = [ "' OR '1'='1", "'; DROP TABLE users; --", "' UNION SELECT username, password FROM users --", "1' AND SLEEP(5) --", ]; describe.each(SQLI_PAYLOADS)('SQLi payload %s', (payload) => { it('rejects payload in /api/users/search', async () => { const start = Date.now(); const res = await api.get(`/users/search?q=${encodeURIComponent(payload)}`); const elapsed = Date.now() - start; // Either rejected by validation or returns 0 rows — never the user table expect([200, 400]).toContain(res.status); if (res.status === 200) expect(res.body.users || []).toHaveLength(0); expect(elapsed).toBeLessThan(2000); // no time-based SQLi }); });
How to verify the fix
Manual: open every input field in your app and try the OWASP SQLi cheatsheet payloads (' OR '1'='1, ', "; SELECT pg_sleep(5);--, ' UNION SELECT 1,2,3-- ). All should return 400 or 404 or normal response with empty results — never an error message mentioning the database, never a delayed response, never a data dump.
Automated: run sqlmap against your staging environment. AuditCore runs it for you with safe parameters (--batch --technique=BEU). Free Trial scans your homepage; Pro/Business runs across the full crawl with multi-role authentication. Findings come with reproduction commands.
Long-term: keep the unit tests from step 6 in your CI pipeline. Run AuditCore weekly via Scheduled Scans on staging — diff alerts catch the day someone refactors a query and accidentally introduces concatenation. Pair with database-level monitoring for unusual queries (Datadog, Honeycomb) to catch exploitation if a bug slips through.
Frequently asked questions
Are ORMs immune to SQL injection?+
ORMs PREVENT SQLi by default — but every ORM has escape hatches that opt out. Prisma's $queryRawUnsafe, SQLAlchemy's text() with f-strings, Sequelize's literal(), Active Record's where('name = "' + x + '"') — all are SQLi-vulnerable when used incorrectly. ORMs are safe IF you stay in their idiomatic patterns. The risk is the 5% of queries developers reach for raw mode to optimize or express something complex.
Can NoSQL databases have 'SQL injection'?+
Yes — called NoSQL injection. MongoDB is the classic case: { username: req.body.username, password: req.body.password } looks safe but if username = { $ne: null }, Mongo treats it as an operator and the query becomes 'username is not null'. Full auth bypass. Same root cause: untrusted data deserialized into the query language. AuditCore's NoSQL scanner tests for this with $gt, $ne, $regex payloads.
Is using stored procedures enough?+
Only if the stored procedure itself uses parameterization internally. Stored procedures with dynamic SQL (EXEC sp_executesql) are SQLi-prone in the same way as application code. Check every CREATE PROCEDURE for string concatenation or EXEC of untrusted parameters. Stored procs are NOT a magic fix.
What about parameterizing ORDER BY?+
You can't parameterize column names or sort directions — those are part of the query structure, not data. The fix is allow-listing: define a Set of permitted column names and directions, reject anything else, then concatenate the validated value. Step 3 above shows the pattern.
Does WAF protection (Cloudflare, AWS WAF) prevent SQLi?+
WAFs catch known SQLi payload patterns but are bypassable with encoding tricks, novel payloads, or mutation-based fuzzing. WAFs are defense in depth — useful as a layer, NOT a substitute for parameterization. Treat WAF rules as 'we'll catch the script kiddies' but assume motivated attackers will route around them.
What's blind SQL injection vs regular?+
Regular SQLi returns data in the response (UNION-based, error-based). Blind SQLi gets no direct output but the attacker infers the answer from response timing or content differences. Time-based: '; SELECT pg_sleep(5); -- — if the response takes 5+ seconds, the SQLi worked. Boolean-based: ' AND SUBSTRING(password,1,1)='a' — if the response includes data only when the condition is true, the attacker brute-forces character by character. Both are equally exploitable, just slower.
How does SQLi pair with other vulnerabilities?+
SQLi is a 'meta-vulnerability' — it bypasses controls above it. SQLi defeats BOLA (you can read any row directly), defeats RBAC (you read the roles table), defeats password hashing (you crack hashes offline), and on misconfigured databases pivots to RCE. If you have SQLi, fix it first — it's the highest-leverage fix you can make.
Will my linter catch SQL injection?+
Some help: ESLint with security plugins flags eval-like patterns. Semgrep has rules for common SQLi patterns (we run Semgrep in our static-analysis phase). They catch the obvious string concatenation cases but miss subtle ones (dynamic ORDER BY, raw escape hatches). Best results: combine static analysis (Semgrep) + parameterized-by-default (ORM) + runtime testing (AuditCore + unit tests).
Related fix guides
How to fix BOLA / IDOR
BOLA is the #1 modern API vuln — fix authorization at the same time as SQLi.
Read guideHow to fix XXE
Same root cause family: untrusted input parsed as code instead of data.
Read guideHow to fix SSRF
Server-Side Request Forgery — another 'untrusted input ends up in a sensitive operation' bug.
Read guide