One search box that pastes what the user typed into a SQL string lets a stranger read every table the app’s database role can read, not only the one the screen shows. SQL injection prevention is one rule and 3 backstops: bind every value as a parameter, then validate input, run the app on a least-privilege role, and keep database errors out of responses.

SQL injection prevention: one rule and three backstops

SQL injection prevention means one rule plus 3 backstops. The rule: use parameterized queries, so the query text is fixed in code and every outside value travels as a bound parameter. The backstops: allow-list validation for what cannot be bound, a least-privilege database role, and database errors kept out of responses.

Protection against SQL injection comes down to that same rule, whatever the app is built with: because every value that came from outside travels separately from the query text, the database never parses user text as SQL. It is one class of bug inside the wider job of web app security.

OWASP’s SQL Injection Prevention Cheat Sheet, from OWASP, the foundation that also publishes the Top 10, splits its defenses into primary and additional ones. The primary defenses, in its order, are prepared statements with parameterized queries, properly constructed stored procedures, allow-list input validation and, last, escaping all user-supplied input, which it marks “STRONGLY DISCOURAGED”; least privilege and allow-list input validation follow as the additional defenses. Of those SQL injection prevention methods, binding is the one generated code can use for every value it sends.

The table sets out the four prevention techniques for SQL injection as I would apply them in an app built with an AI tool. The third column, on what each one does not cover, is my reading, not a quote from any source.

DefenseWhat it doesWhat it does not coverWhere it lives
Parameterized queriesSends the query text and the values separately, so input is never parsed as SQLTable names, column names and sort direction, which cannot be boundEvery query in the app code and in database functions
Allow-list input validationMaps a user-chosen name or keyword to one of a fixed set and rejects the restValues: it does not replace bindingThe request handler, before any query is built
Least-privilege database roleLimits what a missed query can read or changeFixes nothing: the injected query still runsDatabase roles and grants
Errors kept out of responsesRemoves the database error an attacker reads to refine the next requestFixes nothing: blind techniques still workThe API’s error handler and the server logs

Binding stops SQL injection attacks for every value, and the allow-list is the defense for the names binding cannot carry. The other two rows do not prevent an SQL injection attack; they are SQL injection mitigation, and they earn their place because a codebase can hold a string-built query nobody has found yet: they defend against SQL injection in that query, not in the ones you fixed. A least-privilege role, for one, protects the data from an SQL injection attack that got through. A web application firewall is a fifth layer some apps add; it can block SQL injection attacks that match its rules, but it does not fix the query, and the backstops section below says why.

The order I work in, as my working rule: find every string-built query, bind it, test it, then add the backstops. Binding first is how you avoid an SQL injection attack instead of containing one.

What is SQL injection, and what can it reach

SQL injection is user text changing the structure of a database query because the code pasted it into the SQL string. It is CWE-89, and it can reach every table the app’s database role can reach, which on an app with one all-powerful role means all of them.

MITRE’s name for CWE-89 says where it happens: “Improper Neutralization of Special Elements used in an SQL Command (‘SQL Injection’)”. The basic SQL injection example is a lookup built as a string, where the typed value closes the quote and adds a condition that is always true, the OR 1=1 tautology, so the WHERE clause stops filtering. The text only has to be valid SQL syntax once it is pasted in, which is how SQL injections work in any app that builds queries from strings. With a bound parameter, the same SQL injection string is compared as a literal value, never becomes part of the statement, and matches nothing. Common SQL injections in generated code are not clever tricks. A simple SQL injection needs only a template string where a placeholder should be.

The target of an SQL injection attack is whatever the app’s database role can read or write: every table and column, which on a builder app running one all-powerful role includes other tenants’ rows, password hashes and tokens. Destructive SQL injection attacks, the drop table of the jokes, need two more things: a driver that accepts more than one statement in a query, and a role that holds the privilege. PostgreSQL refuses a second statement in a query string sent through its extended protocol but not through the simple one, and the mysql driver for Node runs several only when its multipleStatements option is switched on.

In the OWASP Top 10, SQL injection sits under A05:2025 Injection, fifth in the 2025 edition. Reviewing an app against every category is a job of its own, covered in how to test OWASP Top 10 vulnerabilities.

