
What is SQL injection and how to prevent it?
SQL injection (SQLi) is a vulnerability that lets an attacker change the database queries your application runs by slipping SQL code into input fields, URL parameters, cookies, or headers. You prevent it by never building queries through string concatenation with user input, and instead using parameterized queries (prepared statements), validating input, limiting database privileges, and keeping third-party code updated.
Despite being one of the oldest known web vulnerabilities, SQL injection is still found regularly, especially in plugins, themes, and custom code written in a hurry. The impact can be severe, from leaking every user's details to taking over an entire site. This article explains how SQL injection works, the main types, what it looks like in real code, and exactly how to write queries that are safe, with examples for WordPress, plain PHP, Node.js, and Python.
How Does SQL Injection Work?
Most dynamic websites store their content in a relational database such as MySQL, MariaDB, or PostgreSQL. Your application talks to the database using SQL queries. The problem arises when an application builds a query by gluing user input directly into the SQL string.
Consider this vulnerable PHP code that looks up a user by username:
// VULNERABLE: never do this
$username = $_GET['username'];
$sql = "SELECT * FROM users WHERE username = '$username'";
$result = $mysqli->query( $sql );
If a visitor supplies alice, the query becomes:
SELECT * FROM users WHERE username = 'alice'
But if an attacker supplies ' OR 1=1 --, the query becomes:
SELECT * FROM users WHERE username = '' OR 1=1 -- '
The quote closes the string early, OR 1=1 is always true, and -- comments out the rest of the line. Instead of returning one user, the query returns every user. The database has no way of knowing that part of the query came from untrusted input; it simply executes what it receives.
The root cause is always the same: data is being treated as code.
What Can Attackers Do with SQL Injection?
The impact depends on the query, the database permissions, and the application, but it can include:
- Reading sensitive data: User accounts, email addresses, password hashes, orders, and private content.
- Bypassing authentication: Logging in without a valid password in poorly written login code.
- Modifying data: Changing prices, altering content, or granting themselves admin roles.
- Deleting data: Dropping tables or wiping records.
- Taking over the site: On WordPress, for example, inserting a new administrator into the users table.
- Reaching the server: In some configurations, databases can read or write files on the server, which can lead to further compromise.
Types of SQL Injection
Security professionals group SQL injection into a few categories, based on how the attacker gets results back.
In-Band SQL Injection
The attacker uses the same channel to inject and receive results.
- Error-based: Database error messages displayed on the page reveal information about the query and data. This is one reason to never show raw database errors to visitors.
- Union-based: The attacker uses a
UNION SELECTto append results from another table to the legitimate results displayed on the page.
Blind SQL Injection
The page doesn't show query results or errors, but the attacker can still infer information.
- Boolean-based: The page behaves differently depending on whether an injected condition is true or false.
- Time-based: The attacker injects a condition that makes the database pause, then measures how long the response takes.
Blind injection is slower for an attacker but just as dangerous, and automated tools make it practical.
Out-of-Band SQL Injection
Less common, this relies on the database making an external connection, such as a DNS lookup, to send data to a server the attacker controls. Restricting outbound network access from database servers limits it.
Second-Order SQL Injection
Here, malicious input is stored safely at first, then used unsafely later. For example, a username saved correctly during registration might be concatenated into a query in a different feature. Every query needs to be safe, not just those that handle fresh input.
Where SQL Injection Hides
Any value that ends up in a query can be an injection point:
- Form fields such as search, login, contact, and checkout.
- URL query parameters like
?id=5or?orderby=date. - Cookies and HTTP headers such as
User-AgentorX-Forwarded-For, if logged or queried. - JSON bodies sent to APIs and AJAX handlers.
- Sort columns and sort directions, which can't be parameterized and need allowlisting.
- Data imported from files or third-party APIs.
How to Prevent SQL Injection
1. Use Prepared Statements (Parameterized Queries)
This is the primary defense. With a prepared statement, the SQL structure is sent separately from the data. The database treats the data strictly as values, never as part of the SQL command, so ' OR 1=1 -- is just an odd-looking username.
In WordPress with $wpdb
WordPress provides $wpdb->prepare(), which uses placeholders: %s for strings, %d for integers, %f for floats, and %i for identifiers such as table or column names (available since WordPress 6.2).
global $wpdb;
$username = sanitize_user( wp_unslash( $_GET['username'] ?? '' ) );
$user = $wpdb->get_row(
$wpdb->prepare(
"SELECT ID, user_login, user_email FROM {$wpdb->users} WHERE user_login = %s",
$username
)
);
For LIKE searches, escape the wildcard characters first with $wpdb->esc_like():
global $wpdb;
$term = sanitize_text_field( wp_unslash( $_GET['q'] ?? '' ) );
$like = '%' . $wpdb->esc_like( $term ) . '%';
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts}
WHERE post_status = 'publish' AND post_title LIKE %s
LIMIT 20",
$like
)
);
For inserts and updates, $wpdb->insert() and $wpdb->update() handle escaping for you when you pass data and format arrays:
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'sajjad_leads',
array(
'email' => sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ),
'created_at' => current_time( 'mysql' ),
),
array( '%s', '%s' )
);
Better still, use higher-level APIs like WP_Query, get_posts(), get_users(), and get_option() whenever possible, since they build safe queries for you.
In Plain PHP with PDO
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
'app_user',
getenv( 'DB_PASSWORD' ),
array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
)
);
$stmt = $pdo->prepare( 'SELECT id, email FROM users WHERE username = :username' );
$stmt->execute( array( ':username' => $_GET['username'] ?? '' ) );
$user = $stmt->fetch();
In Node.js with mysql2
import mysql from "mysql2/promise";
const pool = mysql.createPool({
host: "localhost",
user: "app_user",
password: process.env.DB_PASSWORD,
database: "app",
});
export async function findUser(username) {
const [rows] = await pool.execute(
"SELECT id, email FROM users WHERE username = ?",
[username],
);
return rows[0] ?? null;
}
In Python with psycopg (PostgreSQL)
import os
import psycopg
def find_user(username):
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, email FROM users WHERE username = %s",
(username,),
)
return cur.fetchone()
Note that in Python you pass the values as a separate argument. Using Python string formatting to build the query would reintroduce the vulnerability.
2. Allowlist Identifiers and Keywords
Placeholders work for values, but some parts of a query, like column names and sort direction, often can't be parameterized in older code. Never pass them straight from input. Map input to a fixed set of allowed values instead:
$allowed_orderby = array(
'date' => 'post_date',
'title' => 'post_title',
);
$orderby_key = sanitize_key( $_GET['orderby'] ?? 'date' );
$orderby = $allowed_orderby[ $orderby_key ] ?? 'post_date';
$order = ( 'asc' === strtolower( $_GET['order'] ?? '' ) ) ? 'ASC' : 'DESC';
$sql = "SELECT ID, post_title FROM {$wpdb->posts}
WHERE post_status = 'publish'
ORDER BY {$orderby} {$order}
LIMIT 20";
Because $orderby and $order can only ever be one of a few hard-coded strings, they're safe to include.
3. Validate and Sanitize Input
Validation isn't a replacement for prepared statements, but it adds a useful layer. If you expect an integer ID, cast it with absint() or (int). If you expect an email, use sanitize_email() and is_email(). Reject input that doesn't match the expected format.
4. Apply Least Privilege to Database Users
Your application's database user should only have the permissions it needs, and only on its own database. A typical WordPress site needs SELECT, INSERT, UPDATE, and DELETE, plus schema-changing rights like CREATE, ALTER, INDEX, and DROP during updates and plugin installs. It should never need global privileges such as FILE or SUPER.
CREATE USER 'wp_site'@'localhost' IDENTIFIED BY 'use-a-long-random-password';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP
ON wp_site_db.* TO 'wp_site'@'localhost';
FLUSH PRIVILEGES;
Use a separate database and user for each site, so one compromised site can't read another's data.
5. Hide Database Errors from Visitors
Detailed errors help attackers. In production WordPress, keep WP_DEBUG_DISPLAY set to false and log errors privately. In plain PHP, set display_errors = Off in php.ini and log exceptions server-side.
6. Keep Plugins, Themes, and Libraries Updated
On WordPress, most SQL injection vulnerabilities are found in third-party plugins. Updating promptly and removing unused plugins is one of your strongest defenses as a site owner, even if you never write a line of SQL yourself.
7. Add a Web Application Firewall
A WAF blocks many common SQL injection payloads before they reach your application and can provide virtual patches for known plugin vulnerabilities. It's a safety net, not a substitute for safe code.
Testing Your Code for SQL Injection
If you write custom code, review every query:
- Search your codebase for
$wpdb->query,$wpdb->get_results,$wpdb->get_var, and$wpdb->get_rowcalls, and confirm each usesprepare()whenever variables are involved. - Run PHP_CodeSniffer with the WordPress Coding Standards, which flags unprepared queries.
- Use static analysis tools such as PHPStan or Psalm for plain PHP projects.
- Only run dynamic scanners against sites you own or have written permission to test.
You can install the WordPress coding standards with Composer and scan a plugin like this:
composer require --dev wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
vendor/bin/phpcs --standard=WordPress path/to/your-plugin
FAQ: SQL Injection
SQL injection is when an attacker types database commands into a form or URL, and a poorly written website runs them as part of its own database query, letting the attacker read or change data they shouldn't.
Escaping can help, but it's error-prone and easy to get wrong. Prepared statements with bound parameters are the reliable approach because they keep data completely separate from the SQL command.
WordPress core uses safe database APIs and is rarely affected. Most SQL injection issues in the WordPress ecosystem are found in third-party plugins and themes, which is why updates matter so much.
A WAF blocks many common SQL injection patterns and can virtually patch known plugin flaws. It can't guarantee protection against every variation, so the underlying code should still use prepared statements.
ORMs and query builders use parameterized queries by default, which prevents most SQL injection. However, raw query methods inside an ORM can still be vulnerable if you concatenate user input into them.
Yes. NoSQL databases like MongoDB can suffer from similar injection attacks when user input is used to build query objects. The same principle applies: never let untrusted input change the structure of a query.
Report it privately to the plugin author or through a coordinated disclosure program such as Patchstack or Wordfence's bug bounty. In the meantime, consider deactivating the plugin or relying on a WAF rule.
Conclusion
SQL injection happens when user input is treated as part of a database command instead of as data. Attackers exploit it to read, change, or delete data, bypass logins, and sometimes take over entire sites. It has several forms, from in-band and blind to second-order injection, but they all share the same root cause.
The fix is equally consistent: use prepared statements for every query that includes variable data, allowlist anything that can't be parameterized, validate input, give database users only the privileges they need, hide errors from visitors, and keep third-party code updated. Follow those rules, and SQL injection moves from a serious risk to a solved problem in your code.


