PHP Security Fundamentals

Published Updated

PHP security starts with the ordinary surfaces. Passwords, sessions, forms, HTML output, file uploads, and SQL queries cause more damage than exotic attacks because every small app has them.

The rule is simple enough: never trust data because it came from your own page. Browsers can be scripted, requests can be replayed, cookies can be stolen, and forms can be forged. PHP has current tools for all of those boundaries, but the application still has to use them consistently.

If you are building the login flow itself, start with PHP authentication and sessions. If you need the validation layer, read form validation in PHP. This page is the broader security floor around both: how values cross from browser to PHP, from PHP to SQL, from SQL back to HTML, and from uploads into the filesystem.

That boundary framing matters because old PHP security advice often treated every problem as "escape the input." From what I've seen in old LAMP apps, that slogan was useful because it got people looking at the boundary, but it blurs too many jobs together. Validation decides whether data is acceptable for the business rule. Prepared statements protect the SQL parser before user-controlled values reach the database engine. Output escaping protects the HTML, JavaScript, URL, or CSS context where a value is rendered. Session and CSRF controls decide whether the request should be trusted at all.

Passwords

Use password_hash() and password_verify() instead of inventing a hash scheme.

<?php

$hash = password_hash($password, PASSWORD_DEFAULT);

if (! password_verify($passwordAttempt, $hash)) {
    throw new RuntimeException("Invalid login");
}

PASSWORD_DEFAULT lets PHP move the default algorithm forward over time, while PASSWORD_ARGON2ID is available when you deliberately choose Argon2id. The storage column should be large enough for future hash formats. A VARCHAR(255) column is the boring choice, and boring is good here.

If a hash needs upgrading after login, use password_needs_rehash().

<?php

if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
    $newHash = password_hash($passwordAttempt, PASSWORD_DEFAULT);
    // Store the new hash for this user.
}

Do not pass your own salt to password_hash(). PHP generates the salt, stores the algorithm and cost information inside the resulting hash, and gives password_verify() what it needs later. The PHP manual is explicit that PASSWORD_DEFAULT can change over time, which is the reason the database column needs room to grow.

For interactive logins, tune cost on the hardware that actually serves the request. A value that feels fine on a developer laptop can make a small shared VPS sluggish when several users log in at once. The point is not to win a benchmark; it is to make password guessing expensive without making normal login painful.

Generated Passwords and Reset Tokens

The old memorable password generator snippet had the right instinct and the wrong era of random-number habits. If a PHP app must generate a passphrase, choose words with random_int(), not rand(), mt_rand(), timestamps, or a shuffled string.

<?php

function generatePassphrase(array $wordList, int $wordCount = 4): string
{
    if ($wordCount < 4 || count($wordList) < 1024) {
        throw new InvalidArgumentException("Use a larger word list.");
    }

    $words = [];
    $maxIndex = count($wordList) - 1;

    for ($i = 0; $i < $wordCount; $i++) {
        $words[] = $wordList[random_int(0, $maxIndex)];
    }

    return implode("-", $words);
}

That still does not mean the app should invent permanent account passwords for normal signups. Let people use a password manager, hash what they choose, and offer a reset flow when they lose access. When the application needs a reset token, generate bytes, store only a hash, and expire the record:

<?php

$resetToken = bin2hex(random_bytes(32));
$storedHash = hash("sha256", $resetToken);
$expiresAt = (new DateTimeImmutable("+30 minutes"))->format(DateTimeInterface::ATOM);

Send the raw token once, then compare the submitted token's hash against the stored hash. A temporary password is still a credential, so treat it like one: short lifetime, one use, server-side audit trail, and no plaintext database column waiting to leak.

For reset flows, store the token metadata too: user id, creation time, expiry time, consumed time, IP address or coarse source context, and the reason the token was issued. That audit trail is useful when a user says they never requested a reset. It is also useful when a bot starts hammering the reset form and the app needs rate limits.

