PHP Application Security Checklist for Business Software
A working checklist for PHP business applications: access control, prepared statements, output escaping, CSRF, sessions, password hashing, uploads, php.ini, dependencies and headers.

Business software written in PHP (portals, CRMs, order systems, internal tools) tends to share three traits: many user roles, data that matters to someone, and a small team with nobody whose job is security. This PHP security checklist is written for that situation. It is ordered by where real damage tends to come from, and each item names the function, setting or pattern to look for in the code.
It follows the categories of the OWASP Top 10. If you build on Laravel or CodeIgniter, several items are handled for you as long as you do not bypass the framework. The checks still apply.
Access control on every request, for every record
Broken access control sits at the top of the OWASP list, and in business applications it is the finding we meet most. The login works. What is missing is the check that this user may see this record.
- Every page and endpoint checks the session on the server before doing anything else. A hidden menu item is not a permission.
- Every query for a record includes the owner: the account, company or user it belongs to. Changing the number in
/invoices/view.php?id=1042must not show another customer's invoice. - Role checks sit next to the action (approve, export, delete), and the default is to refuse.
- Admin tools, exports and report downloads follow the same rules as pages.
Database queries use prepared statements, without exception
SQL injection is prevented by keeping data out of the SQL text. With PDO that means placeholders for every value, including values that came from your own database earlier.
$stmt = $pdo->prepare(
'SELECT id, number, total FROM invoices WHERE id = :id AND account_id = :account'
);
$stmt->execute(['id' => $invoiceId, 'account' => $currentUser->accountId]);
$invoice = $stmt->fetch(PDO::FETCH_ASSOC);
if ($invoice === false) {
http_response_code(404);
exit;
}
- Search the code for
query(andexec(calls that build SQL with.or with variables inside double quotes. Each one is a finding. - Table names, column names and sort direction cannot be bound as parameters. Compare them against a fixed list of allowed values and never pass them through from the request.
- Create the PDO connection with
PDO::ATTR_EMULATE_PREPARESset tofalseandPDO::ATTR_ERRMODEset toPDO::ERRMODE_EXCEPTION. - The database user the application connects with has rights on its own schema only, and no
FILEorGRANTprivilege.
Output is escaped for the place it lands
Cross-site scripting happens when stored or submitted text is printed as markup. Escape at the moment of output, and match the escaping to the context.
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
- HTML body and attribute values go through a helper like the one above. Attributes are always quoted.
- Data passed into a script block is encoded with
json_encode()and theJSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOTflags, not concatenated into JavaScript. - Values placed in a URL use
rawurlencode(). Links built from user input are checked to start withhttps://or a relative path, so that ajavascript:address cannot get in. - If users may enter rich text, clean it with a maintained HTML sanitizer library and an allow-list of tags. A homemade
strip_tags()filter is not enough.
Forms that change data carry a CSRF token
- Every state-changing request uses POST (or PUT, PATCH, DELETE), never GET. A link that deletes a record can be triggered by an image tag on another site.
- Each session has a token created with
bin2hex(random_bytes(32)). Every form includes it, and the handler compares it withhash_equals()and rejects the request when the session has no token at all. - The session cookie sets
SameSite, which blocks most cross-site form posts in current browsers. Treat it as a second layer, not a replacement for the token.
Sessions and passwords follow PHP's own functions
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
ini_set('session.use_strict_mode', '1');
session_start();
// at login
if ($user !== false && password_verify($password, $user['password_hash'])) {
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
// save $newHash for this user
}
}
- Passwords are stored only with
password_hash()and checked only withpassword_verify(). Anymd5(),sha1()or homemade salt in the login code needs a migration plan: rehash each user at their next successful login. - The session ID is regenerated at login and whenever a user's privileges change.
- Sessions end: an idle timeout stored in the session, a real logout that calls
session_destroy(), and all other sessions closed when a password is changed. - Login and password reset are rate limited per account and per IP address, and the error message is the same whether the email exists or not.
- Reset tokens are random, stored hashed, single use and short lived.
Uploaded files are treated as hostile
$file = $_FILES['document'] ?? null;
if ($file === null || $file['error'] !== UPLOAD_ERR_OK || $file['size'] > 5 * 1024 * 1024) {
exit('Upload failed');
}
$allowed = ['application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset($allowed[$mime])) {
exit('File type not allowed');
}
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
move_uploaded_file($file['tmp_name'], '/var/app/storage/uploads/' . $name);
- The type is detected from the content. The browser-supplied
typeand the original file name are never trusted or used on disk. - Files are stored outside the web root and served through a script that runs the access check first.
- If uploads must sit inside the web root, PHP execution is switched off for that directory in the web server configuration.
- Size limits are set in the code and in
upload_max_filesizeandpost_max_size.
The OWASP file upload cheat sheet goes further, including image re-encoding and malware scanning.
Errors, secrets, php.ini and response headers
- Production runs with
display_errors = Off,log_errors = Onand anerror_logpath outside the web root. A stack trace on screen hands over file paths, queries and sometimes credentials. expose_php = Offandallow_url_include = Off.- Database passwords and API keys live in environment variables or in a file above the web root, not in the repository. If the document root is the project root, check that
/.env,/.git/configand/composer.jsonreturn 404 in a browser. - The web root contains no
phpinfo()page, no database dumps, no.bakor.zipcopies of the code and no installer scripts. - Uncaught exceptions end in a generic error page and a log entry. They never leave the user logged in to a half-finished action.
Response headers
Set these in the web server configuration or once in a shared bootstrap file:
Strict-Transport-Security, after confirming that every page and subdomain works over HTTPS.Content-Security-Policy, starting in report-only mode. Even a policy that only restrictsscript-srcandframe-ancestorslimits what an injected script can do.X-Content-Type-Options: nosniffand aReferrer-Policy.Cache-Control: no-storeon pages that show personal or financial data.
Dependencies and the PHP version stay current
composer auditruns in the build and fails it on known advisories. Production installs usecomposer install --no-dev.- Libraries that were copied into the project by hand are listed with their versions and replaced by Composer packages where possible.
- PHP itself is on a branch that still receives security fixes, per the supported versions page. An application on an unsupported PHP version fails this checklist whatever the code looks like, and upgrading a legacy PHP application is the first project.
- JavaScript libraries shipped with the application are on the list too.
How to work through this PHP security checklist
Start with what an anonymous visitor can reach: login, registration, password reset, public forms and any endpoint that answers without a session. Then take the access control section and test it by hand with two accounts from different customers. Those two passes find most of what matters, and a developer who knows the code can do them in a day or two.
Bring in outside help when the application holds payment, health or identity data, when a customer or auditor asks for evidence of a review, or when the findings are spread across so many files that fixing them is a refactoring project. We review and repair existing systems as part of custom PHP development, and keep them patched under a PHP maintenance plan. For the patterns reviewers see across all stacks, read 10 web application security mistakes found in code reviews.
Frequently asked questions
Is PHP less secure than other languages?
No. Current PHP has prepared statements, strong password hashing and secure random functions built in. Its reputation comes from old tutorials and old code that used none of them. The risk sits in how an application is written and whether it is kept up to date.
Do I need this checklist if the application uses a framework?
Yes, with a different emphasis. A framework covers CSRF tokens, query binding and escaping when you use its tools, so the review looks for places where they were bypassed: raw queries, unescaped output and routes excluded from protection. Access control per record is always your own code.
How often should a PHP application be reviewed?
Run the automated parts, such as composer audit and static analysis, on every build. Review access control whenever a new role or a new type of record is added. A full manual pass once a year, and after any large feature, is a reasonable rhythm for most business systems.
What is the difference between a code review and a penetration test?
A code review reads the source and finds causes, including problems that are hard to reach from outside. A penetration test attacks the running application and shows what an outsider can do today. They find different things, and larger customers often ask for evidence of both.
Add worldwincoder.com as a preferred source on Google, or open this article in your AI assistant to use it as a source.
