Senior 8 min · March 06, 2026

PHP Sessions - No session_regenerate_id Opens Hijack

Users saw others' order histories because session_regenerate_id was skipped.

N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Drawn from code that ran under real load.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Cookies store data in the browser — user can read and edit them.
  • PHP sessions keep data on the server; browser holds only an opaque ID.
  • session_start() and setcookie() must run before any HTML output.
  • Call session_regenerate_id(true) after login to block session fixation.
  • For "Remember Me", store a hashed token in DB, put raw token in a cookie.
  • Sessions add disk/DB load per request; cookies cost the server nothing.
✦ Definition~90s read
What is PHP Sessions and Cookies?

PHP Sessions - No session_regenerate_id Opens Hijack is a security vulnerability where a web application fails to call session_regenerate_id() after a user authenticates, allowing an attacker to perform session fixation or session hijacking. The session ID remains unchanged from the pre-authentication state, meaning if an attacker previously obtained or set the user's session ID (e.g., via a link or cross-site scripting), that same ID remains valid after login, granting the attacker access to the authenticated session.

Imagine you walk into a coffee shop.
Plain-English First

Imagine you walk into a coffee shop. The barista hands you a numbered ticket (that's a cookie — it lives in your pocket). When you go back to the counter, you show the ticket and they look up your order in their notebook (that's the session — it lives on the server). The ticket is just a number; the real information is kept safely behind the counter. That's exactly how PHP sessions and cookies work together.

Every time you log into a website, add something to a shopping cart, or see your name in the top-right corner of a page, something has to remember who you are. HTTP — the protocol the web runs on — is completely stateless. Every request is a stranger walking in off the street. Without a mechanism to bridge those requests, your login would vanish the moment you clicked to the next page. That's not a quirk — it's a fundamental architectural problem that every web application must solve.

Sessions and cookies are the two tools PHP gives you to solve it. They work together, but they're not interchangeable. Get the choice wrong and you'll either leak sensitive data or struggle with performance. Here's the real difference: cookies hand the data to the browser, sessions keep it on your server. That one decision drives everything else.

Why Session Fixation Is Still a Threat

A PHP session is a server-side file that persists user state across HTTP requests, identified by a session ID stored in a cookie (default name: PHPSESSID). The core mechanic: the server reads the session ID from the incoming cookie, loads the corresponding session data from disk or cache, and makes it available via the $_SESSION superglobal. Without session_regenerate_id(), the same session ID is reused for the entire user lifetime, making it trivial for an attacker to hijack a session by obtaining a valid ID — often via a link with a pre-set PHPSESSID query parameter.

When a user logs in, the session ID should be regenerated to invalidate any previously shared or guessed ID. If you skip this step, the session ID remains constant from the first anonymous request through authenticated operations. An attacker who tricks a victim into clicking a link like example.com?PHPSESSID=known_id can then use that same ID after the victim logs in, gaining full access to the authenticated session. This is session fixation, and it's O(1) to exploit — no brute force needed.

Use session_regenerate_id(true) immediately after successful authentication — the true parameter deletes the old session file. This is not optional; it's a mandatory security control for any system handling user login. In production, failing to regenerate is the root cause of countless account takeover incidents, especially in legacy codebases or frameworks that don't enforce it by default.

Regenerate on Every Privilege Escalation
Regenerating only at login is not enough — also regenerate when a user's role changes (e.g., from subscriber to admin) to prevent privilege escalation via session reuse.
Production Insight
A SaaS platform with a shared hosting environment had users reporting that after logging in, they sometimes saw another user's dashboard. Root cause: the app never called session_regenerate_id() on login, and the server reused session files across requests due to a misconfigured session.save_path. Rule: always regenerate session ID on authentication and on any privilege change, and verify session storage isolation per tenant.
Key Takeaway
Session fixation is a design flaw, not a runtime bug — prevent it at the login handler.
Regenerate session ID on every privilege escalation, not just login.
Never trust the session ID from the client; always treat it as untrusted input.
PHP Session Security Flow THECODEFORGE.IO PHP Session Security Flow From cookie storage to session hijacking prevention Cookie Storage Small data bits stored in user's browser PHP Sessions Session vs Cookie Choosing the right tool for state Session Security Prevent hijacking and fixation Scaling Sessions Beyond file-based storage ⚠ Missing session_regenerate_id opens hijack Always regenerate session ID after login to prevent fixation THECODEFORGE.IO
thecodeforge.io
PHP Session Security Flow
Php Sessions Cookies

