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.

auth.php — login success
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.

auth_guard.php
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.

login_attempts table
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.

csrf.php
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:

bootstrap.php
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.