SQL injection in Cursor-generated code: how to find and fix it
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:
- List all files that import a database client, such as
pg,mysql2,better-sqlite3,psycopg, orsqlite3. - Open each file. Read each call that sends SQL to the database.
- For each call, ask one question: does the SQL text contain any value that a user can control?
- 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.
- Pick a route that takes a text value, such as a search or a filter.
- Send a single quote as the value. The quote is the character that ends a text value in SQL.
- Read the response. A
500error, a database error text, or a changed result tells you that the quote reached the SQL. - 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,
sqlfollowed by a template) sends the values as parameters in most libraries. Read the library documentation to confirm. - A function with
unsafeorrawin 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.
- Read the access logs of your host. Search for
%27(the single quote),--,UNION,SELECT,OR%201%3D1, andsleepin the query strings and request bodies. - Read the database logs. Look for syntax errors. A string of syntax errors from one route often shows that someone tried many payloads.
- Check for new database users. Run
select rolname from pg_roles;and look for roles you did not create. - Check for changed data. Compare row counts and the latest
updated_atvalues with a backup. Look for new admin users and changed email addresses. - 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:
- Review each query in each change. Search the diff for
SELECT,INSERT,UPDATE,DELETE,query(, andexecute(. - Add a test for each route that takes text. Send a single quote and check that the route does not return a
500error. - Add a lint rule. The
eslint-plugin-securityand similar tools can flag some string-built queries. They do not find all of them. - Use a database user with low rights. The app user must not own the tables and must not create roles.
- 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.
- 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?
Is an ORM enough to prevent SQL injection?
Can I fix this with input validation only?
Do I need to rotate my database password after a fix?
Can a Cursor rule stop all injection bugs?
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