How to secure a PHP application
The handful of defences that stop almost every real attack on a PHP application, and the reasoning behind each one.
Most PHP applications are not broken into by anything clever. They are broken into through a small number of well-understood mistakes, each of which has a well-understood fix that takes minutes to apply. This is a tour of those mistakes and those fixes.
The organising idea underneath all of them: never let data become code, and never trust anything that arrived from outside your program. Almost every item below is a specific application of that one sentence.
1. Use prepared statements, always
SQL injection happens when user input is concatenated into a query, so that the database ends up parsing the input as SQL rather than treating it as a value. Prepared statements close the hole completely, because the query structure is sent to the database separately from the data.
The dangerous shape looks like this:
// Never do this.
$sql = "SELECT * FROM users WHERE email = '" . $_POST['email'] . "'";
The safe version with PDO:
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$_POST['email']]);
$user = $stmt->fetch();
Or with named parameters, which read better as queries grow:
$stmt = $pdo->prepare('SELECT * FROM orders WHERE user_id = :uid AND status = :status');
$stmt->execute([':uid' => $userId, ':status' => 'paid']);
Functions that escape a string are easy to forget on one query out of two hundred, and that one query is the vulnerability. Prepared statements are safe by default, which is the property you want. Make them the only way your codebase talks to the database.
One thing prepared statements cannot parameterise is an identifier — a table or column name. If a sort order or column comes from user input, validate it against an allowlist you control rather than interpolating it:
$allowed = ['created_at', 'total', 'status'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'created_at';
What SQL injection is covers the mechanism in more detail.
2. Escape on output, not on input
Cross-site scripting happens when user-supplied text is written into a page and the browser parses it as HTML. The fix is to escape at the moment you render, according to where the value is going.
echo htmlspecialchars($comment, ENT_QUOTES, 'UTF-8');
Escape on output rather than sanitising on input, for a practical reason: the same stored value may later be rendered into HTML, into a JSON response, into an email and into a CSV file, and each of those needs different treatment. Sanitising on the way in destroys the original and still gets the other contexts wrong.
Context matters. A value going inside an HTML attribute, into a URL, or into inline JavaScript each needs different handling — and user data inside a <script> block is best avoided entirely rather than escaped. If you use a template engine with automatic escaping, leave it switched on and treat every place you disable it as a decision to justify.
3. Hash passwords with the functions built for it
Never store a password you could read back. Never use a general-purpose hash such as MD5 or SHA-1 for this — they are fast, and speed is exactly the property an attacker with a stolen database wants.
// Storing
$hash = password_hash($password, PASSWORD_DEFAULT);
// Checking
if (password_verify($password, $user['password_hash'])) {
// correct
}
PASSWORD_DEFAULT tracks whatever PHP currently considers strongest, so your stored hashes improve as PHP does. Use password_needs_rehash() at login to upgrade old hashes transparently while you have the plaintext in hand.
Enforce a decent minimum length and check against known-breached password lists. Forced composition rules and frequent expiry push people towards predictable patterns. See choosing passwords worth having.
4. Protect state-changing requests with CSRF tokens
Cross-site request forgery works because the browser attaches your session cookie to a request no matter which site caused it. A form on somebody else's page can therefore act as you.
Give every state-changing form an unpredictable token, and reject requests without it:
// When rendering the form
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
echo '<input type="hidden" name="csrf" value="' . $_SESSION['csrf'] . '">';
// When handling the submission
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('Invalid request');
}
Two details matter. Use random_bytes(), not rand() or uniqid() — those are predictable. Use hash_equals() rather than == for the comparison, so the check takes the same time regardless of how much of the token matched.
Setting SameSite=Lax on your session cookie is a strong second layer, but tokens remain the primary defence.
5. Harden the session
session_set_cookie_params([
'httponly' => true, // JavaScript cannot read it
'secure' => true, // HTTPS only
'samesite' => 'Lax', // not sent on cross-site POSTs
]);
session_start();
And regenerate the session ID the moment privileges change — immediately after a successful login — so that an ID an attacker planted beforehand becomes useless:
session_regenerate_id(true);
6. Treat file uploads as hostile
An upload form that lets someone place a .php file inside your web root hands over the whole account. Defend in layers:
- Never trust the supplied filename or MIME type. Both are attacker-controlled. Generate your own filename and derive the extension from a verified type.
- Check the real content. For images,
getimagesize()or thefinfoextension inspects the file itself rather than its label. - Store uploads outside the web root where possible, and serve them through a script that sets the content type. If they must sit in the web root, ensure PHP execution is disabled in that directory.
- Never include a user-supplied path.
include $_GET['page']is a remote-code-execution hole. Map user input to a fixed allowlist of files instead.
7. Keep secrets out of the web root and out of Git
Database passwords and API keys belong in a configuration file outside the document root, or in environment variables. A .env file sitting in public_html is one misconfigured server away from being downloadable as plain text, and a secret committed to Git stays in the history after you delete it.
See setting environment variables for the practical setup.
8. Do not show errors to the public
A stack trace tells an attacker your absolute paths, your framework, your database name and sometimes your credentials. In production, log errors and display nothing:
display_errors = Off
log_errors = On
Turn display on only while you are actively debugging, and turn it off again immediately — see turning PHP errors on while you debug.
9. Update, and know what you depend on
Most compromised PHP applications are running a known-vulnerable version of something the owner forgot they had installed. Run a supported PHP version, keep your dependencies patched, and remove packages you no longer use. composer audit checks your installed packages against published advisories.
10. Give every component the least privilege it needs
The application's database user does not need DROP or GRANT. Files should be readable, not world-writable — folders 755, files 644, and never 777. See setting safe file permissions.
The point of least privilege is that it limits the blast radius. It does not prevent the first mistake; it stops that mistake from becoming a total loss.
A short checklist
| Area | The defence |
|---|---|
| Database | Prepared statements everywhere; allowlist any identifier that comes from input |
| Output | Escape at render time, per context |
| Passwords | password_hash() and password_verify() |
| Forms | CSRF token compared with hash_equals() |
| Sessions | HttpOnly, Secure, SameSite; regenerate ID on login |
| Uploads | Own filename, verified type, no execution in the upload directory |
| Secrets | Outside the web root, out of Git |
| Errors | Logged, never displayed in production |
| Dependencies | Supported PHP version, patched packages, unused ones removed |
| Permissions | Least privilege for the database user and the filesystem |
None of this is exotic, and that is rather the point. Applications are rarely lost to a novel technique; they are lost to one forgotten query, one unescaped field, or one upload directory that could execute PHP. Add security headers on top of the list above and you have closed the routes that attacks actually take.
Related reading
A decades-old vulnerability that still appears in new code, and the small set of habits that reliably prevent it.
How to secure a WordPress siteUpdates, strong unique logins, two-factor authentication, a minimal plugin list, and backups that actually restore — in that order of importance.
How to add security headers to your siteA handful of response headers close off entire categories of attack for very little effort. Here is what each does and how to add them.
How to set safe file permissionsThe two numbers that cover almost every case on a website: 755 for folders, 644 for files, and why 777 always creates a worse problem.