Why Session Security Is Hard

PHP sessions seem simple: session_start(), store data in $_SESSION, done. But that simplicity hides a minefield of security issues. Session fixation, session hijacking, cross-site session leakage, CSRF attacks — each one can give an attacker full admin access to your CMS.

JekCMS implements seven layers of session security. Here is each one with the specific attack it prevents.

Layer 1: Secure Cookie Configuration

// Session.php constructor
ini_set('session.cookie_httponly', 1);   // Prevent JavaScript access
ini_set('session.cookie_secure', IS_PRODUCTION ? 1 : 0); // HTTPS only in production
ini_set('session.cookie_samesite', 'Lax');  // Prevent CSRF via cross-origin requests
ini_set('session.use_strict_mode', 1);   // Reject uninitialized session IDs
ini_set('session.use_only_cookies', 1);  // No session ID in URLs

Attack prevented: httponly stops XSS from stealing session cookies. secure prevents session cookies from being sent over unencrypted HTTP. samesite=Lax blocks most CSRF attacks by not sending cookies with cross-origin POST requests.

Layer 2: Session Fixation Prevention

Session fixation is when an attacker sets a known session ID before the victim logs in. After login, the attacker uses the same session ID to access the authenticated session.

// After successful login
session_regenerate_id(true); // Generate new ID, delete old session
$_SESSION['created_at'] = time();
$_SESSION['user_id'] = $user['id'];

session_regenerate_id(true) creates a new session ID and destroys the old session data. The attacker's known session ID becomes invalid.

Layer 3: CSRF Token Validation

// Generate token (once per session)
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// Validate on form submission
function validate_csrf(string $token): bool {
    return hash_equals($_SESSION['csrf_token'] ?? '', $token);
}

Every form includes a hidden csrf_token field. POST requests without a valid token are rejected with a 403.

Layer 4: Remember-Me Token Rotation

The "remember me" feature uses a separate token stored in a cookie and the database. Each time the token is used to authenticate, it is rotated — the old token is invalidated and a new one is issued:

public function validateRememberToken(string $token): ?array
{
    $record = $this->db->fetch(
        "SELECT * FROM remember_tokens WHERE token = ? AND expires_at > NOW()",
        [hash('sha256', $token)]
    );

    if ($record) {
        // Rotate: delete old, create new
        $this->db->delete('remember_tokens', 'id = ?', [$record['id']]);
        $newToken = $this->createRememberToken($record['user_id']);
        return ['user_id' => $record['user_id'], 'new_token' => $newToken];
    }

    return null;
}

Token rotation means a stolen remember-me cookie can only be used once. If the legitimate user visits the site before the attacker, the attacker's token is already invalid.

Layer 5: Multi-Site Session Isolation

JekCMS runs 12 sites on the same server. Without isolation, logging into Site A could give you access to Site B if they share the same PHP session storage. We prevent this with two mechanisms:

// 1. Unique session name per site
session_name('JEKCMS_' . md5(SITE_URL));

// 2. Site hash verification in session
$_SESSION['_site_hash'] = md5(SITE_URL);

// On every request
if ($_SESSION['_site_hash'] !== md5(SITE_URL)) {
    session_destroy();
    redirect(SITE_URL . '/admin/login.php');
}

Layer 6: Absolute Session Timeout

Sessions expire after 2 hours regardless of activity. This limits the window of opportunity for session hijacking:

$maxLifetime = 7200; // 2 hours
if (isset($_SESSION['created_at']) && (time() - $_SESSION['created_at']) > $maxLifetime) {
    session_destroy();
    redirect(SITE_URL . '/admin/login.php?expired=1');
}

Layer 7: IP Binding (Optional)

For high-security deployments, sessions can be bound to the client's IP address. If the IP changes mid-session, the session is invalidated. This is disabled by default because mobile users frequently change IPs, but it can be enabled in config.