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

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

FIX / CURSOR SQL INJECTION / 9 MIN READ

To fix SQL injection in Cursor-generated code, find each query that joins user input into the SQL text and replace it with a parameterized query. A parameterized query sends the SQL and the values separately. The database then treats the values as data and never as commands.

This guide shows how to find the bad queries in your repository, how to test the live app, and how to fix each pattern. It also shows how to check for damage and how to make Cursor write safe queries next time. This page covers Cursor only. For the database side, read the PostgreSQL guide.

What does SQL injection in Cursor code mean?

It means that a query in your app contains text that came from a user, and the database reads that text as part of the command. The user can then change what the query does. The user can read other rows, skip a login check, or change data.

Cursor writes code with a language model. The model predicts code that looks correct. A query that works in the first test looks correct, even when the query joins strings. The model does not run an attack against your app, so the unsafe query passes every normal test.

Here is the typical unsafe pattern in a Node.js route:

app.get("/api/orders", async (req, res) => {
  const { status } = req.query;
  const result = await pool.query(
    `SELECT * FROM orders WHERE status = '${status}'`
  );
  res.json(result.rows);
});

A normal request sends status=paid. An attacker sends status=' OR '1'='1. The final SQL then matches every row in the table. The route returns the orders of all users.

The node-postgres documentation names this risk. It warns that joining parameters into the query text “can (and often does) lead to sql injection vulnerabilities.”

Note: This problem is not unique to Cursor. Any tool or person can write this code. Cursor is a common place where the code appears, because users accept generated code fast. The fix is the same for every source.

Where does Cursor put unsafe queries?

Look in these places first. They are the places where user input meets a database call.

Place Typical unsafe pattern
Search routes A LIKE '%${q}%' clause built with a template string
Filter and sort routes A column name or ORDER BY value that comes from the URL
Login and signup routes A query that looks up the email with a template string
Admin and report routes A raw query with many optional filters joined with +
Edge functions and API routes A pool.query, db.execute, or sql.unsafe call with a template string
Pagination A LIMIT or OFFSET value that is not converted to a number

Sort and filter routes are the most common miss. A parameter cannot replace a column name or a sort word. So the model joins the text into the query, even when it uses parameters for the other values.

How do I find the unsafe queries in my repository?

Search the code for query calls that contain a template string or string concatenation. Use these commands from the root of the repository.

grep -rnE "(query|execute|raw|unsafe)\(\s*[\`\"'].*(\\$\{|\" *\+|' *\+)" src app lib server --include=*.ts --include=*.js --include=*.tsx
grep -rnE "queryRawUnsafe|\.unsafe\(|sql\.raw|text\(f\"|execute\(f\"" . --include=*.ts --include=*.js --include=*.py