Why it matters in an AI-generated app: where a string-built query survives

Across the 21 third-party apps I audited, the Input, Injection & Abuse pillar averages 61.9 out of 100, and it ranks 11th of 12 pillars counting from the weakest, where 12th is the best. Those 21 are the 11 public vibe-coded apps and the 10 held-out apps I audited in June and July 2026: a selected set of audited apps, not a random sample and not a rate for AI-built apps in general. My reading: on average, input and injection was not where these apps were weakest, and it was not a solved problem either.

Query builders bind values for you (the per-stack table further down has each project’s own line), so generated CRUD code that stays inside the builder is safe from SQL injection by default. The bug survives where generated code steps outside the builder. The seven places below are my reading of where that happens in a Prisma, Drizzle, Supabase or node-postgres codebase, taken from the stacks’ docs rather than counted in audits.

Where it hidesWhat the code looks likeThe search that finds itThe bound form
A raw-query escape hatch$queryRawUnsafe or $executeRawUnsafe called with a template string, or Drizzle’s sql.raw() around request dataqueryRawUnsafe, executeRawUnsafe, Prisma.raw, sql.raw($queryRaw as a tagged template; Drizzle’s sql tag
A search or filter endpointA LIKE clause assembled from the search box text inside a template literalLIKE '%${ and LIKE '%' +One placeholder carrying the whole pattern, with the % added in code
A sort or column name from the query stringORDER BY followed by a value read from the URLORDER BY ${ and ORDER BY ' +An allow-list that maps the incoming string to a known column name
A Postgres function called through rpcA function body that joins SQL text with the arguments and runs it with EXECUTEEXECUTE in migrations and function definitionsEXECUTE ... USING for values; format() with %I for identifiers
A reporting or export routeOne long SQL string with the filters spliced inTemplate literals that start with SELECT or UPDATE and contain ${The same statement with numbered placeholders and a values array
An admin script or seed file that became an endpointCode written for trusted input, now called from a route with request dataRoutes that import from your scripts or seed foldersThe same statement, bound, before the route ships
A hand-escaped queryA string-escaping helper used in place of binding: the sqlstring patternescape(, SqlString.format, mysql.formatmysql2’s execute with ? placeholders

Picture a generated reporting endpoint that sorts a table by a column the request names. A column name cannot be bound, and Prisma’s own docs say a dynamic table name on PostgreSQL cannot go through $queryRaw either, so the code builds the ORDER BY clause with a template string and hands it to $queryRawUnsafe, one of the methods the docs call “at significant risk of making your code vulnerable to SQL injection”. Every other query in the app goes through the ORM’s builder and is bound, so a review that only asks whether the app uses an ORM passes. The one string-built call is the hole, and the allow-list in the cannot-bind section plus the code search in check 1 are what find it and close it. The lesson I take from a case like this: the ORM was never the hole, the one call that stepped outside it was, and a code search finds that call faster than a scanner does.

What makes a website vulnerable to SQL injection is one of those calls, not the framework it runs on. SQL injection via a URL is not a separate bug either: an id, a slug, a filter or a sort parameter in the query string reaches these seven places exactly as a form field does. So there is no special SQL injection example URL to watch for: any parameter that lands in a string-built query will do.

The login form is the classic SQL injection target because its query decides who you are. A string-built credential lookup can be made to return a row without the password matching, which turns SQL injection on a username and password form into signing in as someone else. The fix is the same bound parameter, plus comparing a password hash in code, never in SQL. My reading: builder apps that use a hosted auth service such as Supabase Auth, Clerk or Auth0 do not run their own credential query, so the login risk sits mostly in custom-rolled auth.

The vibe coding security checklist leaves SQL injection outside its twelve-check priority pass and says such classes still need assessment when they apply; this page is that assessment for SQL injection. The request schema that rejects a malformed id before it reaches any query is a separate control, covered in input validation for a web app.

How it works: the fix, the sub-types, the other databases and the tool

Six parts follow, in the order you will need them: the fix per stack, what the fix cannot carry, the attack types, the non-SQL databases, the tool that probes public endpoints, and the layers behind the fix.

Parameterized queries and ORM injection

A parameterized query sends the SQL text with placeholders and sends the values separately, so the database never parses user input as SQL. ORM injection is the same bug reached through an ORM’s raw-query methods, the place where the ORM stops binding for you.

node-postgres on parameterized queries describes passing “your query text unaltered as well as your parameters” to the server, which does the substitution itself, so a value cannot change the query’s shape. That is why parameterized queries stop SQL injection for every value, and why they cannot carry a table or column name, which the next section covers. ORM injection, in the generated code I am describing, is the ORM’s raw or string-accepting method called with a string the code assembled, not the query builder going wrong. Each row below comes from the project’s own docs: Prisma’s raw queries page, Drizzle’s sql template, Supabase’s database functions guide, PostgreSQL’s dynamic commands section, Django’s security overview, SQLAlchemy’s text() and bound parameters, the Rails security guide, mysql2’s prepared statements and the sqlstring package.

StackThe safe formThe unsafe escape hatch to search forThe docs line
node-postgresclient.query(text, values) with $1, $2 placeholdersA template literal or + building the query textAvoid “string concatenating parameters into the query text directly”
Prisma$queryRaw and $executeRaw as tagged templates$queryRawUnsafe, $executeRawUnsafe, and Prisma.raw fed with inputTagged templates create “prepared statements that are safe from SQL injections”
DrizzleThe sql template tagsql.raw() around inputsql.raw() adds SQL “without any additional processing or escaping”
Supabase data APIThe client’s query filters, served by PostgRESTYour own database functions that build SQL and run it with EXECUTEPostgREST’s generated queries are “parameterized (invulnerable to SQL injection)“
PostgreSQL functionsEXECUTE ... USING for values; format() with %I for identifiers and %L for literalsEXECUTE on a string joined from the argumentsUSING is “much less prone to SQL-injection attacks since there is no need for quoting or escaping”
DjangoQuerySetsraw(), extra() and RawSQL with text built from inputRaw queries “should be used sparingly”
SQLAlchemytext() with :name bound parametersAn f-string passed to text()”Bind parameters are specified by name, using the format :name”
Ruby on RailsHash conditions, and ? or named placeholders in where#{} interpolation inside a where stringThink about “the security consequences when using an external string in SQL”
mysql2execute with ? placeholdersquery with a string built by + or a template literalexecute “will prepare and query the statement”
sqlstringescape for values and escapeId for identifiersEscaping used in place of bindingIts format “differs from prepared statements in that all ? are replaced”

The Supabase row needs one more sentence. Supabase’s database functions guide shows them called from the client with rpc, and says nothing about dynamic SQL inside them, so the binding rule inside a function is PostgreSQL’s: EXECUTE ... USING for values and format() with %I for names. For Python injection in the SQL sense, the Django and SQLAlchemy rows are the answer; the same phrase also names code injection, a different bug covered with the other classes further down. Ruby injection in the SQL sense is the interpolated where string in the Rails row, and the Rails guide shows the safe placeholder forms beside it.

The last row is the sqlstring package, which describes itself as “Simple SQL escape and format for MySQL”. Node’s mysql driver exposes its mysql.format from SqlString.format, and its README says the ? substitution “looks similar to prepared statements in MySQL, however it really just uses the same connection.escape() method internally”. That is client-side escaping rather than binding: acceptable for values while the server’s NO_BACKSLASH_ESCAPES mode stays off, which is MySQL’s default, and the wrong tool for a table or column name unless you use escapeId. For MySQL injection, the example below holds with mysql2’s execute, a ? placeholder in place of $1, and LIKE in place of ILIKE, which PostgreSQL’s docs say “is not in the SQL standard but is a PostgreSQL extension”.

Here is the same search endpoint in node-postgres, string-built and then bound.

// Before: the search term becomes part of the SQL text
const sql = `SELECT id, name FROM products WHERE name ILIKE '%${term}%'`
const before = await client.query(sql)

// After: the text is fixed; the term travels as $1
const text = 'SELECT id, name FROM products WHERE name ILIKE $1'
const after = await client.query(text, [`%${term}%`])

When you find a string-built query, fix it in this order: bind it, add the regression test from check 4 below, then search the codebase for the same pattern elsewhere. If a builder wrote the code, the security prompt for an AI builder includes a prompt that asks it to replace every query built by string concatenation with a parameterized one.

What you cannot bind: table names, column names and sort order

Table names, column names and sort direction cannot be bound as parameters, because placeholders carry values only. The fix is an allow-list: map the incoming string to one of a fixed set of known names in code, and reject anything else before a query is built.

PostgreSQL’s docs say parameter symbols “can only be used for data values”, and Prisma’s say a dynamic table name on PostgreSQL cannot go through $queryRaw, so a user-chosen sort column, sort direction or table name has no placeholder to travel in. OWASP’s cheat sheet names “input validation or query redesign” as the defense for these parts, and that is where input validation for SQL injection earns its place: a map from newest to created_at DESC, and a rejection for anything not in the map. The raw string never reaches the query.

In a LIKE search, SQL injection is already handled once the value is bound. What remains is that % matches any sequence of characters and _ any single one, so a user who types them can turn a narrow search into a match-everything one; escape them with a backslash, PostgreSQL’s default escape character, if that matters for your table.

SQL sanitization by stripping quotes or keywords is the defense that fails. It mangles real names such as O’Brien. Escaping sits last among OWASP’s options, and the cheat sheet says of it that “we CANNOT guarantee that this option will prevent all SQL injections in all situations”. Validation still belongs on every endpoint as a request schema, which the input validation page linked above covers.

Blind, boolean and time-based SQL injection

Blind SQL injection is injection where the response shows no data and no error, so the attacker infers the answer a little at a time: boolean-based from whether the page changes, time-based from whether the response is delayed. Every type needs the query’s structure to change, so bound parameters stop all of them.

OWASP’s testing guide on SQL injection sorts the types of SQL injection attacks by channel: in-band, where data comes back the way the request went in; inferential or blind, where “there is no actual transfer of data” and the answer is rebuilt from how the server behaves; and out-of-band, where data leaves through a different channel. A blind SQL injection vulnerability is the same string-built query as a visible one; only the feedback differs. An owner never needs a blind SQL injection example string, because the log column below is how you recognize one.

TypeWhat the attacker observesWhat it looks like in your logsWhat stops it
Error-based SQL injection (in-band)A database error in the response that leaks the query’s structureA spike in 500s carrying database syntax errorsBound parameters
Union-based SQL injection (in-band)Extra rows joined to a normal result with UNION SELECTRequests to one endpoint where one parameter carries SQL keywordsBound parameters
Boolean-based SQL injection (blind)The page differs depending on whether an injected condition is trueBursts of near-identical requests to one endpoint, varying one parameterBound parameters
Time-based SQL injection (blind)The response is delayed when the injected condition is trueA run of requests each taking a suspiciously round number of secondsBound parameters
Out-of-band SQL injectionThe database sends data out through a different channelOutbound connections from the database hostBound parameters

The first two columns follow the testing guide’s classes and techniques; the log column is my reading. Hiding SQL injection errors turns error-based injection into blind injection and stops nothing by itself, since the guide notes that when “the application hides the error details”, the tester reverse engineers the query instead. What a safe error response looks like is part of API error handling best practices.

MongoDB, GraphQL and NoSQL injection: the same bug without SQL

NoSQL injection in MongoDB is untrusted input reshaping a query object, for example through operator injection: a field expected to be a string arrives as an object holding a query operator. The fix is a type check at the boundary. GraphQL injection is a resolver building a query from an argument by hand, fixed with the same binding.

OWASP’s NoSQL guidance shows the check as rejecting any $ in keys or operator values and tells you to use driver query objects “rather than building query strings”. My reading adds the plain version for a JSON API: confirm each field has the type you expect, so an object never arrives where a string belongs, and never pass a request body straight into a filter. OWASP’s testing guide on NoSQL injection names the $where operator as the “most commonly used API call allowing arbitrary JavaScript input”, and MongoDB’s server-side JavaScript page says how to turn that off: the --noscripting option or security.javascriptEnabled set to false. The same page says $where, $function and $accumulator are deprecated starting in MongoDB 8.0.

GraphQL is a transport, not a database. GraphQL injection is an argument flowing into a string-built SQL or NoSQL query inside a resolver, and it is fixed in the resolver. OWASP’s GraphQL cheat sheet says to “choose libraries/modules/packages offering safe APIs, such as parameterized statements”, and adds that ORMs “must be used properly to avoid flaws such as ORM injection”. Introspection, query depth and batching attacks have their own headings on that sheet, as different controls.

Other code injection examples, such as OS command and template injection, are covered with race condition in cyber security and the other web vulnerability classes. Prompt injection shares the name and nothing else: what is prompt injection. Script injected into a page is cross-site scripting, and the page to mitigate cross-site scripting owns it.

sqlmap: the tool that will be pointed at your endpoints

sqlmap is a free, open-source tool that automates detecting and exploiting SQL injection. Used on a staging endpoint you own, it is a useful check. Used against a site you neither own nor have permission to test, it can break computer misuse laws, such as the UK’s and the US federal one.

The sqlmap project calls it “an open source penetration testing tool that automates the process of detecting and exploiting SQL injection flaws and taking over of database servers”, released under GPLv2. It is a Python program that “works out of the box with Python version 2.7 and 3.x on any platform”, so sqlmap on Windows is Python plus the repository.

What an owner needs is not a sqlmap tutorial. The tool takes a target URL or a request saved to a file, by default tests every GET and POST parameter, and tries techniques like those in the types table above on each. Its option reference is sqlmap’s usage wiki, and this page carries no sqlmap commands list and no sqlmap example against a target. sqlmap’s tamper scripts, which the wiki describes as “WAF/IPS-evasion transformations”, rewrite its requests to slip past signature filters, which is the practical reason a WAF rule set is a backstop and not the fix.

How to use sqlmap responsibly: only against an app you own, on staging with seeded data and a backup, with your hosting provider’s acceptable-use terms checked first, one endpoint at a time, keeping the output as evidence. A clean run is weak evidence, because it tested only the parameters it was shown; the code search in check 1 below is stronger. In your logs, sqlmap by default sends a User-Agent of the form “sqlmap/1.x.x.x#dev (https://sqlmap.org)”, and the wiki notes it can be faked, so the request pattern from the types table is the steadier sign.

SQL injection material on GitHub includes payload lists and deliberately vulnerable practice apps. The practice apps are the useful half, run on your own machine; the check section names one.

Least privilege, quiet errors and a WAF: the layers behind the fix

Behind the bound parameter, two backstops and one optional layer limit the damage of a query you missed: the app connects as a role with only the privileges it uses, responses carry a generic error while the detail goes to the log, and a WAF filters automated probes. None of them fixes a string-built query.

The app should connect as a role that owns nothing and holds only the table privileges it uses, never a superuser or the service role on a request path that takes user input, so one missed query reads less. OWASP’s cheat sheet puts it as minimizing “the privileges assigned to every database account in your environment”. Setting that role up is covered in how to harden managed database settings. Row-level security keeps applying to an injected query when the query runs as the user’s own role, while superusers and roles with BYPASSRLS skip it, and table owners normally do too.

Responses should carry a generic error and a request id, while the database error goes to the log. My reading on the optional layer: a WAF’s managed SQL injection rules block the noisy automated probes and can be slipped past with the tamper scripts above, so they buy quiet logs, not safety. On Cloudflare, Cloudflare’s WAF managed rules list the Cloudflare Managed Ruleset and the OWASP Core Ruleset for the Pro, Business and Enterprise plans, and a Free Managed Ruleset for every plan. Choosing and tuning one is its own task: how to set up a web application firewall.

How to check your own app

SQL injection protection is verified with 6 checks: a code search for string-built SQL, a static analysis run, a single-quote test on every input of your own staging app, a regression test per fixed endpoint, a review of the database role, and a forced error read as a stranger.

To test for SQL injection, start with the code, not the running app. The checks run strongest first, and each one names the evidence to keep.

  1. 01 Search the codebase for string-built SQL: the raw-method names from the per-stack table, template literals or + next to SELECT, INSERT, UPDATE, DELETE and ORDER BY, and EXECUTE inside database functions. Keep the list of hits and the fix commit for each.
  2. 02 Run a static analysis rule set that flags input reaching a query. Keep the report.
  3. 03 On your own staging app, put a single quote, then a long harmless string, into every text input, id and sort parameter. Pass: a validation error or a normal empty result. Fail: a server error or a database error message in the body. Keep each request and response.
  4. 04 Add a regression test per fixed endpoint that submits a hostile-looking string and asserts it was stored or searched as literal text, and that the row count of an unrelated table in the test database did not change.
  5. 05 Connect as the app's own database role, not the owner or a superuser, and list its privileges. Keep the list.
  6. 06 Force an error and read the response body as a stranger would. Keep the response.

Choosing a tool for check 2 is covered in static code analysis tools. A vulnerability scanner, like any of the SQL injection testing tools, can scan only the parameters it is shown, which is why check 1 comes first. Before you check for SQL injection with an online test tool or a hosted scanner, make sure the site is yours, and know that the URL and the results sit with a third party; what each kind of scanner proves is covered in a website security check.

The legal SQL injection test website is one you run on your own machine. OWASP Juice Shop describes itself as “Probably the most modern and sophisticated insecure web application for security trainings, awareness demos and CTFs”, and it is where to watch the attack work. Lists of live SQL injection sites to practice on are lists of other people’s systems, and testing a site you do not own falls under the laws in the FAQ below.

Check 3 is also how the Production Hardening Sprint verifies deliverable 3.1, server-side request validation: submit invalid and hostile payloads and confirm safe rejection or handling.

Where the sprint fits

No deliverable of the Production Hardening Sprint is named after SQL injection; three cover the ground. Under deliverable 3.1 we validate every data-writing endpoint with explicit schemas and safe input/output handling, including injection defenses. Deliverable 3.7 is where we review the application against the OWASP Top 10 and record findings, fixes, and evidence by category. For 3.10 we test the five highest-risk externally reachable attack surfaces, remediate findings, and deliver the methods and evidence; AxonBuild performs this targeted test. The production readiness report accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Formal third-party certifications and independent audit opinions are separate from the sprint deliverables. Every item is listed in the published scope.

Common questions about SQL injection

Are SQL injections still a thing?

Yes. Injection is A05:2025 in the current OWASP Top 10, and CWE-89 ranks 2nd in the 2025 CWE Top 25, one place higher than the year before. In generated code it lives on through the escape hatches in the where-it-hides table: a raw-query method, a sort parameter, a database function that builds SQL.

Which is more resistant to SQL injection attacks?

A parameterized query, compared with escaping user input or a stored procedure that builds SQL from strings inside it. OWASP’s cheat sheet lists prepared statements with parameterized queries first and escaping last, and says stored procedures “are not always safe from SQL injection”: a procedure is only as safe as the SQL it builds.

Is it illegal to do an SQL injection?

Against a system you do not own or have written permission to test, it can be a crime. In the US, 18 U.S.C. 1030 covers anyone who “intentionally accesses a computer without authorization or exceeds authorized access, and thereby obtains” information, including “information from any protected computer”; in the UK, the Computer Misuse Act 1990, section 1 makes “Unauthorised access to computer material” an offence. Against your own app on your own infrastructure it is testing, subject to your host’s terms. This is not legal advice.

sqlmap’s own startup notice says the same about the tool: “Usage of sqlmap for attacking targets without prior mutual consent is illegal”.

How can I detect an SQL injection attack?

Read your request logs for the patterns in the types table: many near-copies of one request with a single parameter changing, database errors behind a rise in server errors, and responses that each take a round number of seconds. A default sqlmap run also names itself in the User-Agent header, though that header can be changed. Getting those logs into one searchable place is the job of security logging.

What are alternatives to SQLmap?

Tool types rather than a ranking: an open-source web app scanner such as ZAP, which describes itself as free and open source; static analysis whose rules trace user input into database calls; and the code search from check 1, which finds what a scanner was never shown. What each kind of scanner proves is the subject of the website security check page.