PHP security: bind, escape, and pin the upload

A needle mending cloth with an unmended coral tear.

A PHP site stays vulnerable when the runtime is out of support or a plugin still concatenates SQL.

PHP 8.2’s security-fix window has an end date. After that, every new CVE in the engine is yours to backport or ignore. Composer audit and a current WordPress or framework line are the first two checks.

The usual mistake is patching a plugin and leaving the host on an EOL PHP because ‘the site still loads.’

This page is the order of fixes: runtime, dependencies, then the application sinks that OWASP already named.

PHP 8.2 loses official security patches on 31 December 2026. As of 22 August 2026, php.net supported-versions table: 8.3 is already security-only, 8.4 is still in active support until that same December, and 8.5 shipped on 20 November 2025. A site still concatenating $_GET into SQL on 8.1 is already past the project’s last patch.

The control is a typed boundary before the interpreter, an escape that covers quotes and bad UTF-8, a hash the runtime already knows how to rotate, and a canonical path that cannot walk out of the upload root.

Keep the injection guide next to this page when the string is SQL, a shell, or a log. Read XSS when the sink is HTML. Read path traversal when the string is a filename. This page is those four jobs in PHP 8.3 and 8.4.

Concatenation is one channel. A prepare plus a bind is two. The upload name is a third job: a token you minted, then a realpath that cannot leave the root.

SecureCoding

8.2 goes quiet in four months

The project gives each branch two years of bug fixes, then two years of critical security fixes, then nothing. 8.1 ended on 31 December 2025. After 31 December 2026 there is no official patch for a new interpreter bug on 8.2. 8.4 still takes regular point releases through that December, then security-only through 31 December 2028. Move production to 8.4 unless you have a named reason to sit on 8.3. Do not start a new app on 8.2.

The language bump is not the hardening. 8.3 and 8.4 still default session.cookie_secure to off and leave session.cookie_samesite empty. A tutorial copied from 7.4 still passes ENT_COMPAT and still concatenates. Upgrade the runtime, then fix the five functions this page names.

Bind the value. Do not glue SQL

CWE-89 is untrusted data reaching the SQL parser as grammar. PDO’s prepare path sends the statement text and the values on two channels. Concatenating into query() or exec() is one channel. That is the whole miss.

userByEmail is the only lookup a login route should call. The email never enters the SQL text.

function userByEmail(PDO $pdo, string $email): ?array {
 $stmt = $pdo->prepare("SELECT id, password_hash FROM account WHERE email = :email");
 $stmt->execute(["email" => $email]);
 $row = $stmt->fetch(PDO::FETCH_ASSOC);
 return $row === false ? null : $row;
}

Set the connection once. ATTR_EMULATE_PREPARES false asks the server for a real prepare. ERRMODE_EXCEPTION so a failed prepare is not a silent empty set.

$pdo = new PDO($dsn, $user, $pass, [
 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
 PDO::ATTR_EMULATE_PREPARES => false,
]);

Identifiers still cannot be bound. A sort token from the query string must map to a column you wrote. Do not concatenate ORDER BY.

const SORTS = [
 "created" => "created_at",
 "name" => "display_name",
];

function listAccounts(PDO $pdo, string $sortToken): PDOStatement {
 $column = SORTS[$sortToken] ?? "created_at";
 $stmt = $pdo->prepare("SELECT id FROM account ORDER BY {$column} DESC");
 $stmt->execute();
 return $stmt;
}

listAccounts only interpolates a literal from SORTS. A missing key falls back to created_at, never to the raw token. That is the identifier gap the injection guide already had. mysqli prepare plus bind_param is the same contract if you are not on PDO. mysql_query is gone. A leftover mysqli_query with interpolation is the grep hit.

htmlspecialchars with the 8.1 flags

htmlspecialchars manual. Since 8.1.0 the default mask is quotes plus substitute plus HTML 4.01. The quotes bit converts both quote styles, so a value inside attr='...' cannot close the attribute. The substitute bit replaces an invalid UTF-8 sequence with U+FFFD instead of returning an empty string. The HTML 4.01 bit has value 0, so the two names in escapeHtml are the mask you mean.

PHP 7 tutorials still pass ENT_COMPAT. That flag leaves single quotes alone. A template that uses single-quoted attributes is then an XSS sink even after “escaping.” Pass the flags on purpose. Pass UTF-8 on purpose. Do not rely on a php.ini encoding you have not read this week.

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

