Skip to content

Fix: PHP Session Not Working — $_SESSION Variables Lost Between Requests

FixDevs ·

Quick Answer

How to fix PHP session variables that don't persist between requests — session_start() placement, cookie settings, session storage, shared hosting, and session fixation security.

The Problem

PHP session variables set in one request are gone in the next:

// page1.php
session_start();
$_SESSION['user_id'] = 42;
echo "Session set: " . $_SESSION['user_id'];  // Outputs: Session set: 42

// page2.php
session_start();
echo "Session value: " . $_SESSION['user_id'];  // Outputs: Session value:  (empty)

Or session_start() throws a warning:

Warning: session_start(): Cannot start session when headers already sent

Or the session ID changes between requests, making session data inaccessible:

// Request 1
echo session_id();  // abc123

// Request 2
echo session_id();  // xyz789 — different ID, different session

Or sessions work locally but not on the production server:

Warning: session_start(): open(/tmp/sessions/sess_abc123, O_RDWR) failed: Permission denied

Why This Happens

PHP sessions work by:

  1. Generating a unique session ID
  2. Storing session data in a file (default) or other storage on the server
  3. Sending the session ID to the browser as a cookie (PHPSESSID)
  4. Reading the cookie on subsequent requests to retrieve the session data

The session breaks when any step in this chain fails:

  • session_start() called after output — if any HTML or whitespace is output before session_start(), the session cookie can’t be sent (headers already sent).
  • Cookie domain/path mismatch — the PHPSESSID cookie set for example.com isn’t sent to www.example.com (or vice versa), so the session ID isn’t transmitted.
  • Session file permission errors — the web server can’t read/write session files in the configured session.save_path.
  • Session garbage collection deletes filessession.gc_maxlifetime expires sessions sooner than expected.
  • session_start() not called — the most common mistake: accessing $_SESSION without calling session_start() first.
  • Multiple session_start() calls — calling session_start() multiple times on the same request can corrupt session state in older PHP versions.

Fix 1: Call session_start() Before Any Output

session_start() must be called before any HTML, echo, print, whitespace, or BOM characters:

<?php
// CORRECT — session_start() is the very first thing, no output before it
session_start();

$_SESSION['user_id'] = 42;
echo "Hello, user " . $_SESSION['user_id'];
?>
<?php
// WRONG — space or newline before opening <?php tag
// Even a blank line before <?php sends output
session_start();  // Warning: Cannot start session when headers already sent
<?php
// WRONG — output before session_start()
echo "Loading...";
session_start();   // Headers already sent — session cookie can't be set

Find what’s sending output early:

Warning: session_start(): Cannot start session when headers already sent in /var/www/html/page.php on line 5 (output started at /var/www/html/header.php:1)

The error message tells you exactly where output started (header.php:1). Common culprits:

  • A BOM (Byte Order Mark) in a file saved by some editors — invisible but counts as output
  • A trailing newline after ?> in an included file
  • echo or print before session_start()

Use output buffering as a temporary fix (not recommended long-term):

<?php
ob_start();   // Buffer all output — session_start() can send headers even after output
session_start();

echo "Some content";
ob_end_flush();

If the session cookie is set for the wrong domain or path, the browser won’t send it back:

<?php
// Configure session cookie settings BEFORE session_start()
session_set_cookie_params([
    'lifetime' => 86400,           // 1 day (0 = until browser closes)
    'path' => '/',                 // Available for entire domain
    'domain' => '.example.com',   // Leading dot = include subdomains
    'secure' => true,             // HTTPS only
    'httponly' => true,           // Not accessible via JavaScript
    'samesite' => 'Lax'          // CSRF protection
]);

session_start();

Or configure in php.ini:

; php.ini or .htaccess
session.cookie_lifetime = 86400
session.cookie_path = /
session.cookie_domain = .example.com
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax

Debugging cookie issues:

<?php
session_start();

// Check what session cookie was set
var_dump(session_get_cookie_params());
// Array: lifetime, path, domain, secure, httponly, samesite

