PHP Cookies
The $_COOKIE lesson covered the basics: setting cookies with setcookie() , reading them from $_COOKIE , deleting them, and protecting them with security options and signatures. This lesson focuses on
The $_COOKIE lesson covered the basics: setting cookies with setcookie(), reading them from $_COOKIE, deleting them, and protecting them with security options and signatures. This lesson focuses on how cookies are used in real applications: scope (which requests receive a cookie), SameSite behavior, cookie prefixes, consent preferences, and a secure "Remember me" login implementation.
Cookie Scope: Who Receives a Cookie?
A browser sends a cookie only with requests that match its domain and path:
| Attribute | Setting | Cookie Is Sent To |
|---|---|---|
domain | Not set (recommended) | Only the exact host that set it, e.g. gocourse.in |
domain | gocourse.in | gocourse.in and all subdomains (app.gocourse.in, blog.gocourse.in) |
path | / | Every path on the host |
path | /admin | /admin and paths below it, such as /admin/users |
<?php
// Only for this host, all paths
setcookie('lang', 'ta', ['path' => '/', 'secure' => true, 'samesite' => 'Lax']);
// Shared with all subdomains (use only when needed)
setcookie('region', 'south', ['domain' => 'gocourse.in', 'path' => '/', 'secure' => true, 'samesite' => 'Lax']);
Why Narrow Scope Is Safer
- A cookie shared with all subdomains is also sent to less-trusted subdomains (for example, a marketing site hosted by a third party), which could read or overwrite it.
- Leaving
domainunset makes the cookie host-only, which is the safest default. pathis not a security boundary: pages on other paths of the same host can still read cookies through other means. Use it to reduce unnecessary traffic, not to protect secrets.
SameSite in Practice
The SameSite attribute decides whether the browser sends a cookie with requests that start on another website:
| Request Starting on Another Site | Strict | Lax (default in modern browsers) | None |
|---|---|---|---|
| Clicking a link to your site | Not sent | Sent | Sent |
| A form on another site POSTing to yours | Not sent | Not sent | Sent |
Your page embedded in an <iframe>, or images/AJAX from other sites | Not sent | Not sent | Sent |
Choosing a Value
Lax— the right choice for most cookies, including login sessions: users arriving from a link (for example, from an email or a search engine) stay logged in, while cross-site form submissions do not carry the cookie, which blocks most CSRF attacks.Strict— for highly sensitive actions (such as a banking session); users following a link from another site appear logged out on the first page load.None— only for genuinely cross-site use, such as an embedded widget or a payment provider's iframe. It requiressecure: true.
SameSite reduces CSRF risk but does not replace CSRF tokens, because subdomains and older browsers can still be sources of cross-site requests.
Cookie Name Prefixes
Browsers enforce extra rules for cookies whose names start with special prefixes:
| Prefix | Browser Requirement | Benefit |
|---|---|---|
__Secure- | Must be set with secure over HTTPS | Cannot be set or overwritten by plain HTTP pages |
__Host- | secure, path=/, and no domain | Locked to the exact host; subdomains cannot set or overwrite it |
<?php
setcookie('__Host-session_hint', 'abc123', [
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
If the requirements are not met, the browser rejects the cookie. __Host- is a strong default for important cookies, such as session and remember-me cookies.
Storing Preferences: Cookie Consent
Non-essential cookies (analytics, advertising) may require the user's consent, depending on the laws that apply to your site and visitors. A common pattern stores the user's choices in a small, readable cookie:
<?php
function readConsent(array $cookies): array
{
$defaults = ['necessary' => true, 'analytics' => false, 'marketing' => false];
$decoded = json_decode($cookies['consent'] ?? '', true);
if (!is_array($decoded)) {
return $defaults;
}
foreach (['analytics', 'marketing'] as $category) {
$defaults[$category] = ($decoded[$category] ?? false) === true;
}
return $defaults;
}
var_export(readConsent([]));
echo "\n";
var_export(readConsent(['consent' => '{"analytics":true,"marketing":"yes"}']));
echo "\n";
Output
array (
'necessary' => true,
'analytics' => false,
'marketing' => false,
)
array (
'necessary' => true,
'analytics' => true,
'marketing' => false,
)
How It Works
- Without a consent cookie, only necessary cookies are allowed.
- Only the exact value
truecounts as consent; anything else (here, the string"yes") is treated as "no", because cookie values can be modified. - The function receives the cookie array as a parameter (
readConsent($_COOKIE)in real use), which makes it testable.
The template then includes analytics scripts only when $consent['analytics'] is true. Setting the consent cookie is done when the user clicks a button in the consent banner, usually with an expiry of several months.
Practical Example: A Secure "Remember Me" Login
A session normally ends when the browser is closed or the session expires. "Remember me" keeps users logged in for weeks using a long-lived cookie. A naive implementation — storing the user ID in a cookie — lets anyone log in as anyone by editing the cookie. A secure design uses a random token that is checked against the database.
The Design
| Part | Stored In | Purpose |
|---|---|---|
| Selector (random, not secret) | Cookie and database | Finds the token record quickly |
| Validator (random, secret) | Cookie only | Proves possession of the token |
| Hash of the validator | Database only | Lets the server verify without storing the secret |
| User ID and expiry | Database | Which user, and until when |
The cookie value is selector:validator. If the database leaks, attackers get only hashes, which cannot be turned back into valid cookies.
Creating a Token
<?php
function createRememberToken(int $userId, DateTimeImmutable $now): array
{
$selector = bin2hex(random_bytes(12));
$validator = bin2hex(random_bytes(32));
$record = [
'selector' => $selector,
'validator_hash' => hash('sha256', $validator),
'user_id' => $userId,
'expires_at' => $now->modify('+30 days')->getTimestamp(),
];
return ["$selector:$validator", $record];
}
In a real application, $record is inserted into a remember_tokens table, and the cookie is set:
setcookie('__Host-remember', $cookieValue, [
'expires' => time() + 30 * 86400,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
Verifying a Token
<?php
function verifyRememberToken(string $cookieValue, array $tokensBySelector, DateTimeImmutable $now): ?int
{
$parts = explode(':', $cookieValue, 2);
if (count($parts) !== 2) {
return null;
}
[$selector, $validator] = $parts;
$record = $tokensBySelector[$selector] ?? null;
if ($record === null || $record['expires_at'] < $now->getTimestamp()) {
return null;
}
return hash_equals($record['validator_hash'], hash('sha256', $validator)) ? $record['user_id'] : null;
}
$now = new DateTimeImmutable('2026-10-15 10:00');
[$cookieValue, $record] = createRememberToken(7, $now);
$table = [$record['selector'] => $record]; // simulates the database table
[$selector] = explode(':', $cookieValue);
var_dump(verifyRememberToken($cookieValue, $table, $now));
var_dump(verifyRememberToken($selector . ':' . str_repeat('0', 64), $table, $now));
var_dump(verifyRememberToken($cookieValue, $table, $now->modify('+31 days')));
var_dump(verifyRememberToken('garbage', $table, $now));
Output
int(7)
NULL
NULL
NULL
Step-by-Step Explanation
- A valid cookie returns the user's ID (
7). - A cookie with the right selector but a forged validator fails: its hash does not match the stored hash.
- After 30 days, the token has expired.
- Malformed cookie values are rejected before any lookup.
Using It Safely
- When a user is logged in through a remember-me token, issue a new token (and delete the old one) — this "rotation" limits the damage if a cookie is stolen.
- Delete all of a user's tokens when they change their password or click "log out of all devices".
- Require the password again for sensitive actions (changing the email address, payments), even for remembered users.
- Call
session_regenerate_id(true)when the remember-me login creates a new session (see PHP Sessions).
Common Mistakes
Sharing Cookies With All Subdomains Unnecessarily
Effect: Other subdomains can read or overwrite them.
Fix: Leave domain unset, or use the __Host- prefix.
Using SameSite=None Without Need
Effect: Cookies are sent with cross-site requests, increasing CSRF risk.
Fix: Use Lax unless cross-site use is required.
Storing User IDs or Roles in Cookies
Effect: Users can change them to impersonate others.
Fix: Use random tokens verified on the server.
Storing Remember-Me Tokens in Plain Text
Effect: A database leak gives attackers working login cookies.
Fix: Store only a hash of the secret validator.
Ignoring Consent Requirements
Effect: Legal and trust problems.
Fix: Load non-essential scripts only after consent, and store the user's choice.
Best Practices
- Keep cookies host-only and small; use
__Host-for important cookies. - Use
SameSite=Lax,secure, andhttponlyby default. - Store only random identifiers or harmless preferences in cookies, and validate them when reading.
- Implement "Remember me" with a selector, a hashed validator, expiry, and rotation.
- Respect consent for non-essential cookies.
Official reference: https://www.php.net/manual/en/features.cookies.php