<< Home

Chapter 17: Security Fundamentals

Security in PHP is about careful data handling, conservative defaults, and consistent defense in depth. You will validate and sanitize inputs, you will escape outputs, you will parameterize queries, and you will protect state with secure sessions and headers. This chapter walks through practical techniques you can apply in every project, with compact examples that you can adapt and reuse.

💡 Start from a deny by default mindset, then explicitly allow only what you need. Fewer features and simpler code often produce fewer vulnerabilities.

Validating and Sanitizing Input

Every external value is untrusted, including values from $_GET, $_POST, $_COOKIE, $_FILES, php://input, and JSON bodies. Validation checks whether a value conforms to your rules. Sanitizing transforms the value into a safe representation. Perform validation first, then sanitize for the destination where the value will be used.

Using filter_var() and filter_input() for strict validation

The filter_* functions provide fast, readable validators for common data shapes. You can require an email address, a url, an integer range, or a boolean flag. Craft specific rules and reject everything else.

// Integer id in range
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, [
  'options' => ['min_range' => 1, 'max_range' => 1000000]
]);
if ($id === false) {
  http_response_code(400);
  exit('Invalid id');
}

// Email with dns check
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($email === false) {
  exit('Invalid email');
}

Normalizing and trimming user input safely

After validation you may normalize whitespace, normalize Unicode, or convert locale specific formats. Keep normalization simple. Trimming and collapsing repeated spaces are often enough for names or free text.

$name = trim((string)($_POST['name'] ?? ''));
$name = preg_replace('/\s{2,}/u', ' ', $name);

Allowlisting with regular expressions

For custom formats prefer allowlists. Write a pattern that strictly defines what is accepted. Reject partial matches and ambiguous forms. Anchor your pattern at the start and end.

// Usernames: letters, digits, underscore, length 3 to 24
$username = $_POST['username'] ?? '';
if (!preg_match('/^[A-Za-z0-9_]{3,24}$/', $username)) {
  exit('Invalid username');
}
⚠️ Do not rely on client side checks. Browsers can skip or alter them. Always repeat validation on the server.

Sanitizing free text with strip_tags() and htmlspecialchars()

When storing or echoing free text you can strip tags to remove markup, then escape on output. Store the raw text if you need full fidelity, then escape for each context later. If you only permit a small set of tags pass an allowlist to strip_tags().

$raw = (string)($_POST['comment'] ?? '');
$clean = strip_tags($raw, '<b><i><code>');  // allow a few tags
// Store $clean, then escape when rendering in HTML

Escaping Output and XSS

Cross site scripting occurs when untrusted input appears in HTML without correct escaping. Escape at the final moment for the specific sink where the value lands, such as HTML text, attribute values, JavaScript strings, CSS strings, or URLs. The correct escaping function depends on the context.

Escaping for HTML text with htmlspecialchars()

For plain HTML text nodes convert special characters. Use ENT_QUOTES and UTF-8.

$safe = htmlspecialchars($clean, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<p>' . $safe . '</p>';

Escaping for attributes and URLs

Attributes need HTML escaping plus careful quoting. When building links, validate the scheme and use rawurlencode() for query values. Prevent javascript or data schemes unless you intend to support them.

$q = rawurlencode((string)($_GET['q'] ?? ''));
$url = '/search?q=' . $q;  // server validated path
echo '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">Search</a>';

Safely embedding data in JavaScript

Do not concatenate untrusted strings into inline scripts. Prefer JSON encoding and then read the value. This prevents breaking out of string or script contexts.

$data = ['user' => $username, 'prefs' => ['theme' => 'light']];
$json = json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
?>
<script>
  const APP_DATA = JSON.parse('<?= $json ?>');
</script>
<?php
💡 Add a Content Security Policy to limit where scripts and resources can load from. Even if a bug slips in, CSP can reduce impact.

SQL Injection and Parameterization

SQL injection happens when untrusted data is mixed directly into SQL strings. The fix is to use prepared statements with bound parameters. Never concatenate user input into a query. Use placeholders and let the driver handle encoding and quoting.

Using PDO prepared statements with ? placeholders

Prepared statements separate the SQL plan from the data. Bind values by position or by name. The driver transmits values safely to the database.

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

$stmt = $pdo->prepare('SELECT id, email FROM users WHERE email = ?');
$stmt->execute([$_POST['email'] ?? '']);
$user = $stmt->fetch();

Using named parameters for clarity

Named parameters make longer queries easier to read and maintain. Bind by array or with bindValue().

$stmt = $pdo->prepare('
  INSERT INTO posts (user_id, title, body)
  VALUES (:uid, :title, :body)
');
$stmt->execute([
  ':uid' => $userId,
  ':title' => $title,
  ':body' => $body,
]);

Avoiding dynamic identifiers and building safe allowlists

Placeholders only work for values. They do not work for table or column names. If you must switch tables or sort columns from input, map user choices to a fixed allowlist and interpolate only known safe strings.

$allowedSort = ['created_at', 'title', 'id'];
$sort = in_array($_GET['sort'] ?? '', $allowedSort, true) ? $_GET['sort'] : 'created_at';
$sql = "SELECT id, title FROM posts ORDER BY $sort DESC";
⚠️ Escaping values with addslashes() is not sufficient. Use prepared statements for all untrusted data in queries.

Password Hashing and Sessions

Never store passwords in plain text. Hash with strong algorithms, store the hash, and verify on login. Protect session identifiers and rotate them at key moments. Limit session lifetime and scope with cookie attributes.

Hashing with password_hash() and verifying with password_verify()

password_hash() selects a safe default algorithm and salt strategy. Store the returned string. Later call password_verify() to check a login attempt.

// Creating a user
$hash = password_hash($password, PASSWORD_DEFAULT);

// Authenticating
if (password_verify($passwordAttempt, $hash)) {
  // ok
} else {
  // fail
}

Rehashing when algorithm options change with password_needs_rehash()

When PHP updates defaults or you raise the cost, a legacy hash can be upgraded on the next successful login. Check and rehash transparently.

$options = ['cost' => 12];
if (password_needs_rehash($hash, PASSWORD_DEFAULT, $options)) {
  $hash = password_hash($passwordAttempt, PASSWORD_DEFAULT, $options);
  // save new $hash
}

Starting secure sessions and rotating identifiers

Enable strict mode and use secure cookie attributes. Regenerate the session id after login to prevent fixation. Do not store sensitive secrets in the session store.

session_set_cookie_params([
  'secure' => true,
  'httponly' => true,
  'samesite' => 'Lax',  // or 'Strict' for same site only flows
]);
ini_set('session.use_strict_mode', '1');

session_start();

// After successful login
session_regenerate_id(true);
$_SESSION['uid'] = $user['id'];
💡 Consider shorter session lifetimes for admin areas. Idle timeouts reduce risk from stolen tokens.

CSRF, Clickjacking, and Headers

Cross site request forgery tricks a browser into sending a valid request with the victim’s cookies. Defend with same site cookies, anti CSRF tokens, and method design. Clickjacking overlays a trusted page inside an invisible frame. Defend with framing controls and a Content Security Policy.

Protecting forms with synchronizer tokens

Generate a per session or per form token, store it server side, and include it in a hidden field. On submit verify equality and timing safety.

// Generate once per session
if (empty($_SESSION['csrf'])) {
  $_SESSION['csrf'] = bin2hex(random_bytes(32));
}
?>
<form method="post" action="/transfer">
  <input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES, 'UTF-8') ?>">
  <!-- other fields -->
</form>
<?php
// On POST
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
  http_response_code(403);
  exit('Invalid CSRF token');
}

Setting cookie attributes to limit cross site sends

SameSite reduces ambient cross site cookie sends. Pair this with Secure and HttpOnly. For sensitive operations pick SameSite=Strict where your flow permits it.

setcookie('session', $sid, [
  'secure' => true,
  'httponly' => true,
  'samesite' => 'Lax'
]);

Blocking frames and defining a Content Security Policy

Prevent clickjacking with X-Frame-Options or CSP frame-ancestors. A minimal CSP can also restrict scripts and styles to your own origin.

header('X-Frame-Options: DENY'); // legacy protection
header("Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'");
⚠️ Test CSP in report only mode first: Content-Security-Policy-Report-Only: …. A strict policy can break pages until you add the correct sources.

Secure Configuration and Secrets

Security depends on runtime configuration as much as code. Hide stack traces from users, log errors privately, keep secrets out of the repository, and restrict file system access. Load configuration from the environment at startup and avoid dynamic changes during requests.

Configuring production error handling

Do not display errors to end users. Send errors to logs. Keep logs outside the web root. Review logs regularly and aggregate them in your observability stack.

ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/app/error.log');
error_reporting(E_ALL);

Managing secrets with environment variables and rotation

Store keys in environment variables or a secret store. Rotate credentials on a schedule. Never commit secrets to version control. Restrict read access with file permissions.

$dbDsn = getenv('APP_DB_DSN');
$dbUser = getenv('APP_DB_USER');
$dbPass = getenv('APP_DB_PASS');

$pdo = new PDO($dbDsn, $dbUser, $dbPass, [
  PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);

Generating cryptographic randomness with random_bytes()

Use the built in CSPRNG for tokens and keys. Convert to printable strings with bin2hex() or base64_encode(). Do not use mt_rand() for security decisions.

$token = bin2hex(random_bytes(32));  // 64 hex characters

Hardening the HTTP surface with minimal, explicit headers

Reduce information leakage and enforce safe browser behavior. Set HSTS after you serve over HTTPS reliably. Remove server banners where possible.

HeaderPurposeExample
Strict-Transport-SecurityForces HTTPS for a periodmax-age=31536000; includeSubDomains
Referrer-PolicyLimits referrer datano-referrer-when-downgrade
Permissions-PolicyControls powerful featuresgeolocation=()
X-Content-Type-OptionsDisables MIME sniffingnosniff
💡 Keep a small bootstrap that sets headers, session params, and error handling in one place. Centralized defaults cut repetition and reduce mistakes.

Separating build artifacts from the web root

Place vendor libraries, configuration files, and writable directories outside the document root. Expose only the public front controller, static assets, and nothing else. Deny direct access to backups and temporary files with server rules.

/var/www/app/
  public/  # only this is web accessible
  src/
  vendor/
  var/
  .env

Strong validation, careful escaping, parameterized queries, protected sessions, and disciplined configuration form a cohesive baseline. Apply these habits consistently and your applications will be much harder to exploit while remaining clear and maintainable.