// Check if browser sent the session cookie
echo "PHPSESSID from cookie: " . ($_COOKIE['PHPSESSID'] ?? 'not sent');

// Check current session ID
echo "Current session ID: " . session_id();

In browser DevTools → Application → Cookies, verify:

  • PHPSESSID cookie exists
  • Domain matches the current request domain
  • Path matches the request URL path
  • Expiry is in the future (not already expired)

Fix 3: Fix Session File Permissions

If PHP can’t write session files, sessions won’t persist:

# Check where PHP stores sessions
php -r "echo session_save_path();"
# Often: /tmp or /var/lib/php/sessions

# Check permissions
ls -la /var/lib/php/sessions/
# Should be: drwxrwx--- www-data www-data
# Or:        drwxr-x--- root www-data

Fix permission issues:

# Set ownership to the web server user (www-data for Nginx/Apache on Ubuntu)
chown www-data:www-data /var/lib/php/sessions/
chmod 750 /var/lib/php/sessions/

# Create the directory if it doesn't exist
mkdir -p /var/lib/php/sessions
chown www-data:www-data /var/lib/php/sessions
chmod 750 /var/lib/php/sessions

Set a custom session save path with correct permissions:

<?php
// Use a directory your app has write access to
ini_set('session.save_path', '/var/www/html/storage/sessions');

// Ensure the directory exists and is writable:
// mkdir -p /var/www/html/storage/sessions
// chmod 700 /var/www/html/storage/sessions

session_start();

On shared hosting — you often can’t modify /tmp permissions. Use the session.save_path to point to a directory within your web root that the web server can write:

ini_set('session.save_path', dirname(__DIR__) . '/sessions');

Fix 4: Fix Session Duration

Sessions expire due to session.gc_maxlifetime (default: 1440 seconds = 24 minutes) and cookie lifetime:

// Two settings control session lifetime:
// 1. session.gc_maxlifetime — how long session FILES are kept on server
// 2. session.cookie_lifetime — how long the cookie lives in the browser

// For a 30-day session:
ini_set('session.gc_maxlifetime', 60 * 60 * 24 * 30);  // 30 days in seconds

session_set_cookie_params([
    'lifetime' => 60 * 60 * 24 * 30,  // 30 days
    'path' => '/',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax'
]);

session_start();

Note: On shared hosting, multiple PHP applications may share the same session storage directory. Session garbage collection (gc_maxlifetime) is triggered probabilistically — your session files may be deleted by another application’s garbage collection using a shorter gc_maxlifetime. The fix is to use a separate session.save_path for each application.

Fix 5: Use Database Sessions for Multi-Server Setups

File-based sessions don’t work when your app runs on multiple servers (each server has its own filesystem, so sessions from server A aren’t available on server B):

// Custom session handler using PDO (database sessions)
class DatabaseSessionHandler implements SessionHandlerInterface
{
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    public function open(string $savePath, string $sessionName): bool
    {
        return true;
    }

    public function close(): bool
    {
        return true;
    }

    public function read(string $id): string|false
    {
        $stmt = $this->pdo->prepare('SELECT data FROM sessions WHERE id = ? AND expires > ?');
        $stmt->execute([$id, time()]);
        return $stmt->fetchColumn() ?: '';
    }

    public function write(string $id, string $data): bool
    {
        $expires = time() + (int) ini_get('session.gc_maxlifetime');
        $stmt = $this->pdo->prepare(
            'INSERT INTO sessions (id, data, expires) VALUES (?, ?, ?)
             ON DUPLICATE KEY UPDATE data = VALUES(data), expires = VALUES(expires)'
        );
        return $stmt->execute([$id, $data, $expires]);
    }

    public function destroy(string $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM sessions WHERE id = ?');
        return $stmt->execute([$id]);
    }

    public function gc(int $maxLifetime): int|false
    {
        $stmt = $this->pdo->prepare('DELETE FROM sessions WHERE expires < ?');
        $stmt->execute([time()]);
        return $stmt->rowCount();
    }
}

