Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

SQL injection in AI-generated code: how to find it and fix it

FIX / SQL INJECTION / 9 MIN READ

To fix SQL injection in AI-generated code, replace every query that joins user input into the SQL text with a parameterized query. Search your code for template strings and string concatenation near SELECT, INSERT, UPDATE, and DELETE. Pass each user value as a parameter. Use an allow-list for the parts of a query that a parameter cannot hold, such as the sort column.

This guide explains why AI tools produce this bug, how to test your app for it, and how to fix it in the common stacks. It also shows how to check for damage and how to stop the bug from returning.

What is SQL injection, and why do AI tools produce it?

The OWASP project describes SQL injection as the insertion of a SQL query through the input data from the client to the application. The attacker sends text that the database reads as a command, not as data.

The bug appears when code builds a query as one string. The user value goes into the same string as the SQL keywords. The database cannot tell which part is the command and which part is the data.

AI tools write this pattern for three reasons:

  1. The pattern is short. A template string with the value inside is the quickest way to make a query work. The tool optimizes for a working demo.
  2. The pattern is common in old code. Models learn from a large body of public code. Much of this code uses concatenation.
  3. You did not ask for safe queries. A prompt such as “add a search box” does not mention injection. The tool does not add a control that you did not request.

The risk is not limited to one tool. Any assistant can write the pattern, and any developer can accept it without a review. For this reason, this guide does not depend on the tool that wrote your code.

The impact is serious. A working injection can let an attacker read tables that the page does not show, such as users and tokens. It can also change or delete data. The exact impact depends on the permissions of the database account that your app uses.

What does a vulnerable query look like?

Look for a query string that contains a variable. These examples show the same bug in three styles.

// Template string
const sql = `SELECT * FROM products WHERE name LIKE '%${req.query.q}%'`;
const { rows } = await pool.query(sql);

// Concatenation
const sql2 = "SELECT * FROM users WHERE email = '" + email + "'";

// Prisma raw query with a plain string
const result = await prisma.$queryRawUnsafe(
  "SELECT * FROM users WHERE name = '" + name + "'"
);

The Prisma documentation warns about this exact case. $queryRawUnsafe accepts a plain string, so Prisma cannot tell the query text from the data in a concatenated string.

The same bug exists in every language. In Python, look for f-strings and the % operator inside execute() calls. In PHP, look for variables inside double-quoted query strings. In Go, look for fmt.Sprintf that builds a query.

Also check the places that you do not see in the user interface:

  • Search boxes and filters. These routes build LIKE and WHERE clauses from user text.
  • Sort and order parameters. A parameter such as ?sort=price often goes into an ORDER BY clause as raw text.
  • Edge Functions and server routes that call rpc or run raw SQL. An AI tool adds these when the ORM does not support a query.
  • Database functions. A PL/pgSQL function can build dynamic SQL with string concatenation. The bug then lives inside the database.
  • Admin pages and reports. The tool often writes these quickly, with raw queries.

How do I test my app for SQL injection?

Test your own app only. Use a staging copy or a test account when you can.

Warning: A test string can change data if the route has the bug. Do not send destructive input such as DROP TABLE. Do not run these tests against a production database that holds real user data without a backup.

  1. List the inputs. Write down each route that takes a search term, a filter, an ID, or a sort field. Include the routes that the user interface does not call.
  2. Send a single quote. Put ' in each input. A 500 error, or a database error text in the response, is a strong sign of the bug.
  3. Send a pair of quotes. Put '' in the same input. If the single quote breaks the request and the pair does not, the input reaches the SQL text.
  4. Send a true condition and a false condition. Compare the two responses. If the results differ, the input changes the logic of the query.
  5. Record the routes that react. Fix these first.
curl -G "https://your-app.example/api/products" --data-urlencode "q='"
curl -G "https://your-app.example/api/products" --data-urlencode "q=''"
curl -G "https://your-app.example/api/products" --data-urlencode "q=' OR '1'='1"

