Security Best Practices

Reviewed & published by Brayan K

By the end of this lesson you'll be able to spot and fix the most common PHP security holes — SQL injection, XSS, and CSRF — and harden passwords, sessions, file uploads, and HTTP headers the way real production apps do.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ SQL Injection — Prepared Statements

SQL injection is the classic, still-number-one web attack. It happens when you glue user input directly into a SQL string: the input then stops being data and becomes part of the command. The fix is a prepared statement — you send the query with a ? placeholder first, then send the value separately, so the database can never confuse the two. This is exactly why the Database lesson taught you prepare() and execute().

<?php
// === SQL INJECTION: never paste user input straight into a query ===
// Imagine this email arrived from a login form ($_POST['email']).
$email = "' OR '1'='1";   // attacker input, not a real address

// ❌ VULNERABLE: the input becomes PART OF the SQL command.
$badSql = "SELECT * FROM users WHERE email = '{$email}'";
echo "Vulnerable query sent to the database:\n  {$badSql}\n";
echo "  -> 'OR 1=1' is always true, so the WHERE matches EVERY row.\n";
echo "  -> the attacker is logged in as the first user, no password needed.\n\n";

// ✅ FIXED: a prepared statement sends the SQL and the value on SEPARATE
// channels. The ? is a placeholder; the value can never become code.
//   \$pdo  = new PDO('mysql:host=localhost;dbname=app', \$user, \$pass);
//   \$stmt = \$pdo->prepare('SELECT * FROM users WHERE email = ?');
//   \$stmt->execute([\$email]);          // value bound, structure fixed
//   \$row  = \$stmt->fetch();
echo "Fixed: prepare('... WHERE email = ?') then execute([\$email]).\n";
echo "The database treats \$email as DATA, never as part of the query.\n";

Read the vulnerable query out loud: the attacker's ' OR '1'='1 closes your quote and adds a condition that's always true. With a prepared statement the value stays in its own lane — there's no way to "escape" into the SQL.

2️⃣ XSS — Escape Output & Use a CSP

XSS (cross-site scripting) is the mirror image of SQL injection, but the victim is the browser instead of the database. If you echo untrusted text into a page without escaping it, an attacker can slip in a <script> tag that then runs in every visitor's browser — stealing cookies or hijacking the session. The fix: run every piece of untrusted output through htmlspecialchars(), which converts < > " ' & into harmless entities. Add a Content-Security-Policy header as a second wall.

<?php
// === XSS (Cross-Site Scripting): escape untrusted data before output ===
// This comment text supposedly came from a visitor ($_GET['msg']).
$comment = '<script>steal(document.cookie)</script>';

// ❌ VULNERABLE: echoing raw input puts the attacker's <script> into the
// page. Every other visitor's browser then RUNS it.
$vulnerable = "<p>{$comment}</p>";

// ✅ FIXED: htmlspecialchars() turns < > " ' & into harmless HTML entities,
// so the browser shows the text instead of executing it.
$safe = "<p>" . htmlspecialchars($comment, ENT_QUOTES, 'UTF-8') . "</p>";

echo "Raw (vulnerable): {$vulnerable}\n";
echo "Escaped (safe):   {$safe}\n";
echo "\n";
echo "Defence in depth: also send a Content-Security-Policy header\n";
echo "  header(\"Content-Security-Policy: default-src 'self'\");\n";
echo "so even an injected <script> is blocked by the browser.\n";

In the safe output the < became &lt;, so the browser displays the text instead of running it. Always escape at the moment of output, and always say which charset ('UTF-8') you mean.

3️⃣ CSRF — Per-Session Tokens

CSRF (cross-site request forgery) tricks a logged-in user's browser into firing a request at your site — say, "change my email" — without them realising. Because the browser automatically attaches their session cookie, your server thinks it's genuine. The defence is a secret token that only your own pages know: put a random value in a hidden form field, store the same value in the session, and reject any POST whose token doesn't match. Compare with hash_equals(), not ===, so the comparison takes constant time and leaks no timing clues.

<?php
// === CSRF (Cross-Site Request Forgery): prove the request came from YOU ===
// A CSRF attack tricks a logged-in user's browser into submitting a form to
// your site (e.g. "transfer money") without them meaning to. A secret token
// that only your real pages know stops it.

// session_start();   // (skipped here so this runs as a plain script)

// On GET: generate a random token, store it, put it in a hidden form field.
$token = bin2hex(random_bytes(32));    // 64 hex chars, unguessable
// The token is random, so print how long it is rather than one run's value.
echo "Hidden field: <input type=\"hidden\" name=\"csrf\" value=\"<" . strlen($token) . " hex chars>\">\n\n";