// Register the handler
$pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass');
$handler = new DatabaseSessionHandler($pdo);
session_set_save_handler($handler, true);
session_start();
-- Create the sessions table
CREATE TABLE sessions (
    id VARCHAR(128) NOT NULL PRIMARY KEY,
    data TEXT NOT NULL,
    expires INT UNSIGNED NOT NULL,
    INDEX idx_expires (expires)
);

Using Redis for sessions (faster than database):

// Requires php-redis extension
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', 'tcp://127.0.0.1:6379');

// With password and database:
ini_set('session.save_path', 'tcp://127.0.0.1:6379?auth=password&database=1');

session_start();

Fix 6: Secure Session Configuration

Misconfigured sessions are a common security vulnerability. Apply these settings in production:

<?php
// Security hardening before session_start()

// Prevent JavaScript access (XSS protection)
ini_set('session.cookie_httponly', 1);

// HTTPS only
ini_set('session.cookie_secure', 1);

// Strict mode — reject unrecognized session IDs (prevents session fixation)
ini_set('session.use_strict_mode', 1);

// Only use cookies (not URL parameters) for session IDs
ini_set('session.use_only_cookies', 1);
ini_set('session.use_trans_sid', 0);

// Regenerate session ID on privilege escalation (login)
session_start();

// After login — regenerate to prevent session fixation
function login(int $userId): void
{
    session_regenerate_id(true);  // true = delete old session file
    $_SESSION['user_id'] = $userId;
    $_SESSION['authenticated'] = true;
}

// On logout — destroy the session completely
function logout(): void
{
    $_SESSION = [];
    session_destroy();

    // Delete the cookie
    setcookie(session_name(), '', [
        'expires' => time() - 3600,
        'path' => '/',
        'secure' => true,
        'httponly' => true,
        'samesite' => 'Lax'
    ]);
}

Fix 7: Debug Session State

When sessions behave unexpectedly, inspect the session state directly:

<?php
session_start();

// Dump everything about the current session
echo "Session ID: " . session_id() . "\n";
echo "Session name: " . session_name() . "\n";
echo "Session status: " . session_status() . "\n";  // 2 = PHP_SESSION_ACTIVE
echo "Session data:\n";
var_dump($_SESSION);

// Check session file exists on disk (for file-based sessions)
$sessionFile = session_save_path() . '/sess_' . session_id();
echo "Session file: $sessionFile\n";
echo "File exists: " . (file_exists($sessionFile) ? 'yes' : 'no') . "\n";
echo "File contents:\n" . file_get_contents($sessionFile) . "\n";

Session status codes:

switch (session_status()) {
    case PHP_SESSION_DISABLED:  // 0 — sessions disabled in php.ini
        echo "Sessions are disabled";
        break;
    case PHP_SESSION_NONE:       // 1 — session_start() not called yet
        echo "No active session";
        break;
    case PHP_SESSION_ACTIVE:     // 2 — session is active
        echo "Session is active";
        break;
}

Prevent double session_start():

// Safe session_start() that won't trigger warnings on repeat calls
if (session_status() === PHP_SESSION_NONE) {
    session_start();
}

Still Not Working?

SameSite cookie issue — if your site makes cross-origin requests (e.g., API called from a different domain), samesite=Strict or samesite=Lax blocks the session cookie from being sent. Use samesite=None; Secure for cross-origin sessions. Note that SameSite=None requires Secure (HTTPS).

Docker or containerized environments — each container restart may use a different temp directory or lose session files. Mount a persistent volume for the session save path, or use Redis/database sessions.

PHP-FPM with multiple pools — if different PHP-FPM pools have different users, they may not be able to read each other’s session files. Ensure all pools share a common session save path with appropriate group permissions.

session.auto_start in php.ini — if session.auto_start = 1, sessions start automatically before your script runs. Calling session_start() again then triggers a warning. Check: ini_get('session.auto_start').

WordPress or framework conflicts — WordPress and many frameworks call session_start() themselves. If you’re calling it again in custom code, use if (session_status() === PHP_SESSION_NONE) session_start(); to avoid conflicts.

For related PHP and backend issues, see Fix: Node.js Heap Out of Memory and Fix: Express Async Error Not Caught.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles