What is SQL injection and how do you prevent it?
SQL injection occurs when a value coming from the user is added to a database query by concatenating text. The application sends what the user typed to the database not as data but as part of the query, so the input can change the meaning of the query. The result may be unauthorised reading of data, records being altered or deleted, or the login check being bypassed.
The good news is that SQL injection is one of the vulnerabilities whose cause and cure are best understood. Writing queries with prepared statements solves the problem at its source. This guide explains the concept and how to apply it correctly with PHP and PDO.
In brief
- The vulnerability arises when user input is concatenated into the query text.
- The solution is prepared statements: data and command are sent separately.
- Parts that cannot be bound, such as column names and sort order, are chosen from an allowlist.
- A least-privilege database user and hidden error messages limit the damage.
On this page
Where does the problem start and where does it end?
-
Data and command get mixed together
A search box, a login form, an
idparameter in the address bar or a cookie value: all of these are under the user's control. When the application wraps such a value in quotes and adds it to the query text, the database cannot tell which part is the command written by the developer and which part is the user's data. This is the essence of the vulnerability: data and command being combined in the same text.
: Enlarge -
A prepared statement separates data from command
With a prepared statement, the structure of the query and the data are sent to the database separately. The query text contains placeholders (
?or:name) instead of values; the values are bound afterwards and are never interpreted as a command under any circumstances. Whatever the user types, it is only ever a piece of data to be searched for or stored.PHP<?php // WRONG: user input is concatenated into the query text // $sql = "SELECT id, ad FROM urunler WHERE kategori = '" . $_GET['kategori'] . "'"; // RIGHT: placeholders + binding $sorgu = $pdo->prepare('SELECT id, ad FROM urunler WHERE kategori = ? AND durum = ?'); $sorgu->execute([$_GET['kategori'] ?? '', 1]); $urunler = $sorgu->fetchAll();
: Enlarge -
Use an allowlist where binding is not possible
Placeholders can only be used for values. Structural parts of the query such as a table name, a column name or the sort direction (
ASC/DESC) cannot be bound. If these parts depend on the user's choice, the input is not placed directly in the query; it is matched against a predefined allowlist.
: Enlarge -
Second layers that limit the damage
Prepared statements are the real solution; even so, a mistake may have been made somewhere. Giving the application's database user only the privileges it needs, not showing error details to visitors and validating inputs by type all reduce the impact of a possible vulnerability.
: Enlarge
Signs: how do you spot it?
- In a code review: If you see a variable being concatenated into the query text (
"... WHERE id = " . $id, or a$variableinside double quotes), that spot is suspect. Trace every path by which$_GET,$_POST,$_COOKIEand$_SERVERvalues reach a query. - On the site: If the page shows a database error when a special character such as a single quote is typed into a field, it is a sign that the input reaches the query without being escaped. Only try this on your own site and in a test environment.
- In the logs: Large numbers of requests in the access log with SQL keywords in their parameters, repeated syntax errors in the error log, unusually slow queries in the database.
- In the data: Administrator accounts you did not add, altered content, unfamiliar links added to pages.
Connecting correctly with PDO
Three settings matter when you open the connection: errors being thrown as exceptions, real prepared statements being used (emulation switched off) and the character set being specified in the connection string.
<?php
$pdo = new PDO(
'mysql:host=localhost;dbname=ornek_db;charset=utf8mb4',
$dbKullanici,
$dbParola,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);In projects that use mysqli, the same principle is applied with prepare and bind_param. Rather than mixing the two layers in one project, use the existing one consistently.
Common special cases
Sorting and column names
Do not put the sort field chosen by the user directly into the query; match it to a fixed value in an allowlist.
<?php
// User's choice -> fixed column name to use in the query
// (ad = name, fiyat = price, tarih = date)
$siralamaAlanlari = [
'ad' => 'ad',
'fiyat' => 'fiyat',
'tarih' => 'eklenme_tarihi',
];
$secim = $_GET['sirala'] ?? 'ad';
$kolon = $siralamaAlanlari[$secim] ?? 'ad'; // default if not in the list
$yon = (($_GET['yon'] ?? '') === 'azalan') ? 'DESC' : 'ASC'; // only two fixed values
$sayfa = max(1, (int) ($_GET['sayfa'] ?? 1));
$adet = 20;
$bas = ($sayfa - 1) * $adet;
$sorgu = $pdo->prepare("SELECT id, ad, fiyat FROM urunler WHERE durum = ? ORDER BY $kolon $yon LIMIT ?, ?");
$sorgu->bindValue(1, 1, PDO::PARAM_INT);
$sorgu->bindValue(2, $bas, PDO::PARAM_INT);
$sorgu->bindValue(3, $adet, PDO::PARAM_INT);
$sorgu->execute();Searching with LIKE
The search term is still bound with a placeholder. Because the % and _ characters act as wildcards inside LIKE, escaping these characters when the user types them prevents unexpectedly broad matches.
<?php
$terim = trim((string) ($_GET['q'] ?? ''));
// Neutralise the LIKE wildcards (% and _) and the escape character
$terim = addcslashes($terim, '%_\\');
$sorgu = $pdo->prepare('SELECT id, baslik FROM yazilar WHERE baslik LIKE ? LIMIT 50');
$sorgu->execute(['%' . $terim . '%']);IN lists
For a variable number of values, generate as many placeholders as there are elements; again, send the values by binding them.
<?php
$idler = array_map('intval', (array) ($_POST['idler'] ?? []));
$idler = array_values(array_filter($idler, fn ($id) => $id > 0));
if ($idler !== []) {
$yerTutucular = implode(',', array_fill(0, count($idler), '?'));
$sorgu = $pdo->prepare("SELECT id, ad FROM urunler WHERE id IN ($yerTutucular)");
$sorgu->execute($idler);
$urunler = $sorgu->fetchAll();
}LIMIT and numeric values
Convert numeric inputs such as a page number to an integer and check their range. Converting to an integer is good validation, but it does not replace a prepared statement; use the two together.
If you use an ORM or a query builder
The ORMs and query builders of frameworks such as Laravel and Symfony bind values automatically in normal use. The risk returns wherever a "raw query" is written: if user input is concatenated into methods such as whereRaw, orderByRaw and DB::raw, the same vulnerability arises. If a raw query is needed, use the binding parameters of these methods; for sort fields, apply an allowlist here too.
A least-privilege database user
A web application should not connect to the database as root or as a user with all privileges. Create a user dedicated to the application and allow only the operations it needs, only on its own database. Use a separate user for tasks that require schema changes (creating or dropping tables).
-- Application-specific user: only on its own database, only data operations
CREATE USER 'ornek_uyg'@'localhost' IDENTIFIED BY 'a-long-and-unique-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON ornek_db.* TO 'ornek_uyg'@'localhost';
-- Not granted: DROP, ALTER, CREATE, FILE, GRANT OPTION and other databasesOn shared hosting, users and privileges are usually defined in the control panel; the principle is the same: a separate database and a separate user for each site. The database server should be closed to access from external networks.
Hide error messages and write them to the log
Database error messages reveal table and column names, the structure of the query and file paths. In production, errors should not be shown to visitors; they should be written to the log and a general message returned to the user.
<?php
try {
$sorgu = $pdo->prepare('SELECT id, ad FROM musteriler WHERE eposta = ?');
$sorgu->execute([$eposta]);
$musteri = $sorgu->fetch();
} catch (PDOException $hata) {
// Details go to the log; the visitor gets a general message
error_log('Database error: ' . $hata->getMessage());
http_response_code(500);
echo 'This operation cannot be completed at the moment. Please try again later.';
return;
}In the PHP settings, production should also have display_errors = Off and log_errors = On.
Methods that fall short
- Blocklists: Stripping certain words or characters from the input is not reliable; it can be circumvented and it damages legitimate input.
addslashesand escaping quotes by hand: Not safe, because of differences in character set and context.- Client-side validation only: JavaScript checks in the browser are there for the user experience; a request can be sent straight to the server.
- A firewall (WAF) only: A useful extra layer, but it does not remove the vulnerability in the code.
- Concatenating text inside a stored procedure: Using a stored procedure does not provide protection by itself; if dynamic SQL is concatenated inside the procedure, the vulnerability remains.
Checklist
- There is nowhere left in the project where a variable is concatenated into query text.
- On the PDO connection, exception mode is on, emulation is off and the character set is
utf8mb4. - Sort orders, column names and table names come from an allowlist.
- Numeric inputs are converted to integers and their range is checked.
- The framework's raw query methods have been reviewed.
- The application's database user has only the privileges it needs; each site uses a separate user.
- In production, error details are not shown on screen; they are written to the log.
- The database is backed up regularly.
Frequently asked questions
If I use prepared statements, do I need to do anything else?
Prepared statements are the main protection for values. You also need an allowlist for parts that cannot be bound, such as sort order and column names, and, as additional layers, a least-privilege database user and hidden error messages.
Does cleaning input with htmlspecialchars prevent SQL injection?
No. htmlspecialchars is used against XSS when writing output to the page; query security requires prepared statements. Each measure is applied in its own context.
An old project has hundreds of queries; where should I start?
First the pages that can be reached without logging in and forms such as login, search and contact; then the admin panel. Search the code for places where a variable is concatenated into query text, make a list and convert each one to a prepared statement.
I use a NoSQL database; does this risk concern me?
Injection is not unique to SQL. A similar risk exists in every system where user input gets into the structure of a query; use the parameterised query methods of your database and validate input types.
BYK Yazılım Support Team
This guide is written and regularly reviewed by the BYK Yazılım support team. Last updated: 4 October 2026.
Related guides
- What is XSS and how do you prevent it?Context-aware output escaping, Content-Security-Policy, HttpOnly cookies and rich-text sanitising.
- Login and session securityPassword hashing, attempt limits, session fixation, cookie flags and authorisation checks on every request.
- Server and hosting securitySFTP/FTPS, file permissions, directory listing, error display, sensitive files and database access.
- Website security guide: where should you start?Threat types, layered defence, priorities and a roadmap to all the security guides.
Let us review your website together
BYK Yazılım builds corporate websites. Write to us with any questions about your site.
Contact us Our corporate website service