// On POST: compare the submitted token with the stored one.
$stored    = $token;     // would be \$_SESSION['csrf']
$submitted = $token;     // would be \$_POST['csrf']

// ❌ WRONG: a plain === leaks timing information about the secret.
// ✅ RIGHT: hash_equals() compares in constant time.
$ok = hash_equals($stored, $submitted);
echo "Submitted token matches the session token? " . var_export($ok, true) . "\n";

$forged = hash_equals($stored, 'attacker-guessed-value');
echo "A forged request's token matches?            " . var_export($forged, true) . "\n";

4️⃣ Passwords, Sessions & Cookies

Never store a real password — store a hash. Use password_hash() (bcrypt/Argon2): it's deliberately slow, salts every hash for you, and pairs with password_verify() at login. Then lock down the session cookie: httponly hides it from JavaScript (blunting XSS theft), secure sends it only over HTTPS, and samesite=Strict keeps it off cross-site requests (blunting CSRF). Call session_regenerate_id() right after login to defeat session fixation.

<?php
// === PASSWORD HASHING: never store the real password ===
$password = 'MyS3cur3P@ss';

// ❌ NEVER: md5()/sha1() are fast checksums — a GPU cracks billions/sec.
//    \$bad = md5(\$password);

// ✅ Registration: password_hash() uses bcrypt/argon2 — slow and salted FOR you.
$hash = password_hash($password, PASSWORD_DEFAULT);
// Same again: the salt makes every hash different, so state the facts that hold.
echo "Stored hash: " . password_get_info($hash)["algoName"] . ", " . strlen($hash) . " chars, different every run\n\n";

// ✅ Login: verify the typed password against the stored hash.
echo "Correct password verifies? " . var_export(password_verify('MyS3cur3P@ss', $hash), true) . "\n";
echo "Wrong password verifies?   " . var_export(password_verify('wrongpass',    $hash), true) . "\n\n";

// === SECURE SESSIONS & COOKIES ===
// Set these BEFORE session_start() so the session cookie is locked down:
//   session_set_cookie_params([
//     'httponly' => true,    // JavaScript can't read it -> blunts XSS cookie theft
//     'secure'   => true,    // only sent over HTTPS
//     'samesite' => 'Strict' // not sent on cross-site requests -> blunts CSRF
//   ]);
//   session_start();
//   session_regenerate_id(true);   // new ID after login -> stops session fixation
echo "Cookie flags to set: httponly, secure, samesite=Strict.\n";

5️⃣ Validate Input, Uploads, SSRF & Headers

The thread running through every section is one rule: never trust user input. Validate its shape with filter_var(). For file uploads, trust the bytes (the real MIME type) not the filename, give the file a random name, and store it outside the webroot so it can't be executed. For SSRF, never fetch a user-supplied URL without an allow-list of permitted hosts. And set security headers — X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, Content-Security-Policy — on every response.

<?php
// === NEVER TRUST INPUT: validate, then act ===
// filter_var() checks the SHAPE of a value and returns false if it's wrong.
$emails = ['[email protected]', 'not-an-email'];
foreach ($emails as $e) {
    $ok = filter_var($e, FILTER_VALIDATE_EMAIL) !== false;
    echo str_pad($e, 16) . " -> valid email? " . var_export($ok, true) . "\n";
}
echo "age '42'  is an int? " . var_export(filter_var('42',  FILTER_VALIDATE_INT) !== false, true) . "\n";
echo "age '4.2' is an int? " . var_export(filter_var('4.2', FILTER_VALIDATE_INT) !== false, true) . "\n\n";

// === FILE UPLOADS: trust the bytes, not the name ===
// ❌ "photo.jpg.php" looks like an image but RUNS as PHP if you keep the name.
// ✅ Check the real MIME type, give it a random name, store OUTSIDE the webroot:
//   \$mime = (new finfo(FILEINFO_MIME_TYPE))->file(\$tmp);   // read the bytes
//   if (!in_array(\$mime, ['image/jpeg','image/png'])) reject();
//   \$safeName = bin2hex(random_bytes(16)) . '.jpg';
//   move_uploaded_file(\$tmp, '/var/uploads/' . \$safeName);
echo "Upload rule: check MIME, rename randomly, store outside the webroot.\n";

// === SSRF: never fetch a URL the user supplied without checking it ===
// An attacker passes http://169.254.169.254/ to reach internal cloud metadata.
// ✅ Allow-list the hosts you intend to call; block private/loopback IPs.
echo "SSRF rule: allow-list outbound URLs; reject internal/private addresses.\n";

// === SECURITY HEADERS: set them before ANY output ===
//   header('X-Content-Type-Options: nosniff');
//   header('X-Frame-Options: DENY');
//   header('Strict-Transport-Security: max-age=31536000');
//   header(\"Content-Security-Policy: default-src 'self'\");
echo "Send security headers on every response.\n";

Now you try. The script below is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.

<?php
// 🎯 YOUR TURN — make the user's comment safe to print.
// The attacker tucked a <script> tag inside their "comment".

$comment = $_GET['comment'] ?? '<script>alert(1)</script>';

// 1) Escape $comment so the browser shows it as text, not code.
//    Use htmlspecialchars() with ENT_QUOTES and the 'UTF-8' charset.
$safe = ___;            // 👉 htmlspecialchars($comment, ENT_QUOTES, 'UTF-8')

echo "<p>{$safe}</p>\n";

// ✅ Expected output:
//    <p>&lt;script&gt;alert(1)&lt;/script&gt;</p>

One more. Turn a dangerous concatenated query into a parameterised one by placing a single ? where the value belongs.

<?php
// 🎯 YOUR TURN — rewrite a dangerous query as a prepared statement.
// $username comes from a login form, so it can NOT be trusted.

$username = "admin' --";   // attacker input that comments out the rest

// ❌ The vulnerable version (do NOT use):
//    \$sql = "SELECT * FROM users WHERE name = '{$username}'";

// 1) Replace the value with a ?  placeholder in the SQL string:
$sql = "SELECT * FROM users WHERE name = ___";   // 👉 put a single ?  where the value goes

// 2) Pass the real value to execute() as an array element:
//    \$stmt = \$pdo->prepare(\$sql);
//    \$stmt->execute([___]);                     // 👉 \$username

echo "Prepared SQL: {$sql}\n";

// ✅ Expected output:
//    Prepared SQL: SELECT * FROM users WHERE name = ?

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Threat → Defence

ThreatDefenceIn PHP
SQL injectionPrepared statements$pdo->prepare('... = ?')
XSSEscape output + CSPhtmlspecialchars($x, ENT_QUOTES)
CSRFPer-session tokenhash_equals($sess, $sent)
Weak passwordsSlow, salted hashpassword_hash() / password_verify()
Session hijack/fixationHardened cookie + new IDhttponly,secure,samesite; regenerate_id()
Bad / malicious inputValidate shapefilter_var($x, FILTER_VALIDATE_*)
Malicious file uploadMIME + rename + movefinfo; random name; outside webroot
SSRFAllow-list URLsblock private/loopback hosts
Clickjacking / sniffingSecurity headersheader('X-Frame-Options: DENY')

Mini-Challenge: A Safe Comment Handler

No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This combines CSRF checking and XSS escaping — the two defences you'll wire into almost every form you ever build.

<?php
// 🎯 MINI-CHALLENGE: a safe "post a comment" handler.
// No code is filled in — work from the steps, then run it.
//
// 1. Pretend the form sent these (already provided):
//      \$name    = "Bobby <b>Tables</b>";
//      \$token   = "expected-token";   // the session's CSRF token
//      \$sent    = "expected-token";   // the token the form submitted
// 2. Reject the request unless the tokens match — use hash_equals(\$token, \$sent).
//      If they don't match, echo "Rejected: bad CSRF token" and stop.
// 3. If they match, escape the name with
//      htmlspecialchars(\$name, ENT_QUOTES, 'UTF-8')
//    and echo:  Saved comment from: <escaped name>
//
// Tip: store the comment with a prepared statement (? placeholder), never by
//      concatenating \$name into the SQL.
//
// ✅ Expected output (tokens match):
//    Saved comment from: Bobby &lt;b&gt;Tables&lt;/b&gt;

// your code here

🎉 Lesson Complete!

Practice quiz

What is the correct defence against SQL injection?

  • Escaping quotes with addslashes()
  • Removing spaces from input
  • Prepared statements with bound parameters
  • Using md5() on the query

Answer: Prepared statements with bound parameters. A prepared statement sends the SQL and the value on separate channels, so user input can never become part of the command.

Why is "' OR '1'='1" dangerous when concatenated into a query?

  • It closes the quote and adds an always-true condition, matching every row
  • It crashes the database
  • It deletes the users table
  • It is harmless text

Answer: It closes the quote and adds an always-true condition, matching every row. The injected condition is always true, so WHERE matches every row and the attacker logs in as the first user.

Which function escapes output to prevent XSS?

  • strip_tags() only
  • urlencode()
  • json_encode()
  • htmlspecialchars($x, ENT_QUOTES, 'UTF-8')

