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

The life of a PHP session during one request. 1: the browser sends the cookie PHPSESSID. 2: session_start reads and locks the session file in the session store. 3: the stored data becomes $_SESSION. 4: the script reads and changes $_SESSION. 5: at the end of the script PHP writes the data back and releases the lock. 6: the response goes to the browser. Browser PHP script Session files 1. PHPSESSID=k7Qm… 2. read + lock sess_k7Qm… 3. data → $_SESSION 4. script reads and changes $_SESSION 5. write + unlock 6. response
The browser only ever holds the ID; the data stays in the session file on the server. On the first visit, step 1 has no cookie and step 6 contains 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.