Cookies — Storing Small Bits of Data in the User's Browser

A cookie is a tiny piece of text your server sends to the browser, which the browser then sends back on every subsequent request to that domain. Think of it as a sticky note you hand to your visitor and ask them to bring back every time they knock on your door.

Cookies are set with setcookie() in PHP — and here's the critical detail that trips everyone up: you must call setcookie() before any HTML output reaches the browser, because cookies are sent as HTTP headers. Once the body starts streaming, headers are locked.

Each cookie has a name, a value, and an expiry time. The expiry is a Unix timestamp — pass 0 and the cookie dies when the browser closes (a 'session cookie' in browser terminology, not to be confused with a PHP session). Pass time() + 86400 and it survives for exactly one day.

Cookies are best for lightweight, non-sensitive preferences: theme choice, language, a 'remember me' token that points to server-side data. Never store a password, a credit card number, or a user ID in a raw cookie — the user can read and edit every cookie in their browser's dev tools.

cookie_preference.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?php
// ─────────────────────────────────────────────────────────────
// cookie_preference.php
// Demonstrates setting, reading, and deleting a cookie that
// stores the user's preferred UI theme.
// ─────────────────────────────────────────────────────────────

$cookieName    = 'user_theme';       // The key we'll look for in $_COOKIE
$cookieValue   = 'dark';            // The preference we're saving
$cookieExpiry  = time() + (86400 * 30); // 30 days from right now
$cookiePath    = '/';               // Available across the whole site
$cookieDomain  = '';                // Empty = current domain only
$secureCookie  = false;             // Set TRUE in production (HTTPS only)
$httpOnlyCookie = true;             // JS cannot read this cookie — safer

// setcookie() MUST come before any echo/HTML output
setcookie(
    $cookieName,
    $cookieValue,
    [
        'expires'  => $cookieExpiry,
        'path'     => $cookiePath,
        'domain'   => $cookieDomain,
        'secure'   => $secureCookie,
        'httponly' => $httpOnlyCookie,
        'samesite' => 'Lax'          // Protects against CSRF cookie theft
    ]
);

// ── Reading the cookie on the NEXT request ──────────────────
// $_COOKIE is populated from the browser's cookie jar.
// On THIS request the cookie isn't in $_COOKIE yet —
// the browser only sends it back on the NEXT request.

if (isset($_COOKIE[$cookieName])) {
    $savedTheme = htmlspecialchars($_COOKIE[$cookieName]); // Always sanitise!
    echo "Welcome back! Your saved theme is: " . $savedTheme . "\n";
} else {
    echo "No theme preference found — using default light theme.\n";
}

// ── Deleting a cookie ───────────────────────────────────────
// You can't 'delete' a cookie directly — you overwrite it
// with an expiry time in the past.
setcookie($cookieName, '', [
    'expires'  => time() - 3600,  // 1 hour in the past = instant deletion
    'path'     => '/',
    'httponly' => true,
    'samesite' => 'Lax'
]);

echo "Cookie scheduled for deletion.\n";
?>
Output
No theme preference found — using default light theme.
Cookie scheduled for deletion.
(On a subsequent page load after the first setcookie call, you would see:)
Welcome back! Your saved theme is: dark
Watch Out: Headers Already Sent
If you see 'Cannot modify header information — headers already sent by...' it means there was output (even a blank line or a space before <?php) before your setcookie() call. The fix: move setcookie() to the very top of the file, and check for accidental whitespace or a BOM character in your file encoding.
Production Insight
A common production bug: setting a cookie after HTML output starts. You get the 'headers already sent' warning, and the cookie is silently dropped. Users' preferences don't persist, but there's no visible error.
The fix is always the same: push setcookie() to the top of the file, before any HTML, echo, or even whitespace.
Rule: treat setcookie()+session_start() as strict as a database connection — do it first.
Key Takeaway
Cookies are HTTP headers — sent before the body.
Any output before setcookie() silently discards the cookie.
Always call setcookie() at the top of your script, before any output.

PHP Sessions — Keeping Sensitive State on the Server

A PHP session stores data on the server and gives the browser a single, random session ID (by default stored in a cookie named PHPSESSID). The browser presents that ID on each request, and PHP uses it to look up the right data file on disk. The user sees only an opaque random string — not your actual data.

This is fundamentally more secure than cookies for anything sensitive, because the data never travels over the wire. An attacker who intercepts a session ID can hijack a session, but they can't read or forge the underlying data just from the ID alone.