Replace the URL and the parameter name with your own. If the third command returns every product, and a normal search term returns only a few, the route is vulnerable.

A safe route treats each of these strings as plain text. It returns an empty list or the normal “no results” answer. It does not return a database error.

Also read your code. A test can miss a route that needs a login or a special input. A review of the source finds each query that holds a variable. A search for query(, execute(, $queryRawUnsafe, and sql in your repository is a good start.

What do I do first? Fix it now, in order of urgency

Do these steps in order. Fix the routes that reacted to the tests first.

Caution: If you suspect that someone already used the bug, read the damage section before you change the database. Do not delete logs.

  1. Take the vulnerable route offline, or add a temporary block. If you cannot fix the route in the next hour, return a 503 for it. A short outage costs less than a data leak.
  2. Replace the string with a parameterized query. Write the SQL with placeholders. Pass the values in a separate list. See the examples below.
  3. Use an allow-list for identifiers. A parameter cannot hold a table name, a column name, or a keyword such as ASC.
  4. Reduce the database permissions. The account that your app uses must not own the tables, drop tables, or read tables that the app does not need.
  5. Rotate the credentials if the attacker could read them. If the bug exposed the users table, reset sessions and tokens. Rotate any secret that the database holds.
  6. Deploy and repeat the tests. Send the same test strings to the new version.
  7. Search the whole codebase for the same pattern. The same bug usually appears more than once.

Parameterized query with node-postgres

The node-postgres documentation says that you must avoid string concatenation of parameters into the query text, because this can lead to SQL injection. The server substitutes the parameters safely. This is the fixed version of the first example:

const { rows } = await pool.query(
  "SELECT id, name, price FROM products WHERE name ILIKE $1",
  [`%${req.query.q}%`]
);

The $1 marker holds the value. The % characters go into the value, not into the SQL text. The attacker’s quote characters are now plain data.

Tagged template with Prisma

The $queryRaw method is a tagged template. Prisma sends the embedded variables as parameters, according to the Prisma raw queries page:

const result = await prisma.$queryRaw`
  SELECT id, name FROM "User" WHERE name = ${name}
`;

Do not wrap input in Prisma.raw(), and do not build the template from a string. Both choices remove the protection. If you must use $queryRawUnsafe, pass the values as separate arguments with $1 placeholders.

Use the normal ORM methods when you can

A plain method such as prisma.user.findMany({ where: { name } }) does not need raw SQL at all. Ask your AI tool to use the query builder first. Use raw SQL only when the query builder cannot express the query.

Allow-list the sort column

Parameters hold data values only. The OWASP SQL Injection Prevention Cheat Sheet says that parameter markers cannot represent table names, column names, or keywords such as ASC and DESC. It advises you to map the choice of the user to a small set of tokens that your application owns.

const sortColumns = { price: "price", name: "name", newest: "created_at" };
const sortDirs = { asc: "ASC", desc: "DESC" };

const column = sortColumns[req.query.sort] ?? "created_at";
const dir = sortDirs[req.query.dir] ?? "DESC";

const { rows } = await pool.query(
  `SELECT id, name, price FROM products ORDER BY ${column} ${dir} LIMIT 50`
);

The user text never reaches the query. The code looks up the text in a fixed object and uses only the value that your code owns. An unknown value falls back to the default.

Fix dynamic SQL inside the database

If a PL/pgSQL function builds a query with the || operator, rewrite it. Use format() with %I for identifiers and %L for literals, or use EXECUTE ... USING for values. Check the PostgreSQL documentation for the exact syntax before you change a function. If you do not need dynamic SQL, remove it.

Does input validation or escaping fix SQL injection?

No. Validation helps, but it does not replace parameters. The OWASP cheat sheet lists the defenses in this order: prepared statements with parameterized queries, then stored procedures that bind their arguments, then allow-list validation, and last escaping. It describes escaping as strongly discouraged.

The cheat sheet also says that validation complements binding and does not replace it. Data values must still be bound.

Two common “fixes” fail:

  • A function that removes quotes. Attackers find inputs that bypass a custom filter. A filter also breaks valid input, such as the name O’Brien.
  • A check in the browser. The attacker sends the request directly and skips your form.

Use the parameterized query as the main control. Use validation for the format of the data, for example to check that an ID is a number.

How do I check the damage?

Find out if someone used the bug. The attacker’s requests contain unusual text in the parameters.

  1. Read the access logs of your server or host. Search the query strings for ', %27, UNION, SELECT, OR 1=1, and --.
  2. Check the database error logs. A burst of syntax errors from one route often shows an attacker who tests the input.
  3. Look for large responses. A response that is much bigger than normal can mean that the attacker read a whole table.
  4. Check the data. Look for rows that you did not create, changed roles, and new accounts.
  5. Check the permissions of the app account. The permissions set the upper limit of the damage. If the account can read the users table, assume that the attacker read it.
  6. Decide what to rotate. If the bug could read the users table, reset passwords and sessions. If it could read tokens or API keys, rotate them.

The cheat sheet says that least privilege limits the damage an attacker can cause through a database account, but it does not prevent injection. Use it as a second layer. Never give your application account DBA or admin access.

If the logs do not go back far enough, assume that the attacker read what the account could read. Tell your users if the data included personal information. Check the rules that apply to you.

How do I stop it from coming back?

Use these controls in every project that has a database.

  1. Add a rule for your AI tool. Write in the project instructions: “Never build SQL with string concatenation or template strings. Use parameterized queries or the query builder.”
  2. Review every query in a pull request. Look for variables inside SQL text. Ask the tool to explain any raw query that it adds.
  3. Search the code automatically. Add a check to your CI that fails on $queryRawUnsafe and on template strings that contain SELECT. A static analysis tool can do this work.
  4. Use the query builder by default. Raw SQL is the exception and needs a reason.
  5. Use a restricted database role. The app account gets only the rights that the app needs. It does not own the schema.
  6. Test the deployed app. Scans find the routes that react to quote characters. See database security best practices for the wider checklist.
  7. Check for related bugs. Raw input in a query is one form of a wider mistake: trust in client data. Read about mass assignment, which has the same cause in a different place.

If your app uses PostgreSQL, the PostgreSQL guide lists settings for roles and permissions.

When should I get help?

Get help in these cases:

  • Your logs show injection strings and you cannot tell what the attacker read. An outside engineer can read the logs and the permissions with you.
  • The database holds personal data, payment data, or health data. You may have a legal duty to report a breach.
  • The app account has admin rights, and a route was vulnerable. The attacker may have changed the database, not only read it.
  • You find the bug in many files. A full code review is faster than a fix for each file.
  • You cannot test the app safely. A scan of the deployed app tests the routes for you without changes to your code.

After you deploy the fix, send the three test strings from the test section again. Each route must return plain results or an error that does not show database text.

Common questions

Do AI coding tools write SQL injection bugs?
Yes, they can. A tool can build a query with string concatenation or a template string. Read every query that contains user input.
Is an ORM safe from SQL injection?
An ORM is safe when you use its normal query methods. Raw query methods are not always safe. Pass values as parameters and never join them into the query text.
Does input validation replace parameterized queries?
No. Validation is a second layer. The OWASP cheat sheet says that you must still bind data values as parameters.
Can I use a parameter for a table name or a column name?
No. The database does not accept parameters for identifiers. Use an allow-list and map the user choice to a fixed name that your code owns.
Does Supabase protect me from SQL injection?
Normal client calls send values as data. Raw SQL that you build in an Edge Function or a database function can still be vulnerable. Parameterize that code.

Test for SQL injection

A VibeEval scan sends test input to the parameters of your deployed app and reports the routes where the input changes a database query.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →