SQL injection has appeared in every edition of the OWASP Top 10 since the list began in 2003, and it still breaks into websites every year. The fix is not complicated; legacy code simply keeps repeating the same broken patterns. This guide shows the attack, the correct fixes in PHP and Node.js, and how to check your own code.
Never build SQL by joining strings with user input. Use parameterised queries: PDO or mysqli prepared statements in PHP, and execute() in mysql2, $1 placeholders in pg, or your ORM's safe methods in Node.js. Identifiers such as column names in ORDER BY cannot be parameterised, so check them against an allowlist. Add least-privilege database users, input validation and hidden error messages as extra layers; a WAF helps but never replaces correct code.
1. What SQL injection is
An attacker makes your application run SQL they wrote, by sneaking it in through user input. The root cause is that the application treats user data as if it were part of the SQL code.
// NEVER DO THIS
$username = $_POST['username'];
$query = "SELECT * FROM users WHERE username = '$username'";
$result = mysqli_query($conn, $query);If an attacker submits admin' -- as the username, the query becomes:
SELECT * FROM users WHERE username = 'admin' --'-- starts a SQL comment, so the rest of the line is ignored, and the query returns the admin user without any password check.
2. Why escaping alone fails
Escaping input with mysqli_real_escape_string is not a defence on its own:
// STILL VULNERABLE
$id = mysqli_real_escape_string($conn, $_POST['id']);
$query = "SELECT * FROM posts WHERE id = $id";Escaping only protects values inside quotes. Here the value is a number with no quotes, so 1 OR 1=1 passes straight through. The right mental model is to separate the code from the data: the database receives the query template and the values separately, and never parses the values as SQL.
3. PHP: the correct fixes
PDO prepared statements (preferred)
$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false, // use real server-side prepares
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :u AND status = :s');
$stmt->execute(['u' => $username, 's' => 'active']);
$user = $stmt->fetch();With emulated prepares off and the character set in the DSN, the template and the values travel to MySQL separately and the values are never parsed as SQL.
mysqli prepared statements
$stmt = $mysqli->prepare('SELECT * FROM users WHERE username = ?');
$stmt->bind_param('s', $username); // s string, i integer, d double, b blob
$stmt->execute();
$result = $stmt->get_result();Laravel and WordPress
$user = User::where('username', $username)->first(); // safe
$users = DB::select('SELECT * FROM users WHERE username = ?', [$username]); // safe
DB::select(DB::raw("SELECT * FROM users WHERE username = '$username'")); // UNSAFEIn WordPress, always pass user input through $wpdb->prepare() before $wpdb->query() or $wpdb->get_results().
What still needs manual care
- Column names in ORDER BY cannot be bound, because placeholders are for values only. Use an allowlist:
$allowed = ['created_at', 'username', 'email'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'created_at';
$stmt = $pdo->prepare("SELECT * FROM users ORDER BY $sort");- LIMIT and OFFSET: bind them with
PDO::PARAM_INT, or cast to an integer and clamp the range:$limit = max(1, min(100, (int)($_GET['limit'] ?? 25))); - Table names: use an allowlist, exactly as for columns.
4. Node.js: the correct fixes
// mysql2: execute() uses a real prepared statement
import mysql from 'mysql2/promise';
const conn = await mysql.createConnection({ host, user, password, database });
const [rows] = await conn.execute(
'SELECT * FROM users WHERE username = ? AND status = ?',
[username, 'active']
);
// pg (PostgreSQL): numbered placeholders
import pg from 'pg';
const pool = new pg.Pool();
const result = await pool.query('SELECT * FROM users WHERE username = $1', [username]);In mysql2, prefer execute() over query(): query() escapes values on the client, while execute() sends a true prepared statement.
ORMs and query builders
// Prisma: safe
await prisma.user.findFirst({ where: { username: input } });
await prisma.$queryRaw`SELECT * FROM users WHERE username = ${input}`; // tagged template, parameterised
// Prisma: UNSAFE
await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE username = '${input}'`);
// Knex: safe, then UNSAFE
await knex('users').where('username', username);
await knex.raw('SELECT * FROM users WHERE username = ?', [username]);
await knex.raw(`SELECT * FROM users WHERE username = '${username}'`); // UNSAFEEvery ORM has an escape hatch for raw SQL (DB::raw, $queryRawUnsafe, $executeRawUnsafe, knex.raw with a template string). Search your code for them and review each one.
In JavaScript, a backtick string with ${} inside a normal function call builds a plain string, exactly like concatenation. Only Prisma's tagged $queryRaw form turns ${} into parameters. When in doubt, use ? or $1 placeholders with a separate values array.
5. Defence in depth
Parameterised queries stop the attack. These layers limit the damage if something else goes wrong.
- Least-privilege database users. The user your application runs as needs only
SELECT, INSERT, UPDATE, DELETEon its own tables. Keep a separate user withCREATE,ALTERandDROPfor migrations. - Validate input at the boundary. Check type, length and format before the query runs, for example with Laravel's
$request->validate()or a schema library such as zod in Node.js. Validation is an extra layer, not a replacement for parameters. - Hide database errors. Set
display_errors = Offin production PHP,APP_DEBUG=falsein Laravel, and return a generic error from your Node.js error handler. Log the detail instead of showing table and column names to attackers. - A web application firewall. A WAF blocks many automated injection attempts, but a determined attacker can craft payloads that slip past its rules.
- Monitoring. Watch your access logs for
UNION,OR 1=1,SLEEP(and very long query strings, and watch for sudden bursts of slow queries.
6. Test and audit your code
- Test on a copy, never on production.On a staging copy you own, try
' OR '1'='1in text fields,admin'--in a login form and1 OR 1=1in numeric fields. If any of them changes the result, you have an injection bug. - Scan with a tool.sqlmap, OWASP ZAP or Burp Suite Community find what manual tests miss. Use them only on sites you own or have written permission to test.
- Add static analysis to your workflow.For PHP, Psalm with
--taint-analysis; for JavaScript, semgrep security rules oreslint-plugin-security. - Grep for the dangerous patterns.Search for string-built SQL,
DB::raw,$queryRawUnsafeandknex.rawwith backticks. - Repeat after big changes.Review again after framework upgrades and new features, especially login, search and admin pages.
Real breaches show why this matters: SQL injection was the way in for the Heartland Payment Systems breach in 2008, the TalkTalk breach in 2015 that exposed about 157,000 customers and led to a £400,000 fine from the UK regulator, and the mass exploitation of the MOVEit Transfer file-sharing software in 2023. The common thread is code nobody had reviewed for years.
7. Running this on Domain India
- cPanel shared hosting: ModSecurity runs for the whole server with Imunify360's full ruleset, which blocks many common injection attempts before they reach your code. It is a second line of defence only. See configuring ModSecurity in cPanel.
- Database access: MySQL port 3306 is closed from outside on every shared server, so your database is reached only by code on the same server. For remote tools, use an SSH tunnel; see how to connect to the MySQL database. If a database password may have been exposed, change it in your control panel.
- Node.js: shared hosting runs Node.js apps through the control panel's Node.js tool, and the App Platform runs Node.js apps with automatic detection. A self-managed VPS gives you root access to add fail2ban or your own WAF rules.
- If your site was already attacked: follow the security checklist for a hacked website. Hacked-site clean-up is not a free service.
Does using an ORM make me safe from SQL injection?
For normal queries, yes: ORMs such as Eloquent, Prisma and Sequelize parameterise values for you. But every ORM also has raw-SQL methods, such as DB::raw or $queryRawUnsafe, and any use of those with user input needs review.
Is mysqli_real_escape_string enough to prevent SQL injection?
No. It only protects values inside quotes, so numeric contexts and character-set mismatches can bypass it. Use prepared statements with bound parameters instead.
Can I use a placeholder for a column name in ORDER BY?
No. Placeholders only work for values, not identifiers such as column or table names. Check the requested column against a fixed allowlist and fall back to a default.
Does my hosting firewall protect me from SQL injection?
A web application firewall such as ModSecurity blocks many automated attacks, but a determined attacker can get past its rules. It is an extra layer; parameterised queries in your code are the real defence.
What is the difference between SQL injection and XSS?
SQL injection makes your database run the attacker's SQL; cross-site scripting (XSS) makes a visitor's browser run the attacker's JavaScript. SQL injection is fixed with parameterised queries, XSS with output encoding.
Can NoSQL databases like MongoDB be injected?
Yes. NoSQL injection has the same root cause, user input treated as part of the query, for example an object with an operator passed where a string was expected. Validate input types and use the driver's query objects rather than building queries from raw input.
Ready to secure your application? Review your code against the checklist above, and if you need help with your hosting account, open a support ticket or compare cPanel hosting and the App Platform.
Every cPanel plan runs on servers with ModSecurity and Imunify360 configured for the whole server, whatever plan you choose.
See cPanel hosting