escapeHtml is the named helper every template should call. Echoing $_GET["q"] is the miss. Twig and Blade already escape. A leftover echo $row["bio"] in a .php view is still CWE-79. You need a different rule for href: escaping will not turn javascript: into a safe URL. Allowlist the scheme, or do not put user text in href.

ENT_IGNORE is the flag the manual still discourages. It drops invalid bytes. A dropped byte can glue two halves of a tag back together. Do not pass it. ENT_SUBSTITUTE is the replacement.

password_hash, then rehash on login

The password_hash manual is the first-party page. PASSWORD_DEFAULT is bcrypt today and may change later. That is the point of the constant. PHP 8.4.0 raised the default bcrypt cost from 10 to 12. A hash minted on 8.3 still verifies on 8.4. password_needs_rehash picks up the new cost on the next successful login.

function storePassword(string $plain): string {
 return password_hash($plain, PASSWORD_DEFAULT);
}

function checkPassword(string $plain, string $hash): bool {
 if (!password_verify($plain, $hash)) {
 return false;
 }
 return true;
}

function passwordNeedsWriteback(string $hash): bool {
 return password_needs_rehash($hash, PASSWORD_DEFAULT);
}

storePassword is the only writer. checkPassword is the only verifier. After a true verify, if passwordNeedsWriteback is true, the login handler writes storePassword($plain) back to the same row. Do not invent a md5 or sha1 of the password. Do not roll a salt. Do not store the bcrypt string in a cookie.

PASSWORD_ARGON2ID is available when PHP was built with Argon2. Use it if you control the build. Pass that constant to both hash and rehash. A host that lacks Argon2 will throw. PASSWORD_DEFAULT will not.

I copied these defaults from the session configuration page on 8.4. Secure is off. SameSite is empty, so the attribute is omitted. HttpOnly is off. Strict mode is off. None of those are locks you inherit by installing 8.4.

function startLockedSession(): void {
 session_name("__Host-session");
 session_set_cookie_params([
 "lifetime" => 0,
 "path" => "/",
 "secure" => true,
 "httponly" => true,
 "samesite" => "Lax",
 ]);
 session_start([
 "use_strict_mode" => true,
 "use_only_cookies" => true,
 "cookie_httponly" => true,
 "cookie_secure" => true,
 "cookie_samesite" => "Lax",
 ]);
}

startLockedSession is the named boot helper. Call it before any output. Do not set a domain key. A host-only cookie stays on the exact host that minted it. The __Host- prefix refuses Domain, requires Secure, and requires Path=/. Leave PHPSESSID only if a load balancer you cannot change already keys on that name. Then still set Secure, HttpOnly, and Lax.

Lax still sends the cookie on a top-level GET. Do not change state on GET. Strict also withholds the cookie on a click from another site. Pick Lax unless you can name the flow that needs Strict. Store a user id in $_SESSION, not a role you cannot afford to show and not a password hash.

Rename the upload, then realpath it

CWE-22 in an upload handler is a join on $_FILES["f"]["name"]. The client chose that string. Two dots as a segment walk toward the parent. A null byte in older stacks cut the suffix. The original name is not a filename you will write.

Mint a token. Map that token to a name you chose. Canonicalize both sides. Compare with a trailing separator so /srv/app/uploads does not match /srv/app/uploads-backup.

function openUnderRoot(string $rootDir, string $name): ?string {
 if (str_contains($name, "\0")) {
 return null;
 }
 $root = realpath($rootDir);
 $resolved = realpath($root. DIRECTORY_SEPARATOR. $name);
 if ($root === false || $resolved === false) {
 return null;
 }
 $prefix = rtrim($root, DIRECTORY_SEPARATOR). DIRECTORY_SEPARATOR;
 if ($resolved !== $root && !str_starts_with($resolved, $prefix)) {
 return null;
 }
 return $resolved;
}

function storeUpload(string $rootDir, array $file): ?string {
 if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
 return null;
 }
 if (!is_uploaded_file($file["tmp_name"])) {
 return null;
 }
 $token = bin2hex(random_bytes(16));
 $destName = $token. ".bin";
 $dest = $rootDir. DIRECTORY_SEPARATOR. $destName;
 if (!move_uploaded_file($file["tmp_name"], $dest)) {
 return null;
 }
 return openUnderRoot($rootDir, $destName);
}

storeUpload never reads the client filename. openUnderRoot is the named fallback when you later open that token. realpath returns false when the path does not exist. That is a feature. A download of a file that is not there yet is a 404, not a lexical guess. JepZ’s April 2012 question on Stack Overflow 10064499 is still the reminder: do not invent a normalize-only helper so you can serve a path realpath refused. The longer argument is on the path traversal page.

