PHP sessions get a bad reputation — often deservedly, because most tutorials show the bare minimum and developers ship that. But session-based auth can be genuinely secure when you apply the right layer of controls. Here's how I do it across all my production systems.
Session fixation — the first thing to fix
Session fixation is when an attacker tricks a user into authenticating with a session ID the attacker already knows. The fix is simple and non-negotiable: regenerate the session ID immediately after a successful login.
session_start();
if (password_verify($password, $hash)) {
// Regenerate ID before writing any session data
session_regenerate_id(true); // true = delete old session
$_SESSION['user_id'] = $user['id'];
$_SESSION['role'] = $user['role'];
$_SESSION['ua'] = $_SERVER['HTTP_USER_AGENT'];
$_SESSION['ip'] = $_SERVER['REMOTE_ADDR'];
$_SESSION['created'] = time();
}
I also store the user agent and IP at login time. On every subsequent request, I verify they haven't changed. This won't stop a sophisticated attacker but it will catch session theft in the vast majority of real-world cases.
Session validation on every request
Every protected page starts with a call to a requireAuth() function.
It checks that a valid session exists, that the UA and IP match, and that the session
hasn't exceeded the idle timeout.
function requireAuth(string $requiredRole = '') {
if (session_status() === PHP_SESSION_NONE) session_start();
if (empty($_SESSION['user_id'])) {
redirect('/login.php');
}
// UA binding
if ($_SESSION['ua'] !== $_SERVER['HTTP_USER_AGENT']) {
session_destroy();
redirect('/login.php?reason=ua_mismatch');
}
// Idle timeout (30 min)
if (isset($_SESSION['last_active']) &&
time() - $_SESSION['last_active'] > 1800) {
session_destroy();
redirect('/login.php?reason=timeout');
}
$_SESSION['last_active'] = time();
// Role check
if ($requiredRole && $_SESSION['role'] !== $requiredRole) {
http_response_code(403);
exit('Access denied.');
}
}
Brute-force protection without a cache layer
I don't always have Redis or Memcached available on a client's VPS. Brute-force rate limiting via a database table works well enough for the scale I'm dealing with.
CREATE TABLE login_attempts (
id INT AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(45) NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX (ip, attempted_at)
);
-- Check before allowing a login attempt:
SELECT COUNT(*) FROM login_attempts
WHERE ip = ? AND attempted_at > NOW() - INTERVAL 15 MINUTE;
If the count exceeds a threshold (I use 10), the login form shows a lockout message and returns early. Successful logins purge the attempt rows for that IP. A scheduled event or cron cleans up old rows daily.
Password hashing — the only acceptable standard
password_hash($password, PASSWORD_BCRYPT) and
password_verify(). That's it. Never MD5, never SHA-1 with a salt,
never any custom scheme. Bcrypt with PHP's default cost is sufficient for every
system I've built. If you need Argon2, PHP has PASSWORD_ARGON2ID
and it's one character change.
CSRF on every state-changing form
Session-based auth is vulnerable to CSRF because the browser sends cookies automatically. Every POST form that changes state gets a CSRF token.
function csrfToken(): string {
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function verifyCsrf(): void {
$token = $_POST['_csrf'] ?? '';
if (!hash_equals($_SESSION['csrf'] ?? '', $token)) {
http_response_code(403);
exit('CSRF validation failed.');
}
}
In forms: <input type="hidden" name="_csrf" value="<?= csrfToken() ?>">.
In every POST handler: verifyCsrf(); before doing anything else.
Secure session cookie configuration
The php.ini defaults aren't good enough. At the top of every application's bootstrap:
ini_set('session.cookie_httponly', '1'); // No JS access
ini_set('session.cookie_secure', '1'); // HTTPS only
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', '1'); // Reject unrecognised IDs
ini_set('session.gc_maxlifetime', '1800');
session_start();
Security isn't a feature you add at the end. It's a set of defaults you bake in at the start and enforce consistently across every file.
These patterns cover the OWASP Top 10 vulnerabilities most relevant to session-based PHP applications. They add maybe 40 lines of code to a project and prevent the most common categories of breach. No framework required.