Authentication and Security
Security is not optional. Every PHP application that handles user data must defend against a well-known set of attacks: SQL injection, cross-site scripting (XSS), cross-site request forgery (CSRF), and weak password storage. PHP provides built-in tools for each of these threats, and understanding how to use them correctly is what separates a production-ready application from a vulnerable one. In this lesson you will learn how to hash passwords, sanitize input, prevent common attacks, and build a session-based authentication flow.
What You'll Learn
- Password hashing with
password_hash()andpassword_verify() - Input sanitization and output encoding
- Preventing cross-site scripting (XSS)
- Generating and validating CSRF tokens
- Preventing SQL injection with prepared statements
- Building a complete session-based authentication flow
Password Hashing
Never store passwords in plain text. PHP's password_hash() function uses bcrypt by default, which applies a salt and a cost factor to produce a one-way hash. The companion password_verify() function checks a plain-text password against a stored hash:
<?php
// Hashing a password
$password = 'my_secure_password_123';
$hash = password_hash($password, PASSWORD_BCRYPT);
echo "Original password: {$password}\n";
echo "Hashed password: {$hash}\n";
echo "Hash length: " . strlen($hash) . " characters\n\n";
// Each call produces a different hash (unique salt)
$hash2 = password_hash($password, PASSWORD_BCRYPT);
echo "Second hash: {$hash2}\n";
echo "Hashes are equal: " . ($hash === $hash2 ? 'yes' : 'no') . "\n\n";
// Verifying a password
$attempts = [
'my_secure_password_123', // correct
'wrong_password', // incorrect
'MY_SECURE_PASSWORD_123', // wrong case
'', // empty
];
foreach ($attempts as $attempt) {
$matches = password_verify($attempt, $hash);
$label = $matches ? 'MATCH' : 'NO MATCH';
$display = $attempt === '' ? '(empty)' : $attempt;
echo "[{$label}] Tried: {$display}\n";
}
echo "\n";
// Using PASSWORD_DEFAULT (currently bcrypt, may change in future PHP versions)
$defaultHash = password_hash($password, PASSWORD_DEFAULT);
echo "PASSWORD_DEFAULT hash: {$defaultHash}\n";
// Check if a hash needs rehashing (e.g., after PHP upgrades the default algorithm)
$needsRehash = password_needs_rehash($hash, PASSWORD_DEFAULT);
echo "Needs rehash: " . ($needsRehash ? 'yes' : 'no') . "\n";
// Custom cost factor (higher = slower but more secure)
$options = ['cost' => 12]; // default is 10
$strongHash = password_hash($password, PASSWORD_BCRYPT, $options);
echo "Cost-12 hash: {$strongHash}\n";
echo "Info: " . json_encode(password_get_info($strongHash)) . "\n";
Always use PASSWORD_DEFAULT or PASSWORD_BCRYPT with a cost factor of at least 10. The password_needs_rehash() function lets you transparently upgrade hashes when users log in after you change the algorithm or cost.
Input Sanitization and XSS Prevention
Cross-site scripting (XSS) occurs when an attacker injects malicious scripts into your pages. The primary defense is to escape all user-supplied data before rendering it in HTML using htmlspecialchars():
<?php
// XSS attack example: user submits malicious input
$maliciousInputs = [
'<script>alert("XSS!")</script>',
'<img src=x onerror="steal(document.cookie)">',
'" onmouseover="alert(1)" data-x="',
"'; DROP TABLE users; --",
'<a href="javascript:void(0)" onclick="malicious()">Click me</a>',
];
echo "=== Without Escaping (VULNERABLE) ===\n\n";
foreach ($maliciousInputs as $input) {
echo " Raw: {$input}\n";
}
echo "\n=== With htmlspecialchars() (SAFE) ===\n\n";
foreach ($maliciousInputs as $input) {
$safe = htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
echo " Escaped: {$safe}\n";
}
echo "\n";
// Helper function for escaping output
function e(string $value): string {
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
// Sanitizing different types of input
function sanitizeInput(array $data): array {
$clean = [];
// Trim whitespace from strings
$clean['name'] = trim($data['name'] ?? '');
// Validate and sanitize email
$clean['email'] = filter_var($data['email'] ?? '', FILTER_SANITIZE_EMAIL);
$clean['email_valid'] = filter_var($clean['email'], FILTER_VALIDATE_EMAIL) !== false;
// Force integer
$clean['age'] = filter_var($data['age'] ?? 0, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 150]
]);
// Strip tags from a bio field
$clean['bio'] = strip_tags($data['bio'] ?? '');
// Sanitize URL
$clean['website'] = filter_var($data['website'] ?? '', FILTER_SANITIZE_URL);
return $clean;
}
$userInput = [
'name' => ' Alice <script>alert(1)</script> ',
'email' => 'alice@example.com',
'age' => '28',
'bio' => '<b>Hello</b> <script>evil()</script> I am a developer',
'website' => 'https://example.com/page?q=test',
];
$sanitized = sanitizeInput($userInput);
echo "Sanitized input:\n";
foreach ($sanitized as $key => $value) {
$display = is_bool($value) ? ($value ? 'true' : 'false') : $value;
echo " {$key}: {$display}\n";
}
The golden rule: sanitize input, escape output. Never trust any data that comes from the user, the URL, or an external API. Use htmlspecialchars() every time you render dynamic data into HTML.
CSRF Token Protection
Cross-site request forgery tricks authenticated users into submitting unwanted requests. CSRF tokens are unique, per-session values that verify a form submission originated from your application:
<?php
class CsrfProtection {
private static array $tokens = [];
public static function generateToken(string $formName = 'default'): string {
$token = bin2hex(random_bytes(32));
// In a real app: $_SESSION['csrf_tokens'][$formName] = $token;
self::$tokens[$formName] = $token;
return $token;
}
public static function getHiddenField(string $formName = 'default'): string {
$token = self::generateToken($formName);
return '<input type="hidden" name="_csrf_token" value="' . $token . '">';
}
public static function validateToken(string $submittedToken, string $formName = 'default'): bool {
$storedToken = self::$tokens[$formName] ?? '';
if (empty($storedToken) || empty($submittedToken)) {
return false;
}
// Use hash_equals to prevent timing attacks
$valid = hash_equals($storedToken, $submittedToken);
// Invalidate token after use (one-time use)
unset(self::$tokens[$formName]);
return $valid;
}
}
// Generate tokens for different forms
$loginToken = CsrfProtection::generateToken('login_form');
$profileToken = CsrfProtection::generateToken('profile_form');
echo "Login form token: {$loginToken}\n";
echo "Profile form token: {$profileToken}\n\n";
// Simulate form submissions
$submissions = [
['token' => $loginToken, 'form' => 'login_form', 'label' => 'Valid login form'],
['token' => 'fake_token_from_attacker', 'form' => 'login_form', 'label' => 'Forged login form'],
['token' => $profileToken, 'form' => 'profile_form', 'label' => 'Valid profile form'],
['token' => $profileToken, 'form' => 'profile_form', 'label' => 'Reused profile token'],
];
foreach ($submissions as $sub) {
$valid = CsrfProtection::validateToken($sub['token'], $sub['form']);
$status = $valid ? 'ACCEPTED' : 'REJECTED';
echo "[{$status}] {$sub['label']}\n";
}
echo "\n";
// HTML form example
echo "HTML form with CSRF token:\n";
echo "<form method=\"POST\" action=\"/update-profile\">\n";
echo " " . CsrfProtection::getHiddenField('profile') . "\n";
echo " <input type=\"text\" name=\"name\" value=\"Alice\">\n";
echo " <button type=\"submit\">Update</button>\n";
echo "</form>\n";
Always include a CSRF token in every form that changes state (POST, PUT, DELETE). Use hash_equals() for comparison instead of === to prevent timing attacks. Regenerate tokens after each use to prevent replay attacks.
SQL Injection Prevention
SQL injection is one of the most dangerous vulnerabilities. Prepared statements with parameter binding eliminate it entirely by separating SQL structure from data:
<?php
$pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec("
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL
)
");
$hash = password_hash('secret123', PASSWORD_BCRYPT);
$stmt = $pdo->prepare("INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)");
$stmt->execute(['alice', 'alice@example.com', $hash]);
$stmt->execute(['bob', 'bob@example.com', $hash]);
echo "=== VULNERABLE: String Concatenation ===\n\n";
// NEVER DO THIS — vulnerable to SQL injection
$maliciousUsername = "' OR '1'='1";
$vulnerableQuery = "SELECT * FROM users WHERE username = '{$maliciousUsername}'";
echo "Dangerous query: {$vulnerableQuery}\n";
echo "This would return ALL users!\n\n";
echo "=== SAFE: Prepared Statements ===\n\n";
// Always use prepared statements
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$stmt->execute(['username' => $maliciousUsername]);
$results = $stmt->fetchAll();
echo "Safe query with injection attempt: " . count($results) . " results (correctly 0)\n\n";
// Normal lookup works perfectly
$stmt->execute(['username' => 'alice']);
$user = $stmt->fetch();
echo "Safe query with valid input: Found {$user['username']} ({$user['email']})\n\n";
// Safe search with LIKE
$searchTerm = "%ali%";
$stmt = $pdo->prepare("SELECT * FROM users WHERE username LIKE :search");
$stmt->execute(['search' => $searchTerm]);
$results = $stmt->fetchAll();
echo "Safe LIKE search:\n";
foreach ($results as $row) {
echo " Found: {$row['username']}\n";
}
echo "\n";
// Safe IN clause with multiple parameters
$ids = [1, 2];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT username FROM users WHERE id IN ({$placeholders})");
$stmt->execute($ids);
$results = $stmt->fetchAll();
echo "Safe IN query:\n";
foreach ($results as $row) {
echo " {$row['username']}\n";
}
The key principle: never concatenate user input into SQL. Use ? positional parameters or :name named parameters with prepare() and execute(). This applies to every query, not just those with user-visible input.
Session-Based Authentication Flow
Combining all these techniques, here is a complete registration and login system:
<?php
$pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec("
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
");
// Simulated session
$_SESSION = [];
class Auth {
private PDO $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function register(string $username, string $email, string $password): array {
// Validate input
$errors = [];
if (strlen($username) < 3) {
$errors[] = 'Username must be at least 3 characters';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email address';
}
if (strlen($password) < 8) {
$errors[] = 'Password must be at least 8 characters';
}
if (!empty($errors)) {
return ['success' => false, 'errors' => $errors];
}
// Check if username exists
$stmt = $this->pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
if ($stmt->fetch()) {
return ['success' => false, 'errors' => ['Username already taken']];
}
// Hash password and insert
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->pdo->prepare(
"INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)"
);
$stmt->execute([$username, $email, $hash]);
return ['success' => true, 'user_id' => (int) $this->pdo->lastInsertId()];
}
public function login(string $username, string $password): array {
$stmt = $this->pdo->prepare(
"SELECT id, username, email, password_hash FROM users WHERE username = ?"
);
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
return ['success' => false, 'errors' => ['Invalid username or password']];
}
// Regenerate session ID to prevent session fixation
// session_regenerate_id(true); // In a real app
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['logged_in_at'] = date('Y-m-d H:i:s');
return ['success' => true, 'user' => [
'id' => $user['id'],
'username' => $user['username'],
'email' => $user['email'],
]];
}
public function isAuthenticated(): bool {
return isset($_SESSION['user_id']);
}
public function getCurrentUser(): ?array {
if (!$this->isAuthenticated()) {
return null;
}
return [
'id' => $_SESSION['user_id'],
'username' => $_SESSION['username'],
];
}
public function logout(): void {
$_SESSION = [];
// session_destroy(); // In a real app
}
}
$auth = new Auth($pdo);
// Register users
echo "=== Registration ===\n\n";
$registrations = [
['alice', 'alice@example.com', 'secure_pass_123'],
['bob', 'bob@example.com', 'another_password'],
['alice', 'alice2@example.com', 'duplicate_user'], // duplicate
['x', 'bad', 'short'], // validation errors
];
foreach ($registrations as [$username, $email, $password]) {
$result = $auth->register($username, $email, $password);
if ($result['success']) {
echo "[OK] Registered '{$username}' (ID: {$result['user_id']})\n";
} else {
echo "[FAIL] '{$username}': " . implode(', ', $result['errors']) . "\n";
}
}
// Login
echo "\n=== Login ===\n\n";
$loginAttempts = [
['alice', 'secure_pass_123'], // correct
['alice', 'wrong_password'], // wrong password
['nobody', 'any_password'], // user not found
];
foreach ($loginAttempts as [$username, $password]) {
$result = $auth->login($username, $password);
if ($result['success']) {
echo "[OK] Logged in as {$result['user']['username']} ({$result['user']['email']})\n";
} else {
echo "[FAIL] '{$username}': " . implode(', ', $result['errors']) . "\n";
}
}
// Check authentication state
echo "\n=== Auth State ===\n\n";
echo "Authenticated: " . ($auth->isAuthenticated() ? 'yes' : 'no') . "\n";
$user = $auth->getCurrentUser();
echo "Current user: {$user['username']} (ID: {$user['id']})\n";
// Logout
$auth->logout();
echo "After logout: " . ($auth->isAuthenticated() ? 'yes' : 'no') . "\n";
Notice the layered defense: input validation catches bad data early, prepared statements prevent SQL injection, password_hash() protects stored passwords, and session regeneration prevents fixation attacks. Never reveal whether a username or password was incorrect individually -- always use a generic "Invalid username or password" message.
Try It Yourself
Build a middleware-style function that combines multiple security checks. This example validates a CSRF token, checks authentication, and sanitizes input before processing a request:
<?php
// Simulated session and request
$_SESSION = ['user_id' => 1, 'username' => 'alice'];
$csrfTokens = [];
function generateCsrf(): string {
global $csrfTokens;
$token = bin2hex(random_bytes(32));
$csrfTokens[] = $token;
return $token;
}
function verifyCsrf(string $token): bool {
global $csrfTokens;
foreach ($csrfTokens as $i => $stored) {
if (hash_equals($stored, $token)) {
unset($csrfTokens[$i]);
return true;
}
}
return false;
}
function secureHandler(array $request, callable $handler): array {
// Step 1: Check authentication
if (!isset($_SESSION['user_id'])) {
return ['status' => 401, 'error' => 'Authentication required'];
}
// Step 2: Verify CSRF token for state-changing requests
if (in_array($request['method'], ['POST', 'PUT', 'DELETE'])) {
if (!verifyCsrf($request['csrf_token'] ?? '')) {
return ['status' => 403, 'error' => 'Invalid CSRF token'];
}
}
// Step 3: Sanitize all string inputs
$cleanData = [];
foreach ($request['data'] ?? [] as $key => $value) {
$cleanData[$key] = is_string($value)
? htmlspecialchars(trim($value), ENT_QUOTES, 'UTF-8')
: $value;
}
// Step 4: Call the actual handler with clean data
return $handler($cleanData, $_SESSION['user_id']);
}
// Generate a valid CSRF token
$validToken = generateCsrf();
// Test scenarios
$tests = [
[
'label' => 'Valid POST with CSRF',
'request' => [
'method' => 'POST',
'csrf_token' => $validToken,
'data' => ['title' => ' New Post ', 'body' => '<b>Hello</b> world'],
],
],
[
'label' => 'POST without CSRF token',
'request' => [
'method' => 'POST',
'csrf_token' => 'fake_token',
'data' => ['title' => 'Hacked'],
],
],
[
'label' => 'GET request (no CSRF needed)',
'request' => [
'method' => 'GET',
'data' => ['search' => '<script>alert(1)</script>'],
],
],
];
$handler = function (array $data, int $userId): array {
return ['status' => 200, 'message' => 'Processed', 'user_id' => $userId, 'data' => $data];
};
foreach ($tests as $test) {
echo "--- {$test['label']} ---\n";
$result = secureHandler($test['request'], $handler);
echo json_encode($result, JSON_PRETTY_PRINT) . "\n\n";
}
// Test unauthenticated request
$_SESSION = [];
echo "--- Unauthenticated request ---\n";
$result = secureHandler(['method' => 'GET', 'data' => []], $handler);
echo json_encode($result, JSON_PRETTY_PRINT) . "\n";
Key Takeaways
- Always hash passwords with
password_hash()and verify withpassword_verify()-- never store plain text - Escape all dynamic output with
htmlspecialchars()to prevent XSS attacks - Include CSRF tokens in every state-changing form and validate them with
hash_equals() - Use prepared statements with parameter binding for every database query to prevent SQL injection
- Regenerate session IDs after login with
session_regenerate_id(true)to prevent session fixation - Never reveal specific authentication failure reasons -- use generic error messages
- Use
filter_var()andfilter_input()to validate and sanitize user input by type - Apply defense in depth: combine multiple security layers rather than relying on a single check
Next Steps
You now know how to hash passwords, prevent XSS and SQL injection, implement CSRF protection, and build complete authentication flows. Your applications are ready to handle real user data securely.
Building a secure application locally is only half the battle -- you also need to get it running safely on a server. Deployment covers configuring Nginx and PHP-FPM, managing environment variables, setting up production error logging, and optimizing performance with OPcache. These operational skills close the gap between "it works on my machine" and "it works in production," and they are essential for any PHP developer who ships real software.
Pro Tip: Security is not a one-time task. Subscribe to PHP security advisories, keep your PHP version updated, and use tools like
composer auditto check for vulnerabilities in your dependencies. Consider adding Content-Security-Policy headers, rate limiting on login endpoints, and account lockout after repeated failed attempts. The OWASP Top 10 is an excellent checklist for web application security.