Home PHP PHP Sessions and Cookies Explained — How Websites Remember You

PHP Sessions and Cookies Explained — How Websites Remember You

In Plain English 🔥
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.
⚡ Quick Answer
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.

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.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
<?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 SentIf you see 'Cannot modify header information — headers already sent by...' it means there was output (even a blank line or a space before

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.php · PHP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
<?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 AttackAlways 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.

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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
<?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 RotationFor 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.
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

  • 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.
  • 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.
  • 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.
  • 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.

⚠ Common Mistakes to Avoid

  • Mistake 1: 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)' — 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
  • Mistake 2: 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.
  • Mistake 3: 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.

Interview Questions on This Topic

  • QWhat is the difference between a session and a cookie in PHP, and how do they work together under the hood?
  • QWhat is a session fixation attack, and what single line of PHP code is the primary defence against it?
  • QIf a user ticks 'Remember Me' on your login form, how would you implement that securely — and why is storing the user's ID in a cookie the wrong approach?

Frequently Asked Questions

How long does a PHP session last by default?

By default, a PHP session lasts until the browser is closed, because the PHPSESSID cookie has no expiry set. On the server side, the session data file is eligible for garbage collection after session.gc_maxlifetime seconds (default 1440 — 24 minutes of inactivity). You can extend this in php.ini or by implementing your own timeout logic using a timestamp stored in $_SESSION.

Can I use PHP sessions without cookies?

Yes — PHP can pass the session ID in the URL as a query parameter (e.g. page.php?PHPSESSID=abc123) if you set session.use_trans_sid = 1 in php.ini. However, this is a serious security risk because session IDs appear in browser history, server logs, and Referer headers. Stick with cookie-based sessions and set session.use_only_cookies = 1 to enforce it.

What is the difference between session_unset() and session_destroy()?

session_unset() clears all variables stored in $_SESSION for the current session, but the session itself (and its server-side file) still exists. session_destroy() deletes the session file on disk but does NOT clear the $_SESSION superglobal in the current request. For a proper logout, you should do both: set $_SESSION = [] to clear variables, then call session_destroy() to remove the file, and finally expire the PHPSESSID cookie in the browser.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousPHP Forms and User InputNext →PHP File Handling
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged