Sessions and Cookies
Remembering users between requests in PHP: setting and reading cookies, how sessions work, session_start, regenerating the session ID, and secure cookie settings.
Remembering users between requests
Every PHP request starts with empty memory (see
How PHP sees a request), and HTTP itself is stateless. To know that the person asking for
/account.php is the customer who logged in a minute
ago, a site needs a
cookie,
and usually a
session
on top of it. That page explains the concepts and the cookie
attributes; this page shows how PHP implements them.
| Aspect | Cookie | PHP session |
|---|---|---|
| Data stored | in the browser | on the server; the browser holds only the ID |
| Who can change it | the user, freely | only your code |
| Write with | setcookie() |
$_SESSION['key'] = … |
| Read with | $_COOKIE (on the next request) |
$_SESSION (after session_start())
|
| Good for | harmless preferences: language, chosen area | who is logged in, form tokens, a shopping cart |
Cookies
Setting a cookie
setcookie() adds a
Set-Cookie header to the response, asking the browser
to store a name and value. Since PHP 7.3 the attributes are passed
as an array of options. Like every header, it must be sent before
any output (see
"Headers already sent").
<?php
// Remember the customer's preferred area for 30 days
setcookie('area', 'Zwolle', [
'expires' => time() + 30 * 24 * 60 * 60,
'path' => '/',
'secure' => true, // only over HTTPS
'httponly' => true, // not readable from JavaScript
'samesite' => 'Lax', // not sent with most cross-site requests
]);
Set-Cookie: area=Zwolle; expires=Sat, 31 Oct 2026 09:00:00 GMT; Max-Age=2592000; path=/; secure; HttpOnly; SameSite=Lax
Without expires the cookie is a session cookie
that the browser forgets when it closes. PHP URL-encodes the value,
so spaces and semicolons are safe. What
HttpOnly,
SameSite
and Secure protect against is explained on the Web
page; as a rule, set all three unless you have a reason not to.
Reading a cookie
Cookies the browser sends arrive in the superglobal
$_COOKIE. A cookie set in this response is
not in $_COOKIE yet: it appears from the next
request on. Treat cookie values exactly like form input — strings
the user can edit in the browser's developer tools — and check them
against what you allow:
<?php
$_COOKIE = ['area' => 'Zwolle', 'theme' => 'dark']; // simulated request
$areas = ['Apeldoorn', 'Deventer', 'Zwolle'];
$cookie = $_COOKIE['area'] ?? '';
$area = in_array($cookie, $areas, true) ? $cookie : 'Deventer';
echo "Showing outages for $area\n";
Showing outages for Zwolle
Never store anything in a cookie that decides what a user may do — a
customer number, a role, is_admin=1. The user can
simply change it. That information belongs in the session.
Deleting a cookie
There is no "delete" command in HTTP. You set the cookie again with
an expiry time in the past, and with the same path and
domain as when it was set (otherwise the browser treats
it as a different cookie):
<?php
setcookie('area', '', ['expires' => time() - 3600, 'path' => '/']);
unset($_COOKIE['area']); // also forget it for the rest of this request
How PHP sessions work
session_start()
session_start()
starts or resumes the visitor's session. If the request carries a
session cookie (named PHPSESSID by default), PHP loads
the data stored for that
session ID. If not, it creates a new random ID and adds a
Set-Cookie header for it. With the default
files handler, each session is a small file named
sess_ plus the ID, in the folder set by
session.save_path (on Debian and Ubuntu,
/var/lib/php/sessions).
Set-Cookie: PHPSESSID=….
$_SESSION
After session_start(),
$_SESSION is an
ordinary array holding this visitor's session data. Whatever you put
in it is saved when the script ends and is there again on the
visitor's next request. The example below plays two requests in one
script: the first stores data and closes the session, the second
resumes it with the same ID, as a browser sending the cookie would.
It also prints the session file, so you can see PHP's storage
format.
<?php
// Demo only: session files in the current folder, no cookies (no browser here)
ini_set('session.save_path', getcwd());
ini_set('session.use_cookies', '0');
// Request 1: the customer logs in
session_start();
$_SESSION['customer_id'] = 1042;
$_SESSION['name'] = 'Lotte Visser';
$id = session_id();
session_write_close(); // PHP writes the session file
$file = file_get_contents("sess_$id");
// Request 2: the browser sends the same session ID back
session_id($id);
session_start();
$greeting = "Welcome back, {$_SESSION['name']} (customer {$_SESSION['customer_id']})";
session_write_close();
echo "Session file: $file\n";
echo $greeting, "\n";
echo "The session ID has ", strlen($id), " characters\n";
Session file: customer_id|i:1042;name|s:12:"Lotte Visser"; Welcome back, Lotte Visser (customer 1042) The session ID has 32 characters
The ID itself is random and different on every run, which is why the
example prints only its length (32 is PHP's built-in default; the
php.ini templates set session.sid_length = 26). Store
small, simple values — an ID, a name, a token — rather than whole
objects: the session is read and written on every request, and
objects can only be restored when their class is loaded.
Session locking
With the files handler,
session_start() locks the session file until the script
ends. A second request from the same visitor — another browser tab,
or several AJAX calls at once — waits for the lock, so one slow page
can make the whole site feel slow for that user. If a script only
needs to read the session, call
session_write_close() as soon as it has what it needs;
that saves the data and releases the lock.
Sites that run on several servers cannot use local session files,
because the next request may reach another server. They configure a
shared store such as Redis or a database through
session.save_handler (an extension or framework
provides the handler).
Logging in safely
Session fixation
In a session fixation attack, the attacker does not steal a session ID — they make the victim use an ID the attacker already knows, for example by planting a session cookie through a vulnerable subdomain. If the site keeps that same ID when the victim logs in, the attacker's copy of the ID is now logged in too.
PHP makes this easier than it should by default: with
session.use_strict_mode at its default of
0, session_start() accepts any ID the
browser sends, even one the server never created, and starts a new
session under it. The PHP manual calls enabling strict mode
mandatory for session security. With it, PHP ignores unknown IDs and
issues a fresh one.
Regenerating the session ID
The second half of the defense is
session_regenerate_id(): it gives the current session a new random ID, keeps the data, and
sends the new ID in a Set-Cookie header. Pass
true to delete the old session file as well. Call it
whenever the privilege level changes — after login, and after a
switch to an administrator role — so that any ID known before that
moment becomes worthless.
<?php
// login.php — after validating the POSTed form
session_start();
$customer = findCustomerByEmail($email); // e.g. with PDO
if ($customer === null || !password_verify($password, $customer['password_hash'])) {
$error = 'Email address or password is incorrect.'; // same message for both cases
} else {
session_regenerate_id(true); // new ID, old one deleted
$_SESSION['customer_id'] = $customer['id'];
header('Location: /account.php', true, 303);
exit;
}
Password checking with password_verify() and
password_hash() is covered in
PHP Security Basics and, in
general terms, in
Passwords and Authentication.
Secure session settings
PHP's session defaults favor compatibility over security. With PHP's
built-in defaults (as of 2026), the session cookie is not
HttpOnly, not Secure and has no
SameSite attribute, and strict mode is off. Set them in
php.ini (or the pool configuration of PHP-FPM) for the whole server:
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Lax"
session.use_only_cookies = 1
Or pass the same options to session_start() in the
application, which keeps working when the code moves to another
server:
<?php
session_start([
'use_strict_mode' => true,
'cookie_httponly' => true,
'cookie_secure' => true,
'cookie_samesite' => 'Lax',
]);
Set-Cookie: PHPSESSID=bd6c07efaf5e7262197dc87efde81e5d; path=/; secure; HttpOnly; SameSite=Lax Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: no-store, no-cache, must-revalidate Pragma: no-cache
The last three headers come from session_start() too:
pages that use a session are personal, so by default PHP tells
browsers and proxies not to cache them.
use_only_cookies (already 1 by default)
keeps PHP from accepting session IDs in the URL, where they would
leak through browser history, logs and the
Referer header. Sessions are also where a site keeps
its
cross-site request forgery
tokens; see
PHP Security Basics.
Logging out
A complete logout removes the data, the session file and the cookie.
session_destroy() alone deletes the stored data but
leaves $_SESSION filled for the rest of the request and
the cookie in the browser.
<?php
// logout.php — reached with a POST from a "Log out" button
session_start();
$_SESSION = []; // 1. forget the data now
$params = session_get_cookie_params(); // 2. delete the cookie,
setcookie(session_name(), '', [ // with the same attributes
'expires' => time() - 3600,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
'samesite' => $params['samesite'],
]);
session_destroy(); // 3. delete the session file
header('Location: /', true, 303);
exit;
Logging out with a POST request (a small form with a button) rather than a plain link keeps other sites from logging your users out with an image tag.
Session lifetime and timeouts
Session files do not live forever. PHP's garbage collector deletes
files that have not been used for
session.gc_maxlifetime seconds (default 1440, which is
24 minutes). On Debian and Ubuntu a cron job does that cleanup
instead of PHP itself. The default session cookie has no expiry, so
the browser forgets it when it closes.
Garbage collection is not a reliable logout: it runs only now and then (on Debian, from cron), so a session may survive well past the limit. If your application must log users out after a period of inactivity, check that yourself on every request, using a timestamp in the session:
<?php
const IDLE_LIMIT = 30 * 60; // log out after 30 minutes without activity
function stillActive(array &$session, int $now): bool
{
$last = $session['last_activity'] ?? $now;
if ($now - $last > IDLE_LIMIT) {
$session = []; // expired: forget everything
return false;
}
$session['last_activity'] = $now;
return true;
}
// In real code: stillActive($_SESSION, time()). Fixed times keep the demo repeatable.
$session = ['customer_id' => 1042, 'last_activity' => 1_790_000_000];
var_dump(stillActive($session, 1_790_000_000 + 10 * 60)); // 10 minutes later
var_dump(stillActive($session, 1_790_000_000 + 50 * 60)); // 40 minutes after that
var_dump($session);
bool(true)
bool(false)
array(0) {
}
The second check fails because 40 minutes passed since the
last activity, not since login: each successful check moves
the timestamp forward. After a timeout, also call
session_regenerate_id(true) or the full logout above,
and send the user to the login page.