Article PHP & Development

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.

Updated 11 min read Advanced

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']);
Escaping is not a substitute

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.

Do not impose rules that make passwords worse

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 the finfo extension 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

AreaThe defence
DatabasePrepared statements everywhere; allowlist any identifier that comes from input
OutputEscape at render time, per context
Passwordspassword_hash() and password_verify()
FormsCSRF token compared with hash_equals()
SessionsHttpOnly, Secure, SameSite; regenerate ID on login
UploadsOwn filename, verified type, no execution in the upload directory
SecretsOutside the web root, out of Git
ErrorsLogged, never displayed in production
DependenciesSupported PHP version, patched packages, unused ones removed
PermissionsLeast 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