Serve uploads from a directory that cannot execute PHP. Check MIME with finfo_file after the move if you need an allowlist. Do not trust $file["type"].

unserialize is a hatch. Use JSON

PHP object injection is CWE-502. unserialize rebuilds objects. A gadget in a class you already autoload can run in __wakeup or __destruct. A cookie or a cache blob you did not mint is the input. The function is the hatch.

PHP 8.3 added json_validate. Use it when you only need to know the blob is JSON before you decode. json_decode with JSON_THROW_ON_ERROR and a depth cap is the loader.

function loadJsonPayload(string $blob): array {
 if (!json_validate($blob, 32)) {
 throw new InvalidArgumentException("invalid json");
 }
 $data = json_decode($blob, true, 32, JSON_THROW_ON_ERROR);
 if (!is_array($data)) {
 throw new InvalidArgumentException("json must be an object or list");
 }
 return $data;
}

loadJsonPayload is the named fallback for any blob you used to hand to unserialize. If a legacy row still stores PHP serialize format, migrate it on read and write JSON back. The options array ['allowed_classes' => false] is a last-resort read, not a design. Prefer the JSON path. Grep unserialize( the way you grep eval(.

A JWT is not a PHP session

A cookie session is a random id the server looks up. A JWT is a signed blob the client carries. A leaked __Host-session id is one row you can delete. A leaked signing key is every token still inside its exp. If you mint tokens in PHP, read the secure PHP API using JWT page. This page will not re-teach claims. Pick one per browser. Mixing a JWT in localStorage with a cookie session is two surfaces and one logout story you will get wrong.

Prove the bind, the flags, and the pin

You are not walking an exploit. You are proving the helper refused a missing bind, the Set-Cookie line carries the flags, and openUnderRoot returns null outside the root.

# cookie flags on a login you already own
curl -sS -D - -o /dev/null -X POST "https://your-app.example/login" \
 -H "Content-Type: application/x-www-form-urlencoded" \
 --data "email=you@your-app.example&password=REDACTED"
# Expect: Set-Cookie: __Host-session=...; Path=/; Secure; HttpOnly; SameSite=Lax

A unit test for the pin, using a directory you create in the test and a name that tries to leave it:

function test_open_under_root_rejects_parent(string $tmp): void {
 $root = $tmp. "/uploads";
 mkdir($root, 0700, true);
 $ok = openUnderRoot($root, "missing.bin");
 assert($ok === null); // file not created yet, realpath fails closed
 file_put_contents($root. "/note.bin", "x");
 $inside = openUnderRoot($root, "note.bin");
 assert($inside === realpath($root. "/note.bin"));
 assert(openUnderRoot($root, "../note.bin") === null);
}

Grep the hatches this page named:

rg -n "mysqli_query\\s*\\(|->query\\s*\\(\\s*[\\\"'].*\\$|ENT_COMPAT|md5\\s*\\(\\s*\\$|sha1\\s*\\(\\s*\\$|unserialize\\s*\\(|\\$_FILES\\[[^\\]]+\\]\\[[\\\"']name[\\\"']\\]" \
 --glob '!vendor'

A hit on a dotted query, on ENT_COMPAT, on md5 of a password, on unserialize, or on the client filename is a review. A hit on password_hash is the path you want.

Questions we keep getting

Is mysqli prepare as good as PDO?

Yes, if you bind. mysqli_prepare plus bind_param is two channels. mysqli_query with interpolation is one. PDO is the API this page wrote down because the options array is one place. Either library is fine. Concatenation is not.

Can I skip htmlspecialchars if I use Twig?

Twig escapes by default. A |raw filter is the hatch, the same job as echo in a .php view. Grep |raw the way this page greps escapeHtml callers. User text still cannot go in an unquoted attribute or a javascript: URL.

Does SameSite=Lax replace a CSRF token on a PHP form?

No. Lax covers a foreign POST for modern browsers. Login, old clients, and a sibling host on your eTLD+1 still need a token or a Fetch Metadata check. This page locked the cookie. It did not finish request forgery.

Mauro Chojrin

Mauro Chojrin / About Author

Mauro is a PHP Trainer and Consultant. Heโ€™s been involved in the IT Industry since the year 1997 in a wide array of positions, going from which include technical support, development, team leadership, IT Management and, off course, teaching. Currently Mauroโ€™s focus is on in-company training and consulting but also maintaining his blog and YouTube channel where he shares his knowledge with the world. LinkedIn | Twitter