PHP Security: Passwords, Forms, Sessions, and Uploads

Published Updated

Secure a PHP application by checking permissions on the server, hashing passwords, validating input, escaping output, and using prepared database queries. Keep PHP and its dependencies supported, too. Safe application code cannot fix an unpatched runtime.

For the login flow, see PHP authentication and sessions. For field checks and error messages, see PHP form validation. The examples below explain individual protections; they are not a complete authentication system.

From what I've seen in old LAMP apps, “escape the input” blurs several different jobs. Validation checks whether a value is acceptable. Prepared statements keep values out of SQL syntax. Output escaping stops text being interpreted as markup. Permission checks decide whether the user may perform the action.

How Should PHP Store Passwords?

Store PHP passwords with password_hash() and check login attempts with password_verify(). Never store plaintext passwords or use a fast general-purpose hash such as MD5.

<?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 leaves room for longer hashes. If you use bcrypt, reject passwords above its 72-byte limit rather than silently truncating them.

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. Aim to make password guessing expensive without making normal login painful.

How Do You Generate PHP Passwords and Reset Tokens?

Generate PHP reset tokens with random_bytes(). For a generated passphrase, choose words with random_int() from a large list of unique words. Do not use timestamps or the general-purpose random functions for credentials.

<?php

function generatePassphrase(array $wordList, int $wordCount = 6): string
{
    $wordList = array_values(array_unique($wordList));
    if ($wordCount < 6 || count($wordList) < 7776) {
        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 reset token is a credential. Give it a short lifetime, consume it once, and never store the raw token in the database.

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.

How Do You Protect PHP Sessions?

Protect PHP login sessions with HTTPS, secure cookie settings, session-ID rotation at login, and server-side permission checks.

Set cookie options before starting the session. Enable strict mode and cookie-only IDs; custom session handlers must support ID validation for strict mode to work.

<?php

ini_set("session.use_strict_mode", "1");
ini_set("session.use_only_cookies", "1");

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.

How Do You Prevent CSRF in PHP Forms?

Prevent CSRF in cookie-authenticated PHP forms by checking an unpredictable session token on every state-changing request. Use your framework’s CSRF protection when available.

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(mixed $token): void
{
    $expected = $_SESSION["csrf_token"] ?? null;
    if (! is_string($expected) || $expected === "" || ! is_string($token)
        || ! hash_equals($expected, $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.

How Do You Prevent XSS in PHP Output?

Prevent XSS in PHP by encoding values for the place you display them. Use htmlspecialchars() for ordinary HTML text and quoted attribute values; JavaScript and URLs need different handling.

<?= 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, accepting plain text avoids the need to support user-written HTML.

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");
}

Use the helper when you print a value into HTML:

<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. Encode for the exact place the value is going.

Which Security Headers Should PHP Send?

PHP can send Content Security Policy, X-Content-Type-Options, and Referrer-Policy headers to restrict browser behavior. Configure them for the scripts, assets, and services your application actually uses.

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");

Test the CSP against the scripts and assets your app uses before enforcing it. 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.

Security headers supplement output encoding; they do not replace it. A report-only CSP does not block violations. Configure a reporting destination if you need reports collected centrally.

How Do You Prevent SQL Injection in PHP?

Prevent SQL injection in PHP with prepared statements for query values. Choose dynamic column names and sort directions from a fixed allowlist.

<?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",
];

$sort = $_GET["sort"] ?? "created";
$direction = $_GET["direction"] ?? "desc";
$sortColumn = is_string($sort) ? ($allowedSorts[$sort] ?? "created_at") : "created_at";
$sortDirection = is_string($direction) ? ($allowedDirections[$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.

How Do You Handle PHP File Uploads Safely?

Handle PHP uploads by limiting their size and type, generating filenames, and storing them outside executable public directories.

  • 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. MIME detection alone does not prove a file is harmless. For images, also limit dimensions and consider decoding and re-encoding them before serving them.

What Should PHP Validate on the Server?

PHP should validate the type, length, range, and business rules of incoming values before using them. Browser validation can help users, but requests can bypass it.

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.

How Do You Avoid Unsafe PHP Commands and Includes?

Avoid passing user input to shell commands, eval(), or include. Use a PHP library instead of a shell command where possible. Choose template files from a fixed map rather than constructing paths from a request parameter.

If an external command is unavoidable, fix the executable, validate allowed arguments, and use a library that passes arguments separately. Escaping shell characters alone does not prevent a value from being interpreted as a command-line option.

How Should You Configure PHP for Production?

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.

Which PHP Requests Need Rate Limits?

Rate-limit PHP login attempts, password resets, registrations, uploads, and other expensive or easily abused requests. A reset endpoint should not let one visitor send thousands of emails.

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;
}

This query only checks recent events. The application must also record attempts and expire old rows. A separate count and insert can race under concurrent traffic, so production limits need an atomic counter or equivalent coordination.

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 pages with account-specific data, invalidate the session at logout, and keep checking permissions on every request. Browser back-navigation behavior varies, so test it rather than treating the cache header as a logout guarantee.

<?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 behavior 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.

Check These Protections Before Launch

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.

Frequently Asked Questions

Is HTTPS enough to secure a PHP application?

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

What is the difference between authentication and authorization?

Authentication establishes who the user is. Authorization 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. For PHP passwords, use password_verify() to check the submitted password against the stored hash. 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.

Does PHP input need validation when output is escaped?

PHP input still needs validation even when output is escaped. Escaping controls how text is displayed; validation checks whether a value should be accepted. 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?

Do not design your own encryption in PHP. Use maintained cryptographic libraries such as Sodium for encryption and password_hash() for password storage. Correct cryptography depends on details such as key management, initialization vectors, and constant-time comparison that are easy to get subtly wrong.

How important is keeping the PHP version current?

Keeping PHP supported ensures your runtime receives security fixes. PHP branches currently receive two years of active support followed by two years of critical security support. Check the support table for the exact end date of your branch.

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 parameterizable 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)