Secure Cookies
Why Cookie Security Matters In simple terms: Cookies are small pieces of data the browser stores and sends back with every request to your site. They carry session IDs, "remember me" tokens, and prefe
Why Cookie Security Matters
In simple terms: Cookies are small pieces of data the browser stores and sends back with every request to your site. They carry session IDs, "remember me" tokens, and preferences. Because the browser stores them and the user can see and edit them, cookies must be protected in two ways: against theft (someone else obtaining them) and against tampering (the user changing their content).
Technically: Cookie protection relies on attributes set by the server (Secure, HttpOnly, SameSite, Domain, Path, expiry, name prefixes) that tell the browser when to send a cookie and who may read it, plus cryptographic integrity (HMAC signatures) or confidentiality (encryption) for cookie values that the server must be able to trust. The basics of setcookie() are covered in $_COOKIE, and practical cookie patterns in PHP Cookies; this lesson focuses on hardening.
Cookie Attributes for Security
| Attribute | Setting | Protects Against |
|---|---|---|
Secure | 'secure' => true | Sending the cookie over unencrypted HTTP, where it can be intercepted |
HttpOnly | 'httponly' => true | JavaScript (and therefore XSS) reading the cookie |
SameSite | 'samesite' => 'Lax' or 'Strict' | Sending the cookie with cross-site requests (CSRF) |
Domain | Leave unset | Sharing the cookie with all subdomains |
Path | '/' (or narrower) | Sending the cookie to unrelated paths (not a security boundary on its own) |
Expires / Max-Age | As short as practical | Long-lived tokens being stolen and reused |
__Host- name prefix | __Host-session | Overwriting by subdomains; requires Secure, Path=/, no Domain |
__Secure- name prefix | __Secure-pref | Being set over plain HTTP |
A Hardened setcookie() Call
<?php
setcookie('__Host-theme', 'dark', [
'expires' => time() + 30 * 86400,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
The Resulting Response Header
Set-Cookie: __Host-theme=dark; expires=Sun, 15 Nov 2026 10:00:00 GMT; Max-Age=2592000; path=/; secure; HttpOnly; SameSite=Lax
Check the actual headers in your browser's developer tools (Network tab → response headers, and Application/Storage → Cookies) — a missing attribute is easy to spot there.
When HttpOnly Must Be Off
Only cookies that JavaScript genuinely needs to read (for example, a UI preference read by a script) should omit HttpOnly. Session IDs, login tokens, and anything security-relevant must always be HttpOnly.
Never Trust Cookie Contents
Users can change cookies freely with browser tools or by crafting requests. A cookie like role=admin or user_id=7 is an invitation to impersonation. Cookies may contain only:
| Safe in a Cookie | Never in a Cookie |
|---|---|
| Random identifiers that are looked up on the server (session ID, remember-me selector) | User IDs, roles, prices, discounts, or permissions used without verification |
| Harmless preferences (theme, language), validated when read | Passwords, password hashes, API keys |
| Values protected with a signature (integrity) or encryption (confidentiality) | Personal data such as addresses or phone numbers in plain text |
Signed Cookies: Detecting Tampering
When the server must trust a value it stored in a cookie, it adds an HMAC signature — a keyed hash that only the server can compute:
<?php
final class SignedCookie
{
public function __construct(#[\SensitiveParameter] private string $key) {}
public function sign(string $value): string
{
return $value . '.' . $this->mac($value);
}
public function verify(?string $cookie): ?string
{
if ($cookie === null) {
return null;
}
$separator = strrpos($cookie, '.');
if ($separator === false) {
return null;
}
$value = substr($cookie, 0, $separator);
$mac = substr($cookie, $separator + 1);
return hash_equals($this->mac($value), $mac) ? $value : null;
}
private function mac(string $value): string
{
$raw = hash_hmac('sha256', $value, $this->key, true);
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
}
$cookies = new SignedCookie('demo-only-key: use 32+ random bytes from an environment variable');
$signed = $cookies->sign('dark');
var_dump($cookies->verify($signed));
var_dump($cookies->verify('light' . substr($signed, strlen('dark')))); // value changed, old signature kept
var_dump($cookies->verify('dark.forged-signature'));
var_dump($cookies->verify('no-signature'));
var_dump($cookies->verify(null));
Output
string(4) "dark"
NULL
NULL
NULL
NULL
How It Works
sign()appends an HMAC-SHA256 signature (base64url-encoded, so it is safe in cookies) to the value.verify()splits the cookie at the last dot, recomputes the signature, and compares withhash_equals()(constant time).- Any change to the value or the signature makes verification fail, and
verify()returnsnull— the application then ignores the cookie. - The key is marked
#[\SensitiveParameter], so it is hidden in stack traces.
Generate the key once (for example, base64_encode(random_bytes(32))), store it in an environment variable, and never commit it. Rotating the key invalidates all existing signed cookies.
Note: A signature prevents tampering, not reading: the value
darkremains visible. For data that must stay secret, encrypt it (for example, withsodium_crypto_secretbox()— see Encryption with Sodium) — or better, store the data on the server and keep only an identifier in the cookie.
Deleting Cookies Reliably
A cookie is deleted by sending it again with an expiry date in the past and the same name, path, and domain it was set with:
<?php
function deleteCookie(string $name): void
{
setcookie($name, '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
unset($_COOKIE[$name]);
}
If the path or domain differs, the browser treats it as a different cookie and the original remains. unset($_COOKIE[...]) also removes it from the current request's data.
Further Considerations
| Topic | Advice |
|---|---|
| Size | Browsers limit cookies to about 4 KB each and a limited number per site; every cookie is sent with every request, so keep them small |
| Lifetime | Session cookies (no expiry) for logins; long expiry only for low-risk preferences and rotated remember-me tokens |
| Third-party embeds | Cookies needed inside cross-site iframes require SameSite=None; Secure; browsers increasingly restrict such cookies (partitioned "CHIPS" cookies are the modern approach) |
| Consent | Non-essential cookies (analytics, advertising) may require user consent depending on applicable law |
| HSTS | Strict-Transport-Security prevents browsers from ever using HTTP for your site, protecting Secure cookies from downgrade attacks |
Common Mistakes
Missing Secure or HttpOnly on Session Cookies
Effect: Cookies can be intercepted or read by injected scripts.
Fix: Set both on every security-relevant cookie (and in php.ini for sessions).
Trusting Cookie Values
Effect: Users escalate privileges or change prices.
Fix: Store identifiers only, look up data server-side, and sign values that must be trusted.
Comparing Signatures With ===
Effect: Timing differences can leak information.
Fix: Use hash_equals().
Setting Domain Unnecessarily
Effect: All subdomains receive (and can overwrite) the cookie.
Fix: Leave Domain unset; consider the __Host- prefix.
Deleting With Different Parameters
Effect: The cookie survives.
Fix: Use the same name, path, and domain as when it was set.
Best Practices
- Set
Secure,HttpOnly, andSameSiteon every cookie that matters; prefer__Host-names. - Store only random identifiers or harmless preferences in cookies.
- Sign cookies whose values must be trusted, and encrypt those whose contents must stay private.
- Keep cookies small and short-lived, and delete them with matching parameters.
- Serve the whole site over HTTPS with HSTS.
Official reference: https://www.php.net/manual/en/function.setcookie.php