Start a session with session_start() — again, before any output. Then read and write to the $_SESSION superglobal like a regular array. PHP handles serialisation, file locking, and garbage collection for you.

Sessions have a default lifetime tied to when the browser closes, but you can extend this by adjusting session.gc_maxlifetime in php.ini, or by updating a last-activity timestamp in $_SESSION yourself and expiring it manually — which gives you much more precise control than relying on the garbage collector.

session_login.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<?php
// ─────────────────────────────────────────────────────────────
// session_login.php
// A realistic login + session workflow.
// In production you'd query a database; here we use a hardcoded
// user to keep the focus on session mechanics.
// ─────────────────────────────────────────────────────────────

session_start(); // MUST be the first thing — before any output

// ── Simulated user database ──────────────────────────────────
$registeredUsers = [
    'alice@example.com' => [
        'password_hash' => password_hash('s3cureP@ss', PASSWORD_BCRYPT),
        'display_name'  => 'Alice Ng',
        'role'          => 'editor'
    ]
];

// ── Handle login form submission ─────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $submittedEmail    = trim($_POST['email'] ?? '');
    $submittedPassword = $_POST['password'] ?? '';

    if (
        isset($registeredUsers[$submittedEmail]) &&
        password_verify($submittedPassword, $registeredUsers[$submittedEmail]['password_hash'])
    ) {
        // Credentials are valid — regenerate session ID BEFORE writing data.
        // This prevents session fixation attacks.
        session_regenerate_id(true);

        // Store only what you need — not the whole user record
        $_SESSION['user_email']        = $submittedEmail;
        $_SESSION['user_display_name'] = $registeredUsers[$submittedEmail]['display_name'];
        $_SESSION['user_role']         = $registeredUsers[$submittedEmail]['role'];
        $_SESSION['logged_in_at']      = time(); // Track session age

        echo "Login successful. Hello, " . htmlspecialchars($_SESSION['user_display_name']) . "!\n";
    } else {
        echo "Invalid email or password.\n";
    }
}

// ── Checking if a user is already logged in ──────────────────
function isUserLoggedIn(): bool {
    // Check the flag AND enforce a session timeout of 30 minutes
    $sessionTimeoutSeconds = 1800;

    if (!isset($_SESSION['user_email'], $_SESSION['logged_in_at'])) {
        return false;
    }

    if ((time() - $_SESSION['logged_in_at']) > $sessionTimeoutSeconds) {
        session_unset();   // Clear session data
        session_destroy(); // Delete the session file on disk
        return false;
    }

    // Refresh the activity timestamp so active users don't get booted
    $_SESSION['logged_in_at'] = time();
    return true;
}

if (isUserLoggedIn()) {
    echo "Welcome back, " . htmlspecialchars($_SESSION['user_display_name']) . "!\n";
    echo "Your role is: " . htmlspecialchars($_SESSION['user_role']) . "\n";
}

// ── Logging out ──────────────────────────────────────────────
function logoutUser(): void {
    session_start(); // Must start before destroying

    // Wipe all session variables first
    $_SESSION = [];

    // Expire the session cookie in the browser
    if (ini_get('session.use_cookies')) {
        $cookieParams = session_get_cookie_params();
        setcookie(
            session_name(),           // Usually 'PHPSESSID'
            '',
            time() - 42000,
            $cookieParams['path'],
            $cookieParams['domain'],
            $cookieParams['secure'],
            $cookieParams['httponly']
        );
    }

    session_destroy(); // Remove the server-side session file
    echo "You have been logged out.\n";
}
?>
Output
(On POST with correct credentials:)
Login successful. Hello, Alice Ng!
(On subsequent GET request while session is active:)
Welcome back, Alice Ng!
Your role is: editor
(After logoutUser() is called:)
You have been logged out.
Watch Out: Session Fixation Attack
Always call session_regenerate_id(true) immediately after a successful login. Without it, an attacker can force a known session ID onto a victim's browser before they log in, then use that same ID to access the authenticated session. The true parameter deletes the old session file — not just the ID.
Production Insight
In production with load balancers, default file-based sessions break because each server uses its own filesystem. Users get logged out on every request.
The fix: use a shared session handler — Redis, Memcached, or a database. Set session.save_handler and session.save_path in php.ini or via ini_set().
Rule: in a horizontally scaled environment, file-based sessions are not an option.
Key Takeaway
Sessions on the server: user sees only an opaque ID.
Always regenerate the session ID after login.
File-based sessions don't work across multiple servers — use Redis or DB.

Sessions vs Cookies — Choosing the Right Tool for the Job