The first command finds query calls with ${ or a + inside the string. The second command finds functions whose names say that they are unsafe. In Python code, look for f-strings and % formatting inside execute calls.

The search is not complete. A query can be built in a variable on one line and sent on a later line. For this reason, also use these steps:

  1. List all files that import a database client, such as pg, mysql2, better-sqlite3, psycopg, or sqlite3.
  2. Open each file. Read each call that sends SQL to the database.
  3. For each call, ask one question: does the SQL text contain any value that a user can control?
  4. Mark each call that does. Include values from the URL, the body, the headers, and the cookies.

Also check values that come from your own database. A name that a user saved last week can attack a query that runs today. This is a second-order injection.

How do I test the live app?

Test your own app only. Use a staging copy with test data if you can. A failed attack on a production database can change real data.

Warning: Do not run automated attack tools against a service that you do not own. Do not run them against a production database that has no backup.

  1. Pick a route that takes a text value, such as a search or a filter.
  2. Send a single quote as the value. The quote is the character that ends a text value in SQL.
  3. Read the response. A 500 error, a database error text, or a changed result tells you that the quote reached the SQL.
  4. Send a value that is always true, such as ' OR '1'='1. If the route returns more rows than before, the route is open.
curl -s "https://your-app.example/api/orders?status=paid"
curl -s "https://your-app.example/api/orders?status=%27"
curl -s "https://your-app.example/api/orders?status=%27%20OR%20%271%27%3D%271"

Compare the three responses. A safe route returns the same kind of answer for all three: an empty list or a validation error. An unsafe route returns an error from the database or returns all rows.

Some routes hide the error but still run the injected SQL. Test for a timing difference or a changed row count. If you have no experience with this test, use a scanner that does it for you.

How do I fix each unsafe query?

Fix the queries in order. Start with the routes that need no login, then the routes that read personal data, then the rest.

Fix 1: Use parameters for values

Replace each joined value with a placeholder. Pass the values in a separate array.

app.get("/api/orders", async (req, res) => {
  const status = String(req.query.status ?? "");
  const result = await pool.query(
    "SELECT id, total, status FROM orders WHERE status = $1 AND user_id = $2",
    [status, req.user.id]
  );
  res.json(result.rows);
});

The $1 and $2 markers are placeholders. The driver sends the values apart from the SQL. The database never reads the values as SQL.

Note the second change. The query now also filters by user_id. A parameterized query stops injection, but it does not check who owns the rows. Read the guide on broken object-level authorization for that second problem.

The OWASP SQL Injection Prevention Cheat Sheet lists prepared statements with parameterized queries as the first defense. It also says: “Do not use manual escaping as a substitute for parameter binding.” Do not ask Cursor to “escape the input”. Ask it to use parameters.

Fix 2: Use an allow-list for column names and sort order

A placeholder cannot stand for a column name. The node-postgres documentation says that “PostgreSQL does not support parameters for identifiers.” The OWASP page gives the same limit for table names, column names, and sort words.

Do not join the user value into the query. Map the value to a fixed word in your code:

const sortColumns: Record<string, string> = {
  created: "created_at",
  total: "total",
};
const column = sortColumns[String(req.query.sort)] ?? "created_at";
const direction = req.query.dir === "asc" ? "ASC" : "DESC";

const result = await pool.query(
  `SELECT id, total, created_at FROM orders
   WHERE user_id = $1 ORDER BY ${column} ${direction}`,
  [req.user.id]
);

The template string is safe here, because column and direction can only hold words that you wrote. Any other input gets the default value.

Fix 3: Convert numbers before you use them

Convert limit, offset, and id values to numbers, and set a maximum:

const limit = Math.min(Number.parseInt(String(req.query.limit), 10) || 20, 100);

Send the number as a parameter. The maximum also stops a user from asking for the whole table in one request.

Fix 4: Check the raw query escape hatches

ORMs and query builders protect you only when you use their normal methods. Most have a raw mode. Search for it, and check each use:

  • A tagged template with values (for example, sql followed by a template) sends the values as parameters in most libraries. Read the library documentation to confirm.
  • A function with unsafe or raw in the name takes a plain string. Do not pass user input to it.

If you cannot tell which kind a call is, replace it with a call that uses $1 placeholders.

Fix 5: Check dynamic SQL inside database functions

Cursor can also write SQL functions for Supabase or Postgres. If a function builds a query string with || and runs it with EXECUTE, the same risk applies. Use format with %I for names and pass values with USING:

create or replace function public.orders_by_status(p_status text)
returns setof public.orders
language plpgsql
security invoker
set search_path = ''
as $$
begin
  return query execute
    format('select * from %I.%I where status = $1', 'public', 'orders')
    using p_status;
end;
$$;

Use a plain select ... where status = p_status when you can. Dynamic SQL is rarely needed.

How do I check whether someone used the hole?

Read the logs for the period in which the route was public. Look for requests with SQL characters in the values.

  1. Read the access logs of your host. Search for %27 (the single quote), --, UNION, SELECT, OR%201%3D1, and sleep in the query strings and request bodies.
  2. Read the database logs. Look for syntax errors. A string of syntax errors from one route often shows that someone tried many payloads.
  3. Check for new database users. Run select rolname from pg_roles; and look for roles you did not create.
  4. Check for changed data. Compare row counts and the latest updated_at values with a backup. Look for new admin users and changed email addresses.
  5. Check the permissions of the database user. If your app connects as a user with broad rights, the attacker had those rights. Use the lowest rights that the app needs.

Caution: Request bodies are often not logged. If your host logs only the URL, you cannot prove that nobody attacked a POST route. Treat the data as read in that case.

If you find signs of use, rotate the database password and all API keys that the server can read. Then follow your duty to tell the affected users, if the data included personal information.

How do I make Cursor write safe queries next time?

Add a project rule. Cursor reads project rules from the .cursor/rules folder. The Cursor rules documentation says that project rules must use the .mdc extension. A rule with alwaysApply: true is included in every chat.

Create .cursor/rules/sql-safety.mdc:

---
description: SQL safety rules
alwaysApply: true
---

- Never join user input into SQL text. No template strings, no + operator, no f-strings in query calls.
- Send every value as a parameter ($1, $2) in a separate array.
- For column names and sort order, map the user value to a fixed word with an allow-list.
- Always filter rows by the signed-in user id.
- Do not use raw or unsafe query functions without a comment that explains why.

A rule helps, but the model can still ignore it. Use these additional controls:

  1. Review each query in each change. Search the diff for SELECT, INSERT, UPDATE, DELETE, query(, and execute(.
  2. Add a test for each route that takes text. Send a single quote and check that the route does not return a 500 error.
  3. Add a lint rule. The eslint-plugin-security and similar tools can flag some string-built queries. They do not find all of them.
  4. Use a database user with low rights. The app user must not own the tables and must not create roles.
  5. Use Row Level Security if you use Supabase. RLS limits the rows that a query can reach, even when the SQL is wrong. See the Supabase RLS guide.
  6. Scan the live app after each release. A scan finds routes that the review missed. For more on scanning code written in Cursor, read Cursor security scanner.

For more database controls, read database security best practices.

When should I get help?

Get help in these cases:

  • You find signs of injection in the logs, and the database holds personal data or payment data.
  • You see roles, tables, or admin users that you did not create.
  • You cannot tell which routes reach the database, because the code is large and unreviewed.
  • Your app connects to the database as an owner or superuser account, and you do not know why.
  • You have a legal duty to report a breach in your region.

After the fix, run the three curl tests from the test section again. Each route must return the same kind of answer for the quote value and the always-true value.

Common questions

Does Cursor always write unsafe SQL?
No. Cursor often writes parameterized queries. The risk is that nothing forces it to. A vague prompt or a short example in the context can lead to a query built from strings.
Is an ORM enough to prevent SQL injection?
Only when you use the normal query methods. Most ORMs have a raw query option. If you join user input into a raw query string, the injection risk returns.
Can I fix this with input validation only?
No. Validation helps, but it is not the primary defense. Use parameterized queries first. Add validation as a second layer.
Do I need to rotate my database password after a fix?
Yes, if the vulnerable endpoint was public. An attacker can read data through an injection point. Rotate the credentials and read the logs for the period.
Can a Cursor rule stop all injection bugs?
No. A rule lowers the chance. The model can still ignore it. Review each query in each change, and test the live app.

Test your live endpoints

A VibeEval scan sends injection test input to the endpoints of your deployed app and reports the endpoints that return database errors or changed results.

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

SCAN YOUR APP →