Web Development

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.

PHP Application Security Checklist for Business Software

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=1042 must 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( and exec( 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_PREPARES set to false and PDO::ATTR_ERRMODE set to PDO::ERRMODE_EXCEPTION.
  • The database user the application connects with has rights on its own schema only, and no FILE or GRANT privilege.

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 the JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT flags, not concatenated into JavaScript.
  • Values placed in a URL use rawurlencode(). Links built from user input are checked to start with https:// or a relative path, so that a javascript: 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 with hash_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 with password_verify(). Any md5(), 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 type and 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_filesize and post_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 = On and an error_log path outside the web root. A stack trace on screen hands over file paths, queries and sometimes credentials.
  • expose_php = Off and allow_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/config and /composer.json return 404 in a browser.
  • The web root contains no phpinfo() page, no database dumps, no .bak or .zip copies 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 restricts script-src and frame-ancestors limits what an injected script can do.
  • X-Content-Type-Options: nosniff and a Referrer-Policy.
  • Cache-Control: no-store on pages that show personal or financial data.

Dependencies and the PHP version stay current

  • composer audit runs in the build and fails it on known advisories. Production installs use composer 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 WorldWin Coder to your preferred sources

Add worldwincoder.com as a preferred source on Google, or open this article in your AI assistant to use it as a source.

Swati Priyadrshni
· Founder & CEO at WorldWin Coder

I'm Swati Priyadarshani, Founder & CEO of LMS Advisor and Co-Founder of WorldWin Coder Pvt. Ltd. (est. 2019). Over the last 10+ years, I've helped enterprises build learning platforms that actually get used - not just deployed.

Keep reading

Have something to build, fix or move?

Tell us where you are. A developer reads every inquiry, and you get next steps and a free quote within one business day.