PHP Sessions and Cookies

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

PHP 8.4

HTTP is stateless: each request stands alone. To “remember” a user you need cookies (stored in the browser) and sessions (stored on the server).

The division of labour

Cookie Session
Stored in Browser Server (files by default)
Capacity ~4 KB Limited by the server, can be larger
Trust User can read and edit it User only holds an ID
Typical use Preferences, non-sensitive flags Login state, cart, permissions

Sessions depend on cookies: the session ID travels in a cookie named PHPSESSID by default.

Using sessions

<?php
declare(strict_types=1);

session_start();

// write
$_SESSION["user_id"] = 42;
$_SESSION["cart"] = ["item_1" => 2];

// read
$userId = $_SESSION["user_id"] ?? null;

// remove one key
unset($_SESSION["cart"]);

session_start() must run before any output, otherwise you get “headers already sent”. The usual culprits are a BOM at the top of the file or a stray newline after a closing ?>.

Destroying a session properly

Logging out takes three steps, and skipping one leaves state behind:

session_start();

$_SESSION = [];                                  // 1. clear the data

if (ini_get("session.use_cookies")) {            // 2. delete the session cookie
    $params = session_get_cookie_params();
    setcookie(
        session_name(),
        "",
        [
            "expires" => time() - 42000,
            "path" => $params["path"],
            "domain" => $params["domain"],
            "secure" => $params["secure"],
            "httponly" => $params["httponly"],
            "samesite" => $params["samesite"],
        ]
    );
}

session_destroy();                               // 3. remove server-side storage

Calling only session_destroy() leaves $_SESSION readable for the rest of the current request.

Session fixation and ID rotation

After a successful login you must regenerate the session ID, otherwise an attacker who planted a known ID can hijack the authenticated session:

session_start();

if (password_verify($password, $hash)) {
    session_regenerate_id(true);   // true = delete the old session file
    $_SESSION["user_id"] = $user["id"];
    $_SESSION["logged_in_at"] = time();
}

Setting cookies

PHP 7.3+ accepts an options array, which is far more readable than positional arguments:

setcookie("theme", "dark", [
    "expires" => time() + 86400 * 30,   // 30 days
    "path" => "/",
    "domain" => "",                     // current host
    "secure" => true,                   // HTTPS only
    "httponly" => true,                 // not readable from JavaScript
    "samesite" => "Lax",                // cross-site restrictions
]);

What the flags do:

  • secure — sent over HTTPS only. Set it to false while developing on plain HTTP, and back to true in production.
  • httponly — hides the cookie from document.cookie, limiting what XSS can steal.
  • samesite — Lax (good default: cookies ride along normal navigation), Strict (never cross-site), None (cross-site allowed, requires secure).

Delete a cookie by expiring it:

setcookie("theme", "", ["expires" => time() - 3600, "path" => "/"]);

Reading cookies

$theme = $_COOKIE["theme"] ?? "light";

// allow-list the value: users can set it to anything
$theme = in_array($theme, ["light", "dark"], true) ? $theme : "light";

Cookie contents are under the user’s control. Validate before use and never treat them as an identity source — do not decide who is logged in from a user ID stored in a cookie.

Storage and configuration

Sessions default to the server’s temporary directory. To move or change them:

session_save_path(__DIR__ . "/storage/sessions");   // before session_start()
session_start();

Useful php.ini settings:

session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
session.gc_maxlifetime = 1440      ; lifetime of session data, seconds
session.use_strict_mode = 1        ; accept only IDs this server created

Keep session.use_strict_mode = 1 on — it blocks an attacker from choosing a session ID.

Worked example: login and access control

<?php
declare(strict_types=1);

require __DIR__ . "/bootstrap.php";   // session configuration lives here

session_start();

function requireLogin(): void
{
    if (empty($_SESSION["user_id"])) {
        header("Location: /login.php", true, 303);
        exit;
    }
}

function login(int $userId): void
{
    session_regenerate_id(true);
    $_SESSION["user_id"] = $userId;
    $_SESSION["logged_in_at"] = time();
}

function logout(): void
{
    $_SESSION = [];
    session_destroy();
}

requireLogin();
echo "Current user ID: " . (int) $_SESSION["user_id"];

Put the check on every protected entry point, not just the login page: users can request any URL directly.

Common mistakes

1. Output before session_start()

“Headers already sent”. Look for a BOM or a trailing newline after ?>.

2. Not rotating the ID after login

Session fixation risk. Call session_regenerate_id(true).

3. Only calling session_destroy() on logout

$_SESSION is still readable in that request. Clear it first, then delete the cookie.

4. Storing sensitive data in cookies

They are visible and editable. Keep sensitive values server-side.

5. Using a cookie value without validation

Users can forge it. Allow-list or cast it.

6. Shipping without secure / httponly

The cookie travels in clear text or is readable by any script on the page.

7. Checking permissions only at login

Every protected page must verify independently.

Summary

  • Cookies live in the browser, are untrusted and small; sessions live on the server, keyed by an ID.
  • session_start() before any output.
  • On login session_regenerate_id(true); on logout clear $_SESSION, expire the cookie, then session_destroy().
  • Always set secure, httponly and samesite in production.
  • Authorise on each protected entry point, not once at login.

Next: the PHP basics series is complete — Composer, namespaces and autoloading come next in the intermediate series.