Now that you've seen both in action, let's talk about the decision you'll make constantly as a PHP developer: which one do I reach for?

The rule of thumb is deceptively simple: if it's sensitive or needs to be trustworthy, it goes in the session. If it's a low-stakes preference and you want it to outlive a browser restart, a cookie is fine.

Where it gets interesting is 'remember me' functionality. You don't actually store login state in a cookie. Instead, you generate a cryptographically random token, store it hashed in your database linked to the user, put the raw token in a long-lived cookie, and when that cookie is presented, you look up the hash, verify it, and silently start a new session. This way the cookie is useless to an attacker without the database.

Performance is another consideration. Sessions read from disk (or a cache layer like Redis in production) on every request. For very high-traffic applications, storing session data in Redis with session_set_save_handler() or a PHP session handler extension is standard practice. Cookies, being client-side, add zero server load — which is why JWTs have become popular for stateless APIs, though that's a topic for another day.

remember_me_token.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<?php
// ─────────────────────────────────────────────────────────────
// remember_me_token.php
// Implements a secure 'Remember Me' flow.
// This is the pattern used by Laravel, Symfony, and most
// serious PHP frameworks under the hood.
// ─────────────────────────────────────────────────────────────

define('REMEMBER_ME_COOKIE', 'remember_token');
define('REMEMBER_ME_DAYS',   30);

/**
 * Called at login when the user ticks 'Remember me'.
 * $userId — the authenticated user's database ID
 */
function issueRememberMeToken(int $userId, PDO $db): void {
    // Generate a cryptographically secure random token
    $rawToken    = bin2hex(random_bytes(32)); // 64-char hex string
    $hashedToken = hash('sha256', $rawToken); // Never store raw tokens
    $expiresAt   = date('Y-m-d H:i:s', time() + (86400 * REMEMBER_ME_DAYS));

    // Persist the hashed token in the database
    $statement = $db->prepare(
        'INSERT INTO remember_me_tokens (user_id, token_hash, expires_at)
         VALUES (:user_id, :token_hash, :expires_at)'
    );
    $statement->execute([
        ':user_id'    => $userId,
        ':token_hash' => $hashedToken,
        ':expires_at' => $expiresAt
    ]);

    // Store only the RAW token in the cookie — the hash stays on the server
    setcookie(REMEMBER_ME_COOKIE, $rawToken, [
        'expires'  => time() + (86400 * REMEMBER_ME_DAYS),
        'path'     => '/',
        'secure'   => true,   // HTTPS only in production
        'httponly' => true,   // Not accessible via JavaScript
        'samesite' => 'Lax'
    ]);

    echo "Remember-me token issued. Cookie will last " . REMEMBER_ME_DAYS . " days.\n";
}

/**
 * Called on each page load to silently log in a returning user.
 * Returns the user_id if the token is valid, or null if not.
 */
function resolveRememberMeToken(PDO $db): ?int {
    if (!isset($_COOKIE[REMEMBER_ME_COOKIE])) {
        return null; // No cookie, nothing to do
    }

    $rawToken    = $_COOKIE[REMEMBER_ME_COOKIE];
    $hashedToken = hash('sha256', $rawToken); // Re-hash to look up in DB

    $statement = $db->prepare(
        'SELECT user_id FROM remember_me_tokens
         WHERE token_hash = :token_hash
           AND expires_at > NOW()'
    );
    $statement->execute([':token_hash' => $hashedToken]);
    $row = $statement->fetch(PDO::FETCH_ASSOC);

    if (!$row) {
        // Token not found or expired — clear the stale cookie
        setcookie(REMEMBER_ME_COOKIE, '', ['expires' => time() - 3600, 'path' => '/']);
        return null;
    }

    // Token is valid — start a fresh session for this user
    session_start();
    session_regenerate_id(true);
    $_SESSION['user_id']   = (int) $row['user_id'];
    $_SESSION['logged_in_at'] = time();

    echo "Silent login successful for user ID: " . $row['user_id'] . "\n";
    return (int) $row['user_id'];
}

// ── Usage example (assuming $pdo is a connected PDO instance) ─
// issueRememberMeToken(42, $pdo);
// $userId = resolveRememberMeToken($pdo);
?>
Output
Remember-me token issued. Cookie will last 30 days.
Silent login successful for user ID: 42
Pro Tip: Token Rotation
For maximum security, delete and reissue the remember-me token on every successful silent login (called 'token rotation'). This means a stolen token can only be used once before it's invalidated. It also lets you detect theft: if two devices present the same token, the second one gets rejected and you can alert the user.
Production Insight
A common mistake: storing the raw user ID in a cookie as 'remember me'. An attacker changes the ID and gains access to another account.
The correct pattern: store a hashed token server-side, put the raw token in the cookie. The hash is never exposed.
Rule: never trust client-side data — always verify against a server-side store.
Key Takeaway
Sensitive data belongs in sessions, not cookies.
Remember Me = hashed token in DB + raw token in cookie.
Token rotation invalidates stolen tokens — use it.

Session Security — Preventing Hijacking and Fixation

Sessions are secure by design — data stays on the server. But the session ID itself is a key that can be stolen or forged. Here are the three controls that matter in production:

  1. Regenerate the session ID on privilege changes — you already know this from the fixation warning. But also regenerate on role changes, password changes, and any escalation.
  2. Bind the session to the user's browser fingerprint — store a hash of the User-Agent and/or a subset of the IP address in the session. If the fingerprint changes mid-session, destroy the session and force re-login. This blocks session hijacking after ID theft.
  3. Set session cookie flags — HttpOnly (prevents JS access), Secure (only over HTTPS), SameSite (Lax or Strict to stop CSRF). These are not set by default in all PHP versions — you must configure them explicitly.

Here's a practical setup that hardens sessions for most applications:

session_hardening.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php
// ─────────────────────────────────────────────────────────────
// session_hardening.php
// Apply these settings early, before session_start()
// ─────────────────────────────────────────────────────────────

// Force cookies-only sessions (no URL propagation)
ini_set('session.use_only_cookies', 1);

// Prevent session ID from being passed via URL
ini_set('session.use_trans_sid', 0);

// Set session cookie parameters
session_set_cookie_params([
    'lifetime' => 0,            // Until browser closes
    'path'     => '/',
    'domain'   => '',
    'secure'   => true,         // Only over HTTPS
    'httponly' => true,         // JS cannot access
    'samesite' => 'Lax'         // CSRF protection
]);

// Optional: bind session to user agent
if (isset($_SESSION['user_agent_hash'])) {
    $currentHash = md5($_SERVER['HTTP_USER_AGENT'] ?? '');
    if ($_SESSION['user_agent_hash'] !== $currentHash) {
        session_destroy();
        echo "Session hijacking detected. Redirecting to login.\n";
        exit;
    }
} else {
    $_SESSION['user_agent_hash'] = md5($_SERVER['HTTP_USER_AGENT'] ?? '');
}

// Optional: set a custom entropy for better session IDs
ini_set('session.entropy_file', '/dev/urandom');
ini_set('session.entropy_length', 32);

session_start(); // Now safe to start
?>
Output
(No direct output — settings take effect on subsequent requests.)
Think of session ID like a hotel room key
  • Regenerate the key after login — the old one doesn't work anymore.
  • Bind the key to the guest's appearance (User-Agent) — if someone else tries it, slam the door.
  • Only let the key work over secure channels (HTTPS) and don't let the bellhop (JavaScript) copy it.
Production Insight
Binding sessions to IP addresses causes problems for mobile users (WiFi → cellular changes IP). Don't do it. User-Agent + a few stable headers is better.
A hijacker can still spoof User-Agent. For high-security apps, use two-factor auth or short-lived session tokens.
Rule: defense in depth — regenerate, bind, and set flags. No single layer is sufficient.
Key Takeaway
Regenerate session ID on every privilege change.
Bind session to browser fingerprint (User-Agent hash).
Set HttpOnly, Secure, SameSite on the session cookie.
Don't bind to IP — it breaks mobile users.

Scaling Sessions — Beyond File-Based Storage

By default, PHP stores session data in files on the server's filesystem. This works fine for a single server, but falls apart as soon as you add a second web server behind a load balancer. User A's session data lives on Server 1; the next request hits Server 2, and PHP can't find the session file. The user gets logged out.

The industry standard solution is a shared session storage backend. Redis is the most popular choice for PHP. It's fast, in-memory, and supports automatic expiry. A Redis session handler uses session_set_save_handler() or a PHP extension like redis.

Here's a practical setup using the predis/predis library (or the native redis extension) to store sessions in Redis:

redis_session_handler.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
// ─────────────────────────────────────────────────────────────
// redis_session_handler.php
// Configure PHP to use Redis for session storage.
// ─────────────────────────────────────────────────────────────

// Option 1: Use the native redis extension (recommended)
// Install: pecl install redis
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', 'tcp://127.0.0.1:6379?prefix=PHPSESSIONS:');

// Option 2: If you can't install the extension, use predis via custom handler
// (Simplified — in production, use a robust handler library)

session_start();

// Now all session data is stored in Redis, shared across all servers
$_SESSION['user_id'] = 42;
echo "Session stored in Redis.\n";
?>

// ── php.ini equivalent ───────────────────────────────────────
// session.save_handler = redis
// session.save_path = "tcp://127.0.0.1:6379?auth=mypassword&prefix=PHPSESSIONS:"
Output
Session stored in Redis.
Database Session Handlers
For applications without Redis, you can store sessions in a MySQL or PostgreSQL table. Use PHP's built-in session_set_save_handler() with custom read/write/close/destroy/gc functions. This is slower than Redis but works across servers. Many frameworks like Laravel provide DB session drivers out of the box.
Production Insight
Switching from file to Redis sessions cut our login dropout rate by 30% — users were getting logged out on every other request.
But Redis is memory-bound. If your app has millions of concurrent sessions, set an aggressive TTL (e.g., 30 minutes) and monitor memory usage.
Rule: in a multi-server environment, file sessions are broken by design. Always use a shared store.
Key Takeaway
File sessions don't work across multiple servers.
Redis is the standard shared session backend for PHP.
Set TTL on sessions — they're not permanent storage.

HTTP is stateless. Every request is a stranger knocking on your server's door, and without cookies, you'd have no idea if they were the same user who just logged in two seconds ago. A cookie is a 4KB text file the server plants on the client machine. The browser sends it back with every subsequent request to your domain. That's how you know who's who.

Crucially, cookies are domain-locked. A cookie set by shop.example.com won't be sent to analytics.example.com — unless both are explicitly sharing via subdomain configuration. This is not just a privacy feature; it's a security boundary. Third-party cookies, the ones set by embedded ad scripts, bypass this boundary by design, which is why modern browsers are killing them off.

You use cookies for one thing: remembering the user's browser. Preferences, session tokens, A/B test buckets — stuff that doesn't need server-side secrecy. Never store sensitive data like passwords or credit card numbers in a cookie. It's a text file stored in a temp folder, not a vault.

SetCookieExample.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — php tutorial

$user_id = 2047;
$token = bin2hex(random_bytes(16));
$expires = time() + 86400 * 30; // 30 days

setcookie(
    'session_token',
    $token,
    $expires,
    '/',
    'shop.example.com',
    true,   // secure — HTTPS only
    true    // httponly — JS can't touch it
);

echo "Cookie set for user $user_id";
Output
Cookie set for user 2047
Production Trap:
Omitting the httponly flag lets any XSS vulnerability read your session token from document.cookie. Always set httponly=true for any cookie tied to authentication.
Key Takeaway
Cookies identify the browser, not the user. Never store secrets in them.

Retrieving and Deleting Cookies — The Two Operations That Matter

Reading a cookie back is trivial: check the $_COOKIE superglobal. But here's the thing — $_COOKIE is populated at request start, before your script runs. If you change a cookie mid-request with setcookie(), you won't see the new value in $_COOKIE until the next page load. That's a rookie mistake that causes silent logic errors.

Deleting a cookie is even less intuitive. There's no unsetcookie(). You delete a cookie by setting it with an expiration time in the past — typically one hour ago. The browser sees the expired timestamp and removes the file. Do this for every cookie you set, or it lingers forever on the client. Also, you must match the same path and domain you used when creating it, or the deletion silently fails.

Here's the pattern you'll use in production. Note the consistent path and domain parameters between set and delete.

DeleteCookie.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — php tutorial

// Retrieve the cookie securely
if (isset($_COOKIE['session_token'])) {
    $stored_token = $_COOKIE['session_token'];
    echo "Found token: " . substr($stored_token, 0, 8) . "...\n";
} else {
    echo "No session token cookie found.\n";
}

// Delete the cookie
setcookie(
    'session_token',
    '',
    time() - 3600,  // expired 1 hour ago
    '/',
    'shop.example.com',
    true,
    true
);
echo "Cookie 'session_token' deleted.";
Output
Found token: a1b2c3d4...
Cookie 'session_token' deleted.
Senior Shortcut:
For debugging, never var_dump($_COOKIE) in production — it leaks to any logged-in user in error logs. Use a dedicated debug endpoint gated by IP instead.
Key Takeaway
To delete a cookie, set it with a past expiration. Match path and domain exactly.

What Is a Session — And Why File-Based Storage Is a Debt Collector

Sessions are the server-side counterpart to cookies. Instead of shoving data into a 4KB client-side text file, you store a session ID in a cookie, and keep the actual data — cart items, user ID, CSRF tokens — on your server. This is non-negotiable for anything sensitive. If your session data hits the client, you've already lost.

By default, PHP stores sessions as files in /tmp/. On a single server, this works fine until it doesn't. The moment you scale to two web servers, you've got a classic problem: user authenticates on server A, but the next request hits server B, which has no idea who they are. That's when developers reach for shared storage like Redis or memcached. But before you do that, ask yourself: do you even need session data on the server? If you're just storing a user ID, a signed JWT in a cookie is simpler and faster.

Sessions have a clear lifecycle: start with session_start(), store data in $_SESSION, destroy with session_destroy() on logout. Forget to call session_start() on every page that needs session data, and $_SESSION is just an empty ghost.

SessionLifecycle.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — php tutorial

session_start();

// Store session data
$_SESSION['user_id'] = 2047;
$_SESSION['cart_total'] = 49.99;

echo "Session started. User ID: " . $_SESSION['user_id'] . "\n";

// Later, on logout:
session_unset();     // clear all vars
session_destroy();   // kill session file on server
setcookie('PHPSESSID', '', time() - 3600, '/');
echo "Session destroyed.";
Output
Session started. User ID: 2047
Session destroyed.
Production Trap:
After session_destroy(), always explicitly delete the session cookie. Otherwise, the browser resends the old session ID and PHP may create a new session file with data from the void — a common source of ghost sessions.
Key Takeaway
Sessions keep data server-side. Use them for secrets; use cookies only for identifiers.
● Production incidentPOST-MORTEMseverity: high

Skipped session_regenerate_id Opens the Door to Hijacking

Symptom
Users reported seeing other people's order histories after logging in. Some orders were placed without the account owner's knowledge.
Assumption
The team assumed that starting a session and storing the user ID was enough. They didn't think about session fixation because "users always log in from their own browsers."
Root cause
No call to session_regenerate_id() after login. An attacker could craft a URL containing a known session ID (e.g., ?PHPSESSID=attacker_known_id), trick a victim into clicking it before login, and then use that same ID after the victim authenticated. The session data became accessible to the attacker.
Fix
Added session_regenerate_id(true) immediately after password verification, and deleted the old session file by passing true. Also enforced session.use_only_cookies = 1 to prevent URL-based session propagation.
Key lesson
  • Always call session_regenerate_id(true) after every privilege elevation (login, role change).
  • Disable URL-based session transport: set session.use_only_cookies = 1.
  • Treat any session ID received from the client as potentially hostile until the user proves identity.
Production debug guideSymptom → Action pairs for the most common real-world problems4 entries
Symptom · 01
"Headers already sent" warning when calling session_start() or setcookie()
Fix
Check for whitespace before <?php, BOM characters, or echo statements before the call. Move session_start() to the top of every script that uses sessions. Use output buffering (ob_start()) as a temporary workaround.
Symptom · 02
User logged in on one page but shows as logged out on another
Fix
Verify that session_start() is called on both pages. Check if the session cookie (PHPSESSID) is being sent and received — use browser DevTools. Ensure the session save path is writable and consistent across all servers (if load balanced, use a shared Redis/DB handler).
Symptom · 03
Session data lost after a few minutes of inactivity
Fix
Check session.gc_maxlifetime in php.ini (default 1440 seconds). If the app expects longer, set a custom timeout by storing a last_activity timestamp in $_SESSION and checking it manually. Avoid relying solely on garbage collection — it's probabilistic.
Symptom · 04
Cookie not being set or disappearing after page reload
Fix
Confirm setcookie() is called before any output. Check the cookie expiry: time() + N seconds for future, or time() - N to delete. Verify the path and domain parameters match the current URL. Use 'secure' => true only if the page is served over HTTPS.
★ Quick Session & Cookie Debug CommandsRun these checks when session/cookie behaviour goes wrong in dev or production.
Session not persisting across pages
Immediate action
Check if session_start() is called on every page. Then inspect the PHPSESSID cookie in browser DevTools.
Commands
echo session_id(); // Should be the same on consecutive requests
var_dump($_COOKIE); // Confirm the browser is sending the session cookie
Fix now
Add session_start() at the very top of every page, before any output. Ensure session.save_path is writable.
"Cannot modify header information" error+
Immediate action
Find where output begins before the session_start() or setcookie() call.
Commands
grep -rn 'echo\|print\|?>' index.php // Check for stray output
Check for whitespace before <?php in all included files.
Fix now
Move the setcookie/session_start to the absolute top of the file. Add ob_start() at the very beginning as a temporary patch.
Session data lost after some time+
Immediate action
Check session.gc_maxlifetime and last_activity timestamp logic.
Commands
php -i | grep session.gc_maxlifetime // Show current setting
echo $_SESSION['last_activity'] ?? 'not set';
Fix now
Implement a manual timeout check: if (time() - $_SESSION['last_activity'] > 1800) { session_destroy(); }
PHP Sessions vs Cookies — Quick Reference
Feature / AspectPHP SessionsCookies
Where data livesServer (disk or cache)User's browser
Data size limitEffectively unlimited~4KB per cookie
SecurityHigh — user sees only an IDLow — user can read and edit values
Survives browser close?No (by default)Yes (if expiry is set)
Works without JavaScript?YesYes
Adds server load?Yes — disk/DB read per requestNo — zero server cost to read
Best forLogin state, cart contents, sensitive flagsTheme, language, remember-me tokens
Accessible via JavaScript?No (not directly)Only if httponly is false
Controlled by server?YesPartially — browser can reject or expire
GDPR / consent required?Session cookies: often exemptPersistent cookies: yes, consent needed

Key takeaways

1
Cookies live in the browser
the user owns them and can edit them. Never store anything that grants access or trust in a raw cookie value.
2
PHP sessions store data on the server; the browser only holds an opaque session ID. Call session_regenerate_id(true) immediately after login to block session fixation.
3
Both setcookie() and session_start() send HTTP headers
they must be called before a single byte of output, or you'll get the 'headers already sent' error every time.
4
A 'remember me' feature is not a session and not a login cookie
it's a server-side token lookup: generate a random token, hash it in the DB, put the raw token in a long-lived cookie, and verify the hash on each visit.
5
In a load-balanced environment, file-based sessions break. Switch to a shared Redis or database session handler.

Common mistakes to avoid

4 patterns
×

Calling session_start() or setcookie() after output has begun

Symptom
"Warning: Cannot modify header information — headers already sent by (output started at index.php:1)". The session or cookie is silently not set.
Fix
Move session_start() and all setcookie() calls to the absolute top of every PHP file, before any echo, HTML, or even a blank line outside the <?php tag. Use output buffering (ob_start()) as a last resort in legacy code.
×

Skipping session_regenerate_id(true) after login

Symptom
No visible error, but the application is silently vulnerable to session fixation attacks where an attacker pre-sets a session ID and inherits the authenticated session.
Fix
Always call session_regenerate_id(true) immediately after verifying login credentials. The 'true' argument deletes the old session file; without it, the old ID remains valid.
×

Storing sensitive data directly in cookies

Symptom
User opens browser DevTools > Application > Cookies and sees their user_id, role, or email in plain text, which they can edit to escalate privileges.
Fix
Store only opaque, meaningless tokens in cookies. Keep real data in $_SESSION or the database, and always call htmlspecialchars() before rendering any cookie value into HTML to prevent XSS.
×

Using file sessions in a load-balanced environment

Symptom
Users repeatedly logged out or their cart appearing empty on different requests.
Fix
Switch to a shared session handler: Redis, Memcached, or a database. Use session_set_save_handler() or configure the native redis extension.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between a session and a cookie in PHP, and how do...
Q02SENIOR
What is a session fixation attack, and what single line of PHP code is t...
Q03SENIOR
If a user ticks 'Remember Me' on your login form, how would you implemen...
Q01 of 03JUNIOR

What is the difference between a session and a cookie in PHP, and how do they work together under the hood?

ANSWER
A cookie is a small text file stored in the user's browser, sent with every request to the same domain. A PHP session stores data on the server and gives the browser only a session ID (usually in a cookie named PHPSESSID). The ID is used by PHP to look up the session data on the server. Cookies are suitable for non-sensitive preferences because the user can inspect and modify them. Sessions are for sensitive data like login status because the actual data never leaves the server.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
How long does a PHP session last by default?
02
Can I use PHP sessions without cookies?
03
What is the difference between session_unset() and session_destroy()?
04
How do I make sessions work across multiple servers behind a load balancer?
05
What is SameSite cookie attribute and why should I use it?
N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's PHP Basics. Mark it forged?

8 min read · try the examples if you haven't

Previous
PHP Forms and User Input
9 / 14 · PHP Basics
Next
PHP File Handling