Protect Upload Directories
File uploads are one of the most dangerous features in a web application because users are intentionally being allowed to send files to the server. If the validation logic is weak, an attacker may attempt to disguise executable code as an image or document.
The first rule I follow is simple: uploaded files should not be executable.
For an Apache-based upload directory, PHP execution can be blocked with a configuration similar to:
<FilesMatch "\.(php|phtml|phar|php[0-9]*)$">
Require all denied
</FilesMatch>
Depending on the hosting environment, additional PHP handler restrictions may also be appropriate. The objective is to make sure that even if a malicious script somehow reaches the upload directory, the server refuses to execute it.
I also avoid trusting the filename supplied by the user. The browser may claim that a file is called vacation.jpg, but the application should independently verify the file type and create its own storage name.
A basic PHP upload workflow might look like this:
<?php
$allowedTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp'
];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['upload']['tmp_name']);
if (!isset($allowedTypes[$mime])) {
throw new RuntimeException('Unsupported file type.');
}
$filename = bin2hex(random_bytes(16)) . '.' . $allowedTypes[$mime];
?>
The important idea is that the server determines the extension instead of trusting whatever extension the user supplied.
Never Trust MIME Types Sent by the Browser
The MIME type included in an HTTP upload can be manipulated. If PHP receives an upload claiming to be image/jpeg, that value alone should not determine whether the file is safe.
Using finfo allows PHP to inspect the actual file contents and determine a more reliable MIME type. Even then, I still treat uploaded files cautiously.
For images, one additional defense is to actually decode and re-encode the image. Instead of preserving the original uploaded binary data, the application can load the picture into an image-processing library and generate a fresh JPEG or WebP file. This strips away much of the unexpected data that may have been embedded in the original upload.
That approach has another benefit. It standardizes image dimensions, compression, and format while simultaneously improving security.
Use Least-Privilege Database Accounts
Another common mistake is giving the website database user more privileges than the application actually needs. A public website usually does not need the ability to create new database users, grant privileges, or administer the entire MySQL server.
The application should have its own dedicated database account with only the permissions required by the application.
A simplified example might be:
CREATE USER 'website_app'@'localhost'
IDENTIFIED BY 'strong-random-password';
GRANT SELECT, INSERT, UPDATE, DELETE
ON production_database.*
TO 'website_app'@'localhost';
Whether the application needs additional privileges depends on how it operates, but I avoid giving a public-facing application full administrative privileges unless there is a specific reason.
Least privilege limits the blast radius of an application compromise. If the website database account is stolen, the attacker receives only the permissions assigned to that account instead of control over every database on the server.
Use Prepared Statements Everywhere
SQL injection remains one of the most important web application vulnerabilities because it can turn user-controlled input into executable SQL. The solution is not trying to manually escape suspicious characters. The solution is separating SQL structure from user data.
With PDO, I use prepared statements.
<?php
$stmt = $pdo->prepare(
'SELECT id, username, email
FROM users
WHERE email = :email'
);
$stmt->execute([
':email' => $email
]);
$user = $stmt->fetch();
?>
The important point is that $email is treated as data rather than being concatenated into the SQL command.
I avoid code like this:
<?php
$sql = "SELECT * FROM users WHERE email = '$email'";
?>
Even when developers believe they control the input, applications change over time. Prepared statements should be the default pattern rather than something used only on fields considered dangerous.
Secure Administrator Authentication
The administrator interface deserves stronger protection than the public-facing side of the website. If possible, I use multifactor authentication for administrative accounts. At minimum, administrator passwords should be long, unique, and stored using PHP's password hashing functions.
Password storage should look something like this:
<?php
$hash = password_hash($password, PASSWORD_DEFAULT);
?>
Authentication then uses:
<?php
if (password_verify($password, $storedHash)) {
// Successful authentication.
}
?>
Passwords should never be stored in plaintext, encrypted with a reversible key, or hashed with outdated algorithms such as MD5.
I also avoid revealing whether a particular username or email address exists. Instead of saying, “There is no account with that email address,” the application can display a generic authentication failure message.
This prevents the login page from becoming an account-discovery tool.
Harden PHP Sessions
Authentication security does not end when the password is accepted. Once a user logs in, the session cookie effectively becomes the user's temporary credential.
I configure session cookies to use security attributes whenever the site runs over HTTPS.
<?php
session_set_cookie_params([
'httponly' => true,
'secure' => true,
'samesite' => 'Lax'
]);
?>
The HttpOnly attribute makes it harder for client-side JavaScript to access the cookie. The Secure attribute tells the browser to send it only over HTTPS. The SameSite attribute can help reduce certain cross-site request attacks.
After a successful login, I regenerate the session identifier.
<?php
session_regenerate_id(true);
?>
This reduces the risk of session fixation attacks.
Administrative sessions can also use inactivity timeouts. If an administrator walks away from a logged-in browser for several hours, the session should not necessarily remain active indefinitely.
Protect Every State-Changing Request With CSRF Tokens
A Cross-Site Request Forgery attack tricks an authenticated user's browser into submitting an unwanted request. Imagine an administrator being logged into a CMS while visiting another malicious website. If the CMS does not verify where sensitive requests originate, the malicious page may be able to trigger actions using the administrator's authenticated session.
For forms that create, update, or delete information, I use CSRF tokens.
A token can be generated and stored in the session:
<?php
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
The token is then placed inside the form:
<input type="hidden"
name="csrf_token"
value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
When the form is submitted, the application verifies it.
<?php
if (
empty($_POST['csrf_token']) ||
!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
) {
http_response_code(403);
exit('Invalid request.');
}
?>
The token does not replace authentication. It adds an additional verification step proving that the request originated from a form generated by the application.
Escape Output, Not Just Input
Developers sometimes focus entirely on sanitizing incoming data, but output handling is equally important. A value stored in a database might later be displayed inside HTML, JavaScript, an attribute, or a URL. Each context has different escaping requirements.
For ordinary HTML output, I use:
<?php
echo htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
?>
This is especially important for comments, usernames, article titles, form submissions, and any other data that originated outside the application.
Stored Cross-Site Scripting can be particularly dangerous because the malicious payload remains in the database. If that payload appears inside an administrator dashboard, it may execute every time an administrator views the affected record.
Do Not Expose Detailed PHP Errors to Visitors
Development errors are useful during programming, but production users should not receive stack traces, database errors, filesystem paths, or configuration details.
Production PHP should generally avoid displaying internal errors directly to the browser.
display_errors = Off
log_errors = On
Errors should instead be written to a protected log file.
A database exception such as this:
SQLSTATE[HY000] [1045] Access denied for user...
may reveal database usernames, software versions, internal paths, and application structure. None of that needs to be shown to an anonymous visitor.
The user can receive a generic message while the real error is recorded privately for debugging.
Create Security Logging That Is Actually Useful
Standard access logs are valuable, but I also like application-level security logging. The application can record suspicious events such as repeated login failures, blocked upload types, attempts to access protected endpoints, invalid CSRF tokens, and requests for files that should never exist publicly.
A basic security log entry might contain the timestamp, IP address, request URI, user agent, authenticated user ID if available, and the rule that triggered the alert.
For example:
<?php
$entry = sprintf(
"[%s] IP=%s URI=%s EVENT=%s\n",
date('c'),
$_SERVER['REMOTE_ADDR'] ?? 'unknown',
$_SERVER['REQUEST_URI'] ?? '',
'Blocked sensitive-file probe'
);
file_put_contents(
'/home/account/logs/security.log',
$entry,
FILE_APPEND | LOCK_EX
);
?>
Security logs should not be stored inside a publicly downloadable directory. They should also avoid recording secrets such as passwords, full session cookies, authentication headers, or private API keys.
Detect Common Reconnaissance Requests
Attackers frequently probe websites for common files and applications regardless of whether those applications actually exist. A custom PHP website may still receive requests for /wp-login.php, /wp-admin, /.env, /.git/config, and /phpmyadmin.
These requests do not necessarily mean the attacker knows anything about the site. Much of this scanning is automated. However, logging these requests can still be valuable because they reveal suspicious behavior patterns.
Apache can deny certain sensitive paths immediately.
RedirectMatch 404 (?i)/\.git
RedirectMatch 404 (?i)/\.env
I generally prefer returning a normal error response rather than exposing unnecessary information about whether the file once existed.
Rate-Limit Authentication Attempts
A login page should not allow unlimited authentication attempts at machine speed. Even strong passwords benefit from rate limiting because it reduces automated guessing and credential-stuffing attacks.
A basic application can track failed login attempts by account identifier and IP address. After several failed attempts, it can introduce a temporary cooldown period.
The implementation can use MySQL, Redis, a local cache, or another storage mechanism. The exact system matters less than the underlying principle: authentication attempts should have a cost.
I avoid permanent account lockouts triggered only by failed attempts because an attacker could intentionally lock legitimate users out of their accounts. Temporary throttling is usually a better design.
Do Not Trust the User-Agent or IP Address as Authentication
IP addresses and browser user-agent strings can be useful for logging and anomaly detection, but neither should function as a primary identity mechanism. Users can move between cellular networks, Wi-Fi networks, VPNs, and changing IP addresses during a single day.
Security systems should use these signals as context rather than absolute proof.
For example, a sudden administrator login from a new country may justify additional authentication, but an IP change alone should not necessarily terminate a legitimate account.
Separate Public and Administrative Routes
I prefer clearly separating public-facing application routes from administrative functionality. An administration interface should not accidentally reuse public controllers that expose more functionality than intended.
For example, a CMS might use:
/admin/posts
/admin/users
/admin/settings
Every administrative route should perform an authorization check on the server.
Hiding the navigation link is not authorization. JavaScript that removes an admin button is not authorization. A user should receive a server-side denial if they manually enter an administrative URL without permission.
This distinction is extremely important. Anything enforced only inside the browser can potentially be bypassed.
Use Role-Based Authorization
Authentication answers the question, “Who is this user?” Authorization answers the question, “What is this user allowed to do?”
A system with administrators, editors, contributors, customers, or business accounts should define those permissions explicitly.
An editor may be allowed to modify articles but not create administrator accounts. A support employee may be able to view customer sessions but not change billing configuration. A normal user should never gain access to internal administration merely because they discovered an API endpoint.
Each sensitive operation should verify authorization independently.
Backups Need Security Too
Backups are essential after a compromise, but backups can also become security risks. Developers sometimes create files with names such as:
database-backup.sql
website-old.zip
public_html-backup.tar.gz
config.php.bak
and leave them inside the public web directory.
Those files may contain source code, database records, password hashes, email addresses, API credentials, and other confidential information.
Backups should be stored outside the document root whenever possible. Ideally, backups should also be encrypted, access-controlled, and copied to an independent storage location.
Most importantly, backups should actually be tested. A backup that has never been restored is only an assumption.
Review Scheduled Tasks
Cron jobs are easy to forget during incident response. An attacker who obtains sufficient access may create a scheduled process that recreates deleted malware or downloads a payload later.
I therefore review scheduled tasks after a serious compromise.
On Linux, this may include:
crontab -l
Depending on the server configuration, system-level cron directories may also need inspection by the server administrator.
A clean website directory does not necessarily mean the system is clean if another process is capable of reinfecting it automatically.
Check for Unexpected Administrator Accounts
If the CMS contains a user table, I manually review privileged accounts. I want to know who has administrator privileges, when those accounts were created, and whether their email addresses and usernames are recognizable.
The same principle applies to the hosting account, database management system, SSH users, FTP accounts, and control panel users.
Attackers often prefer creating legitimate-looking access rather than relying entirely on malware. An extra administrator account can survive code cleanup and continue giving them access.
Examine the Database for Injected Content
Not all website malware lives inside files. An attacker may insert spam links, scripts, redirects, fake articles, or malicious HTML directly into database records.
I search database content for suspicious domains, unexpected <script> tags, iframe elements, encoded JavaScript, and large blocks of unfamiliar HTML.
This is especially important for CMS systems where page content is stored primarily inside MySQL. Cleaning the filesystem while ignoring the database can leave the compromise partially intact.
Use Content Security Policy Where Practical
Content Security Policy, or CSP, can reduce the impact of certain cross-site scripting attacks by restricting which scripts, styles, frames, and other resources the browser is allowed to load.
A very strict CSP requires careful testing because modern applications often rely on third-party services, analytics, payment providers, CDNs, and inline scripts. However, even a carefully designed policy can significantly reduce the number of places from which executable content is accepted.
A basic starting point might look like this:
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; frame-ancestors 'self';"
This example is intentionally minimal. A real application may need additional directives for Stripe, analytics, fonts, images, APIs, or external assets.
The important point is that CSP should be treated as an additional browser security boundary, not as a replacement for secure programming.
Add Other Useful Security Headers
Several HTTP response headers can reduce unnecessary browser behavior or tighten application security.
For example:
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Frame-Options "SAMEORIGIN"
These headers address different issues. X-Content-Type-Options discourages browsers from guessing content types. Referrer-Policy controls how much referring URL information is shared. X-Frame-Options can reduce framing attacks in browsers that support it.
Modern applications may use CSP's frame-ancestors directive instead of or in addition to X-Frame-Options.
Force HTTPS
A login system should not operate over unencrypted HTTP. HTTPS protects credentials, session cookies, form submissions, and other sensitive information while it travels between the browser and server.
I redirect public HTTP traffic to HTTPS and make sure the application itself generates HTTPS URLs.
A basic Apache redirect might look like:
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
On systems using reverse proxies or load balancers, HTTPS detection can require different configuration, so the hosting architecture needs to be considered.
Keep Dependencies Under Control
Dependencies are effectively third-party code running inside your application. Every package installed through Composer, npm, or another package manager becomes part of the application's attack surface.
I periodically review dependencies and remove packages that are no longer needed. Fewer dependencies mean fewer components that need updates and fewer opportunities for vulnerable code to remain unnoticed.
For Composer-based PHP projects, dependency auditing can be part of the maintenance workflow.
composer audit
The important operational habit is not merely installing updates blindly. You should know what packages the application relies on, what they do, and whether abandoned packages remain in production.
Protect API Keys From the Front End
Secret API credentials should never be embedded inside public JavaScript. Anything sent to a browser can ultimately be inspected by the user.
If an API requires a secret server credential, the request should generally be made from the server.
For example, an OpenAI, Stripe, email, or internal service secret belongs in server-side configuration rather than inside a JavaScript bundle.
Public keys are different. Some providers intentionally use browser-safe publishable keys. Developers should understand the distinction between publishable credentials and secret credentials rather than assuming every API key can safely appear in front-end code.
Verify Stripe Webhooks
Payment systems deserve special attention because a malicious user should not be able to tell your application that a payment succeeded simply by sending their own HTTP request to the webhook URL.
When using Stripe webhooks, the application should verify the event signature using the webhook signing secret before trusting the payload.
The general idea looks like this:
<?php
$event = \Stripe\Webhook::constructEvent(
$payload,
$signature,
$endpointSecret
);
?>
Only after successful verification should the application update an order, unlock paid content, mark an invoice as paid, or grant a service.
Your application database should never treat a browser redirect to a “payment successful” page as proof that money was actually received.
Protect Paid Content on the Server
This is especially important for membership sites and paywalled content. Hiding paid content with CSS or JavaScript is not real access control.
If the full article is delivered to the browser and JavaScript merely hides it, someone can open Developer Tools and read the supposedly protected content.
The server should determine whether the visitor is authorized before returning restricted material.
Conceptually:
<?php
if (!$userHasAccess) {
echo $preview;
exit;
}
echo $fullArticle;
?>
The important distinction is that unauthorized visitors never receive the protected portion of the article.
The same rule applies to premium downloads. Do not place a PDF at a public predictable URL and assume people will only reach it through the purchase page. The application should authenticate access before delivering the file.
Use Secure Download Handlers
Protected files should ideally be stored outside the public web directory. When an authorized customer requests the file, PHP can verify permission and stream the file to the browser.
The storage location might look like:
/home/account/private-downloads/security-checklist.pdf
instead of:
/public_html/downloads/security-checklist.pdf
This prevents someone from bypassing application authorization by discovering the direct URL.
Monitor 404 Traffic
One of the easiest ways to observe automated scanning is to monitor repeated requests for nonexistent files. A normal visitor occasionally generates a 404 error because of a broken link. A scanner may generate hundreds of 404 requests while searching for WordPress plugins, configuration files, backup archives, admin interfaces, environment files, and known vulnerable scripts.
I do not automatically block every visitor who causes a 404, but patterns matter.
A single request for /wp-login.php means very little. Fifty requests in a few seconds for .env, phpmyadmin, xmlrpc.php, .git/config, database backups, and old plugins tell a different story.
Security decisions should be based on behavior rather than one isolated request.
Be Careful With Automatic IP Blocking
Automatic blocking can be useful, but overly aggressive systems can create their own problems. Search engines, uptime monitors, security scanners, corporate proxies, VPN services, and legitimate users may generate unusual request patterns.
Instead of permanently banning an IP because of one suspicious URL, I prefer thresholds and temporary blocks.
For example, an application might temporarily block a client after repeated attempts to access sensitive files within a short window.
This reduces automated scanning without treating every unusual request as a permanent enemy.
Log Administrative Changes
A good CMS should maintain an audit trail for important actions. I want to know when a user logs in, creates a page, deletes a post, changes another user's privileges, updates payment settings, modifies site configuration, or performs another high-impact operation.
An audit log might record the administrator ID, action, affected record, timestamp, and source IP.
For example:
2026-08-29T21:44:12
USER=14
ACTION=delete_post
POST_ID=381
IP=203.0.113.10
Audit logs become extremely valuable when something goes wrong because they allow you to reconstruct what happened instead of relying entirely on memory.
Back Up Before Major Changes
Security hardening itself can break a website if changes are made carelessly. A restrictive Apache rule can block legitimate routes. A new CSP can prevent required JavaScript from loading. File permission changes can break uploads. Session configuration can interfere with authentication.
Before making major security changes, I create a backup of the application and database.
The difference is that I do not leave that backup sitting in the public directory afterward.
Test the Website Like Someone Who Does Not Trust It
Once the basic hardening is complete, I test the application from the perspective of someone trying to make it behave incorrectly.
What happens if I change the record ID in a request? Can one customer access another customer's file? Can a normal account request an administrator URL manually? What happens if I upload a file with a misleading extension? What happens if I submit HTML into every form? What happens if I send a request without the CSRF token? Can I trigger a payment-complete action without actually paying?
This mindset is valuable because many serious vulnerabilities exist not in the code syntax, but in the assumptions developers make about how users will behave.
A normal user follows the interface. An attacker does not.
My Post-Compromise Checklist
When I am evaluating a PHP website after a security incident, I review the entire environment rather than only the page where the compromise became visible. I inspect recently modified files, upload directories, administrator accounts, database content, cron jobs, server logs, API credentials, configuration files, backups, file permissions, and authentication behavior.
I rotate exposed credentials, move secrets outside the document root where possible, disable unnecessary functionality, prevent script execution inside upload directories, review database permissions, verify that prepared statements are used, strengthen sessions, implement CSRF protection, and make sure protected content is actually protected by the server.
I also verify that backups exist somewhere separate from the production website and that those backups are actually capable of restoring the application.
The objective is not to make the website theoretically impossible to compromise. That is not a realistic security model. The objective is to eliminate obvious weaknesses, reduce the attack surface, limit what an attacker can accomplish if one component fails, and create enough logging that suspicious behavior becomes visible.
Security Should Be Layered
One security control is never enough. A firewall can fail. An application can contain a programming mistake. A password can be stolen. A dependency can develop a vulnerability. A server can be misconfigured.
The solution is defense in depth.
If an attacker manages to upload a malicious file, the upload directory should refuse to execute it. If the attacker discovers a database password, the database account should have limited privileges. If an administrator password is stolen, multifactor authentication should create another barrier. If malicious JavaScript reaches the database, output escaping and Content Security Policy should reduce its ability to execute. If something still goes wrong, logging and backups should make detection and recovery easier.
That is the philosophy behind almost every security measure in this article.
Security is not a product that gets installed once and forgotten. It is an architecture, a development habit, and an operational process.
The moment you begin designing systems under the assumption that users will manipulate requests, scanners will probe nonexistent files, bots will attack login pages, and mistakes will eventually happen, your applications become significantly harder to compromise.
And after you have actually cleaned up a hacked website once, you generally stop thinking of these precautions as excessive.