Skip to content

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

FixDevs · (Updated: )

Part of:  PHP, Ruby & Other Languages

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 are a three-piece system: a unique session ID generated by the server, session data stored on the server (file, Redis, or database), and a cookie sent to the browser containing the ID. The browser sends the cookie back on each subsequent request, and PHP uses the ID to look up the stored data. Any failure in this chain results in lost session state — and each failure has its own characteristic symptom.

The most common failure is session_start() being called after the response body has begun. Even a single whitespace character, a BOM in a UTF-8 file, or a stray newline before <?php counts as output. Once HTTP headers are sent, the Set-Cookie header for the session can no longer be transmitted. The warning is logged, but the session may appear to “work” on the page that set it (because PHP holds the data in memory for the rest of the request) and only fail on the next page load. This bifurcated symptom often misleads debugging.

Cookie scope is the second tier. A PHPSESSID cookie set without a domain attribute defaults to the request hostname — so a session created on example.com is not sent to www.example.com. SameSite restrictions add a modern twist: SameSite=Strict blocks the cookie on any cross-origin navigation, including OAuth callbacks; SameSite=Lax blocks it on POST requests originating from another domain. Browsers also silently drop cookies marked Secure over HTTP.

Other causes:

  • Session file storage broken — wrong permissions on session.save_path, full disk, or a tmpfs mount that’s been remounted between requests.
  • Garbage collection too aggressivesession.gc_maxlifetime set lower than expected on shared hosting, deleting session files before users finish their visit.
  • Multiple PHP-FPM pools or load balancers without shared storage — server A writes the session file, server B can’t read it.
  • session.auto_start = 1 — sessions start before your code runs, so a later session_start() triggers warnings and may overwrite state.

In Production: Incident Lens

A broken session is invisible from server logs until users complain. PHP doesn’t log “this user lost their session” — it just hands them an empty $_SESSION array and waits for them to log in again. The user-facing symptom is dramatic: people get randomly logged out, shopping carts empty themselves, multi-step forms restart from page one. Support tickets pile up with reports like “I just logged in and now it says I’m logged out.”

How it surfaces: A deploy changes session.cookie_domain from blank to example.com (without leading dot), and suddenly users on www.example.com can’t log in. A nightly cron clears /tmp and wipes active session files. A new web server is added to the load balancer but uses local file-based sessions, so a user routed to the new server appears logged out. A reverse proxy strips the Secure flag mismatch when the upstream uses HTTP but the public site is HTTPS.

Blast radius: The entire auth flow. Login, profile pages, checkout, admin panels — anything gated by session state breaks. Even unauthenticated functionality like CSRF tokens stored in $_SESSION fails, blocking POST submissions on forms. If your application puts a flash-message system in $_SESSION, error notifications also disappear, masking other problems.

Monitoring signals:

  • Session start rate diverging from active user count: if session_start() is called far more often than your number of distinct active users, sessions are being recreated every request
  • Login success rate dropping with no corresponding spike in failed credentials
  • HTTP 302 redirects to /login increasing on previously authenticated routes
  • PHP error log entries for session_start(): Cannot start session when headers already sent or open(... sess_xxx, O_RDWR) failed: Permission denied
  • Cookie set/sent counters in the load balancer or CDN

Recovery sequence: Identify the symptom. If permissions failed, fix session.save_path ownership (chown www-data:www-data /var/lib/php/sessions). If output-before-session_start(), find the culprit file via the error log’s hint (output started at /path/file.php:N). If cookie domain is wrong, switch to a leading-dot domain (.example.com) to cover subdomains. For multi-server setups, immediately switch to Redis or database-backed sessions. As a stopgap, sticky sessions at the load balancer route users back to the same server.

Postmortem preventives: Use Redis or database sessions in any environment with more than one application server. Avoid storing sessions in /tmp on shared hosting. Add a sanity check at the top of every request: log when a request arrives with a session cookie but session_start() produces empty state — that’s a signal of storage corruption. Configure session-related security flags (HttpOnly, Secure, SameSite) via php.ini, not per-script, so the settings are consistent across deploys.

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.

session_write_close() left open during long requests — by default, PHP locks the session file for the duration of a request. If two AJAX requests fire simultaneously from the same user, the second request blocks waiting for the first. Long-polling or streaming endpoints can starve every other request from the same session. Call session_write_close() right after you finish writing to $_SESSION to release the lock early.

Nginx fastcgi_pass with broken SCRIPT_FILENAME — if Nginx passes a wrong path to PHP-FPM, the session save logic still runs, but the path used to compute the session file may differ between requests, causing apparent session loss. Verify SCRIPT_FILENAME is consistent in fastcgi_params.

Cloudflare or CDN stripping cookies — some CDN cache configurations strip Set-Cookie headers from cached responses, preventing the session cookie from ever reaching the browser. Add a cache bypass rule for any path that calls session_start(), or move sessions to a header-based scheme that bypasses caching layers.

For related PHP and infrastructure issues, see Fix: PHP Fatal Error Allowed Memory Size, Fix: PHP Undefined Array Key, Fix: Redis Connection Refused, and Fix: Nginx 502 Bad Gateway.

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