Fix: PHP Session Not Working — $_SESSION Variables Lost Between Requests
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 sentOr 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 sessionOr sessions work locally but not on the production server:
Warning: session_start(): open(/tmp/sessions/sess_abc123, O_RDWR) failed: Permission deniedWhy This Happens
PHP sessions work by:
- Generating a unique session ID
- Storing session data in a file (default) or other storage on the server
- Sending the session ID to the browser as a cookie (
PHPSESSID) - 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 beforesession_start(), the session cookie can’t be sent (headers already sent).- Cookie domain/path mismatch — the
PHPSESSIDcookie set forexample.comisn’t sent towww.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 files —
session.gc_maxlifetimeexpires sessions sooner than expected. session_start()not called — the most common mistake: accessing$_SESSIONwithout callingsession_start()first.- Multiple
session_start()calls — callingsession_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 setFind 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 echoorprintbeforesession_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();Fix 2: Fix Cookie Domain and Path Settings
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 = LaxDebugging 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:
PHPSESSIDcookie 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-dataFix 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/sessionsSet 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 shortergc_maxlifetime. The fix is to use a separatesession.save_pathfor 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Better Auth Not Working — Login Failing, Session Null, or OAuth Callback Error
How to fix Better Auth issues — server and client setup, email/password and OAuth providers, session management, middleware protection, database adapters, and plugin configuration.
Fix: jose JWT Not Working — Token Verification Failing, Invalid Signature, or Key Import Errors
How to fix jose JWT issues — signing and verifying tokens with HS256 and RS256, JWK and JWKS key handling, token expiration, claims validation, and edge runtime compatibility.
Fix: PostgreSQL Row Level Security Not Working — Policy Not Applied, All Rows Visible, or Permission Denied
How to fix PostgreSQL Row Level Security (RLS) issues — enabling RLS, policy expressions, BYPASSRLS role, SET ROLE, current_user vs session_user, and Supabase auth.uid() patterns.
Fix: Express Rate Limit Not Working — express-rate-limit Requests Not Throttled
How to fix Express rate limiting not working — middleware order, trust proxy for reverse proxies, IP detection, store configuration, custom key generation, and bypassing issues.