Answer: htmlspecialchars($x, ENT_QUOTES, 'UTF-8'). htmlspecialchars() converts < > " ' & into HTML entities so the browser displays untrusted text instead of executing it.

When should you escape data for XSS protection?

  • Before storing it in the database
  • At the moment you output it into HTML
  • Only once, at registration
  • Never, the database handles it

Answer: At the moment you output it into HTML. Store the raw value and escape only at output; escaping before storage leads to double-escaped, mangled text.

How should a CSRF token be compared to avoid timing attacks?

  • With hash_equals(), which compares in constant time
  • With === for speed
  • With == loose comparison
  • With strcmp()

Answer: With hash_equals(), which compares in constant time. hash_equals() compares in constant time so it leaks no timing information about the secret token.

Why must you never use md5() or sha1() for passwords?

  • They are deprecated syntax
  • They only work on numbers
  • They are fast checksums a GPU can crack billions/sec, and aren't salted
  • They produce hashes that are too long

Answer: They are fast checksums a GPU can crack billions/sec, and aren't salted. Password hashing must be slow and salted; password_hash() (bcrypt/Argon2) is built for this, md5/sha1 are not.

Which function verifies a password against a stored hash?

  • password_check()
  • password_verify($input, $hash)
  • hash_compare()
  • md5($input) === $hash

Answer: password_verify($input, $hash). password_verify() safely checks a plaintext password against a hash created by password_hash().

What do the httponly, secure, and samesite cookie flags do?

  • Compress the cookie
  • Encrypt the session data
  • Make the cookie never expire
  • Hide it from JavaScript, send only over HTTPS, and limit cross-site requests

Answer: Hide it from JavaScript, send only over HTTPS, and limit cross-site requests. HttpOnly blunts XSS theft, Secure forces HTTPS, and SameSite limits CSRF; set them before session_start().

Which function call defeats session fixation after login?

  • session_destroy()
  • session_regenerate_id(true)
  • session_unset()
  • setcookie('PHPSESSID', '')

Answer: session_regenerate_id(true). session_regenerate_id(true) issues a brand-new id and deletes the old one, so any planted id becomes worthless.

How should you handle a file upload safely?

  • Trust the file extension and keep the original name
  • Store it in the same folder as your scripts
  • Check the real MIME type, rename it randomly, and store it outside the webroot
  • Only check that the size is under 10 MB

Answer: Check the real MIME type, rename it randomly, and store it outside the webroot. An upload is attacker-controlled bytes; validate the MIME type, give it your own random name, and keep it out of the public directory.

Continue this course

Frequently asked questions

What is the single most important PHP security rule?

Never trust user input. Anything that comes from outside your server — form fields, URL query strings, cookies, headers, uploaded files, even API responses — could be crafted by an attacker. Validate it for shape (filter_var), escape it for the place it's going (htmlspecialchars for HTML), and bind it as data (prepared statements for SQL). Every vulnerability in the OWASP Top 10 is, at heart, input that was trusted when it shouldn't have been.

Do prepared statements stop every kind of injection?

They stop SQL injection for the values you bind, which is the common case. They do not protect the parts of a query that can't be parameterised — table and column names — so never build those from user input; use an allow-list instead. And prepared statements do nothing for XSS, command injection, or SSRF: each context (HTML, the shell, an outbound URL) needs its own defence. Bind values, allow-list identifiers, and escape per context.

Why can't I just use md5() or sha1() for passwords?

MD5 and SHA-1 are fast, general-purpose checksums — a modern GPU computes billions of them per second, so a stolen database of MD5 hashes is cracked almost instantly. Password hashing must be deliberately slow and salted. password_hash() uses bcrypt or Argon2, adds a random salt for you, and lets you raise the cost over time. Always pair it with password_verify(); never compare hashes yourself.

What do the httponly, secure, and samesite cookie flags actually do?

They lock down the session cookie. httponly hides the cookie from JavaScript, so an XSS bug can't read it and steal the session. secure makes the browser send the cookie only over HTTPS, so it can't be sniffed on plain HTTP. samesite=Strict (or Lax) stops the cookie being attached to cross-site requests, which blunts CSRF. Set all three via session_set_cookie_params() before session_start().

What is SSRF and why should a PHP developer care?

SSRF (Server-Side Request Forgery) happens when your server fetches a URL that the user supplied. If you call file_get_contents($_GET['url']) without checking it, an attacker can point it at internal addresses like http://169.254.169.254/ (cloud metadata) or http://localhost/admin to reach things only your server can see. Defend by allow-listing the hosts you intend to call and rejecting private, loopback, and link-local IP ranges.