Do not reveal whether an email address exists through the reset form. Return the same user-facing message for known and unknown accounts, then send a reset email only when the account exists. That feels slightly unfriendly in development, but it avoids turning the form into an account enumeration endpoint.

Sessions

Sessions are authentication state, so the cookie and identifier deserve the same care as the login form.

Set secure cookie attributes, regenerate the session ID after login, and keep sensitive operations behind server-side checks.

<?php

session_set_cookie_params([
    "httponly" => true,
    "secure" => true,
    "samesite" => "Lax",
]);

session_start();

// After a successful login:
session_regenerate_id(true);
$_SESSION["user_id"] = $userId;

HttpOnly keeps ordinary client-side JavaScript away from the session cookie. Secure limits the cookie to HTTPS requests. SameSite=Lax helps with cross-site request handling, but it is defense in depth, not a complete CSRF system.

The full login flow, including password verification, session rotation, logout, remember-me boundaries, and CAPTCHA tradeoffs, belongs in PHP authentication and sessions.

For the broader app, treat the session as a server-side authorization pointer. The cookie proves that the browser knows a session id, and the server still decides whether the session is active, which user it belongs to, what roles it has, and whether the specific action is allowed.

That distinction matters after login because a user who can edit their own profile should not be able to edit another user's profile because the URL id changed. Put authorization checks beside the action, not only beside the navigation link that led to it.

<?php

function requireUserCanEditProfile(int $profileUserId): void
{
    $currentUserId = $_SESSION["user_id"] ?? null;

    if ($currentUserId !== $profileUserId && ! ($_SESSION["is_admin"] ?? false)) {
        http_response_code(403);
        throw new RuntimeException("Forbidden");
    }
}

This is the pattern that prevents "the button was hidden" from becoming the whole security model. PHP cannot know your permissions unless the application checks them on the server.

CSRF

OWASP's guidance is still the right baseline: use framework-provided CSRF protection when available; otherwise add tokens to state-changing requests and validate them on the server.

For a small PHP app, a session-backed token is enough to understand the pattern.

<?php

function csrfToken(): string
{
    if (! isset($_SESSION["csrf_token"])) {
        $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
    }

    return $_SESSION["csrf_token"];
}

function verifyCsrfToken(string $token): void
{
    if (! hash_equals($_SESSION["csrf_token"] ?? "", $token)) {
        throw new RuntimeException("Invalid form token");
    }
}

Then include the token in forms that change state.

<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrfToken(), ENT_QUOTES) ?>">

Do not use GET requests for actions that change data. That one rule prevents a surprising amount of damage.

CSRF protection belongs on POST, PUT, PATCH, and DELETE-style actions because browsers attach cookies automatically. An attacker does not need to read the victim's session cookie if the victim's browser will send it to your site during a forged request. The token adds a second value the attacker cannot guess or obtain from another origin.

SameSite cookies reduce common cross-site request paths, and they are worth setting. They do not replace tokens because real applications still have redirects, embedded flows, old browsers, mixed subdomains, API clients, and edge cases around top-level navigation. For normal PHP forms, a session token is still the plainest model.

For JSON endpoints, send the CSRF token in a custom header and reject requests that do not include it. Also check Origin or Referer when practical, especially on high-impact actions. Headers are supporting evidence; the token remains the main application-level proof.

Output Escaping

Escape output where it is rendered, because data stored in the database is not magically safe just because you validated it on the way in.

