PHP Sessions and Cookies Explained — How Websites Remember You
- 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()andsession_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.
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 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.setcookie()
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 and it survives for exactly one day.time() + 86400
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.
<?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"; ?>
Cookie scheduled for deletion.
(On a subsequent page load after the first setcookie call, you would see:)
Welcome back! Your saved theme is: dark
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.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.
<?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"; } ?>
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.
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.
<?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); ?>
Silent login successful for user ID: 42
| Feature / Aspect | PHP Sessions | Cookies |
|---|---|---|
| Where data lives | Server (disk or cache) | User's browser |
| Data size limit | Effectively unlimited | ~4KB per cookie |
| Security | High — user sees only an ID | Low — user can read and edit values |
| Survives browser close? | No (by default) | Yes (if expiry is set) |
| Works without JavaScript? | Yes | Yes |
| Adds server load? | Yes — disk/DB read per request | No — zero server cost to read |
| Best for | Login state, cart contents, sensitive flags | Theme, language, remember-me tokens |
| Accessible via JavaScript? | No (not directly) | Only if httponly is false |
| Controlled by server? | Yes | Partially — browser can reject or expire |
| GDPR / consent required? | Session cookies: often exempt | Persistent 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()andsession_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
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.