<?= htmlspecialchars($note["title"], ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8") ?>

If the field is supposed to contain HTML, use a real sanitizer with a strict allowlist. For most application fields, plain text is the safer and more honest model.

OWASP's XSS guidance separates output encoding by context because one escaping function cannot safely handle every location. HTML body text, HTML attributes, JavaScript strings, URLs, and CSS values all parse differently. PHP's htmlspecialchars() is the right default for ordinary HTML text and attributes when you pass the flags and encoding deliberately.

<?php

function e(?string $value): string
{
    return htmlspecialchars($value ?? "", ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
}

Then use it at the render boundary:

<h1><?= e($article["title"]) ?></h1>
<input name="email" value="<?= e($form["email"] ?? "") ?>">

Do not use the same helper inside a <script> block and assume it has solved JavaScript injection. Put server data into JSON with json_encode() and the relevant hex flags, then read it as data.

<script>
const profile = <?= json_encode(
    $profile,
    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) ?>;
</script>

For URLs, validate the scheme and host before rendering. For CSS, avoid rendering user-controlled values at all unless the value is mapped through a fixed allowlist. The rule is not "escape once"; the rule is "encode for the exact place the value is going."

Security Headers and CSP

Security headers do not fix unsafe PHP output, but they can reduce the blast radius when a mistake slips through. A current PHP app should serve over HTTPS, set strict cookie attributes, and use headers that match the application rather than copied defaults from a random gist.

Content Security Policy is the header most teams notice first because it can block inline script execution and restrict where scripts, styles, images, frames, and form posts can load from. Start with a report-only policy if the app is already live, because old templates often have inline scripts and third-party embeds hiding in places nobody remembers.

<?php

header("Content-Security-Policy-Report-Only: default-src 'self'; object-src 'none'; base-uri 'self'");
header("X-Content-Type-Options: nosniff");
header("Referrer-Policy: strict-origin-when-cross-origin");

That example is deliberately modest because a production CSP should be tailored to the app's real script and asset model. It's a trade-off, and the policy should be tested before it blocks real users or quietly teaches the team to ignore reports. If the template needs inline JavaScript, move it to a file or use nonces deliberately. If the application embeds payment, maps, video, or analytics providers, name them in the policy instead of opening the whole internet.

The veteran lesson here is that headers are guardrails, not absolution. They buy time when a value gets printed in the wrong context. They do not turn unsafe output into safe output.

SQL Input

Use prepared statements whenever user-controlled values enter a query.

<?php

$statement = $pdo->prepare(
    "SELECT id, email
     FROM users
     WHERE email = :email",
);

$statement->execute(["email" => $email]);

PDO parameters are not a template system for arbitrary SQL; they represent data literals. If the user controls a column name, direction, or table choice, map it through a fixed allowlist before it ever reaches the query string. I've worked through enough codebases where magic_quotes_gpc was silently off, or had been stripped mid-deploy, to know that any approach relying on the runtime config to enforce escaping will eventually betray you.

Read PHP and MySQL with PDO if you are building database-backed forms.

OWASP's SQL injection guidance still puts prepared statements first because they separate code from data. That protection is strongest when the SQL structure is fixed and values are bound through placeholders.

For sortable tables, build the variable part from an allowlist:

<?php

$allowedSorts = [
    "created" => "created_at",
    "email" => "email",
    "name" => "display_name",
];

$allowedDirections = [
    "asc" => "ASC",
    "desc" => "DESC",
];

$sortColumn = $allowedSorts[$_GET["sort"] ?? "created"] ?? "created_at";
$sortDirection = $allowedDirections[$_GET["direction"] ?? "desc"] ?? "DESC";

$statement = $pdo->prepare(
    "SELECT id, email, display_name
     FROM users
     ORDER BY {$sortColumn} {$sortDirection}
     LIMIT 50",
);

$statement->execute();

The important part is that the user supplied a key, not SQL. If you concatenate raw $_GET["sort"] into ORDER BY, prepared statements cannot save that part of the query because identifiers and keywords are not data literals.

The error path deserves the same attention. A production app should log database errors server-side and show a dull user-facing message. Stack traces, SQL strings, table names, and connection details are useful to developers and useful to attackers, which is why they belong in logs with access control.

File Uploads

File uploads need their own rules because they cross the boundary between user input and executable infrastructure:

  • Store uploads outside the web root when possible.
  • Generate your own filenames.
  • Check size limits before processing.
  • Verify the MIME type and file content.
  • Never execute uploaded files as PHP.

The failure mode here is severe enough to treat separately: one loose upload directory can turn a small app into a remote shell.

The PHP manual's upload flow starts with $_FILES, but the security decision starts before PHP sees the file. Set size limits in configuration and in application code. Decide which file types the app really needs. Decide whether uploads are public, private, temporary, or derived into safer output.

For images, treat the extension as a label rather than proof. Check the upload error code, enforce the size, inspect the MIME type with server-side tools, generate a new filename, and move the file with move_uploaded_file().

<?php

function storeUploadedImage(array $file): string
{
    if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        throw new RuntimeException("Upload failed");
    }

    if (($file["size"] ?? 0) > 2_000_000) {
        throw new RuntimeException("File is too large");
    }

    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime = $finfo->file($file["tmp_name"]);

    $extensions = [
        "image/jpeg" => "jpg",
        "image/png" => "png",
        "image/webp" => "webp",
    ];

    if (! isset($extensions[$mime])) {
        throw new RuntimeException("Unsupported file type");
    }

    $name = bin2hex(random_bytes(16)) . "." . $extensions[$mime];
    $target = __DIR__ . "/../storage/uploads/" . $name;

    if (! move_uploaded_file($file["tmp_name"], $target)) {
        throw new RuntimeException("Could not store upload");
    }

    return $name;
}

OWASP's upload guidance also calls out the less glamorous parts: allowlist extensions, change filenames, limit file size, store outside the web root where possible, scan or process files when the risk justifies it, and serve private files through an application-controlled route. If the upload eventually becomes a thumbnail, a profile image, or a downloadable report, store the original and the public derivative as separate decisions.

Never let a user upload something that the web server can execute as PHP. Also check server configuration so an upload directory cannot run scripts even if a dangerous file lands there. Defense in depth is boring until the day it is the only reason the incident report stays short.

Input Validation and Server Rules

Validation is not the same job as escaping. Validate when data enters the application, store the normalized value, and escape when data leaves for a specific output context.

For example, an email field might need all of these:

  • Browser hints such as type="email" for user experience.
  • Server-side syntax validation before the write.
  • A unique database constraint for account identity.
  • Output escaping when the email appears in HTML.
  • Rate limits when the field is part of login or reset.

The form validation in PHP guide covers the form layer in more detail. The security point is that each layer has a separate job. If a rule matters to the server, enforce it on the server.

Configuration and Error Handling

PHP configuration can reduce damage, but it cannot make unsafe code safe. In production, errors should go to logs rather than the browser. The web server should enforce HTTPS on every route that handles cookies or credentials. Secrets should live outside the repository and outside any generated static output. The upload directory should have conservative permissions. Dependencies should be pinned and updated with a reason, not grabbed during a deploy because a tutorial said so.

These settings are worth checking before launch:

  • display_errors is off in production.
  • Errors are logged somewhere the team actually reads.
  • Session cookies use Secure, HttpOnly, and an intentional SameSite value.
  • Upload limits match the application rule, not only the PHP default.
  • Public directories cannot execute uploaded scripts.
  • Database credentials are scoped to the app's real needs.
  • Backups and logs do not expose reset tokens, session ids, or plaintext secrets.

That last point is easy to miss. A password reset token is uselessly "secure" if the raw token lands in an access log, application log, analytics event, or support screenshot.

Rate Limits and Abuse Handling

Small PHP apps often forget rate limits because the demo works fine with one human clicking the form. Attackers do not use the app that way. Login, registration, password reset, contact forms, comment forms, upload forms, search endpoints, and API-like routes all need an abuse story.

Rate limiting can be simple at first: count attempts by account, IP address, session, or a combination; store a short expiry; and slow or block repeated failures. The exact storage can be Redis, a database table, or a reverse proxy rule, but the important point is that the application has a memory of repeated attempts.

<?php

function tooManyAttempts(PDO $pdo, string $key): bool
{
    $statement = $pdo->prepare(
        "SELECT COUNT(*)
         FROM rate_limit_events
         WHERE event_key = :event_key
           AND created_at > (CURRENT_TIMESTAMP - INTERVAL 10 MINUTE)",
    );

    $statement->execute(["event_key" => $key]);

    return (int) $statement->fetchColumn() >= 10;
}

Do not make the rate-limit key only the email address, because that lets an attacker lock out a victim by repeatedly trying their address. Do not make it only the IP address, because shared networks and proxies can make that unfair to real users. Use layered signals and keep the user-facing message boring.

Abuse handling is security work even when it does not look like cryptography. A reset flow that can send 5,000 emails in a minute is a security bug, an operations bug, and a reputation bug at the same time.

Common Pitfalls & Debugging

A Legacy Hash Column Truncates New Password Hashes

Symptom: every account fails to log in after an app switches from a hand-rolled hash to password_hash(), even though the migration script reported success. Cause: the password column was sized for an old MD5 or SHA1 value at 32 or 40 characters, and a bcrypt hash from PASSWORD_DEFAULT is 60 characters, so MySQL truncates every stored hash on insert unless strict mode rejects the write outright. Fix: widen the column to VARCHAR(255) before running the migration, then re-hash a test account and confirm password_verify() succeeds against the value actually stored in the database, not against the value the script printed.

Symptom: the session cookie never appears in the browser on a local server, even though the code matches the session example above exactly. Cause: the secure flag tells the browser to refuse the cookie over plain HTTP, and most local setups serve over HTTP, so the browser drops it silently with no PHP error to explain why. Fix: serve local development over HTTPS with a tool such as mkcert, or set the flag from an environment check. A secure flag deleted to make local testing convenient is easy to forget and ships straight to production.

Headers Already Sent Blocks the Session or the Redirect

Symptom: PHP reports Cannot modify header information - headers already sent, and the session cookie, a redirect, or a security header silently fails to apply. Cause: something sent output before session_start(), setcookie(), or header() ran, often a blank line before the opening PHP tag or a stray character after a closing one, and HTTP headers can only be sent before any body content. Fix: the warning names the file and line where output began, so open that exact location, remove the leading whitespace or early output, and keep header-setting calls at the top of the request.

The Back Button Shows a Page After Logout

Symptom: a user logs out, presses the browser back button, and sees the previous authenticated page again. Cause: the browser served that page from its own cache rather than making a new request, so the server-side session check never ran a second time. Destroying a session on the server does nothing to a page already sitting in the browser. Fix: send a no-store cache header on every page that shows account-specific data, so the browser is told not to keep it for instant back-navigation.

<?php

header("Cache-Control: no-store");

Escaping Leaves Single Quotes Untouched by Default

Symptom: a value breaks out of an HTML attribute using a single quote, even though the page calls htmlspecialchars() on it. Cause: before PHP 8.1 the function defaulted to ENT_COMPAT, which escapes double quotes but leaves single quotes alone, which matters the moment a value is rendered inside a single-quoted attribute. PHP 8.1 changed the default to ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401. Fix: pass the flags explicitly, as the escaping section above does, so the behaviour does not depend on which PHP version happens to be running.

<?= htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8") ?>

The Uploaded File's Reported Type Is Not Its Real Type

Symptom: a file passes the upload form's type check and turns out not to be the image it claimed to be. Cause: the check read $_FILES["file"]["type"], which is the MIME type the browser sent in the request, a value the person uploading the file fully controls and can set to anything regardless of the real content. Fix: detect the type from the file's actual bytes with finfo, the way the upload example above does, and never use the client-supplied type field for a security decision.

The Boring Checklist

Before a PHP app accepts real users:

  1. Passwords use password_hash() and password_verify().
  2. Generated passwords and reset tokens use random_int() or random_bytes().
  3. Sessions use secure cookie settings and regenerate IDs on login.
  4. State-changing forms use CSRF tokens.
  5. Form input is validated on the server before writes.
  6. HTML output is escaped at render time.
  7. SQL values go through prepared statements.
  8. File uploads are renamed, checked, and stored safely.
  9. Errors are logged server-side without showing stack traces to users.
  10. Authorization checks run on the server for every sensitive action.
  11. Upload directories cannot execute scripts.
  12. Reset tokens, session ids, and secrets stay out of logs.
  13. Rate limits protect login, reset, upload, comment, and contact routes.
  14. Security headers match the app's real template and asset model.

This work is not glamorous, but it is the floor before a PHP app accepts real users. The old CodeWalkers archive was full of practical PHP patterns because that is how people learned the web: one form, one upload, one database write at a time. The current version keeps that practical shape, with the 2026 baseline wrapped around it.

Frequently Asked Questions

Is HTTPS enough to secure a PHP application?

No. HTTPS protects data moving between browser and server. It does nothing about injection, weak session handling, missing authorisation checks, or unsafe file uploads, which are attacks against the application itself rather than the connection.

What is the difference between authentication and authorisation?

Authentication establishes who the user is. Authorisation decides what that user may do. A logged-in account can still be forbidden from editing another person's record, so both checks belong on every request that touches protected data.

What is the difference between hashing and encryption?

Hashing is one-way and used for passwords: you verify by hashing the input again and comparing. Encryption is reversible with a key and used for data you must read back, such as stored tokens. Never store a password with encryption you can undo.

If you escape output, do you still need to validate input?

Yes. Escaping decides how data is rendered safely; validation decides whether the data should be accepted at all. Escaping cannot stop a negative order quantity, an out-of-range date, or a value the business rules forbid.

Should you write your own encryption in PHP?

No. Use the platform primitives: password_hash for passwords, and Sodium or OpenSSL for encryption. Correct cryptography depends on details such as key management, initialisation vectors, and constant-time comparison that are easy to get subtly wrong.

How important is keeping the PHP version current?

It is one of the highest-value security tasks. Each PHP release gets roughly two years of active support and one further year of security-only fixes, about three years in total. After that, known vulnerabilities stay unpatched however careful the application code is.

Self-Check

  1. Which function produces a password hash, and which one checks a password against it?
  2. Why is VARCHAR(255) recommended for a password column?
  3. What should you call after a successful login, and what attack does it stop?
  4. A CSRF token protects against what, and what does it not protect against?
  5. Where does escaping belong: on the way into the database, or on the way out?
  6. Why is a prepared statement safer than escaping a value into the SQL string?
  7. What is wrong with trusting the uploaded file's reported type?

Answers

  1. password_hash() returns the hash and your own code stores it. password_verify() checks a password against a stored hash. Never compare hashes with ==.
  2. Future-proofing. Current bcrypt hashes are 60 characters, but the default algorithm and its length can change. A column too narrow for the stored hash truncates it, and every login then fails.
  3. session_regenerate_id(true). It stops session fixation, where an attacker plants a known session identifier before the victim logs in.
  4. It stops another site making a request as the logged-in user. It does nothing about a script running on your own page, which is what output escaping and CSP address.
  5. On the way out. Escape at the point of output, for the context you are writing into, because the same stored value is safe in one context and dangerous in another.
  6. Placeholders keep bound values separate from the SQL structure. A value cannot become syntax, whatever it contains. Dynamic identifiers such as table or column names are not parameterisable and still need an allowlist.
  7. The browser supplies it and an attacker controls it. Check the real content by inspection on the server, and never derive the stored filename or extension from the upload.

Sources

  1. [1]
  2. [2]
  3. [3]
  4. [4]
  5. [5]
  6. [6]
  7. [7]
  8. [8]
  9. [9]
    SQL Injection Prevention Cheat Sheet
    (cheatsheetseries.owasp.org)
  10. [10]
    File Upload Cheat Sheet
    (cheatsheetseries.owasp.org)
  11. [11]
    PDO::prepare
    (php.net)