Cookies are visible to the user and travel with every request. Three flags and two habits make them much safer.
The three flags
| Flag | Effect | Protects against |
|---|---|---|
Secure | Sent only over HTTPS | Reading the cookie on an unencrypted network |
HttpOnly | JavaScript cannot read it | Cookie theft through XSS |
SameSite | Limits cross-site sending | CSRF and some tracking |
<?php
setcookie("theme", "dark", [
"expires" => time() + 86400 * 30,
"path" => "/",
"secure" => true,
"httponly" => true,
"samesite" => "Lax",
]);
Which SameSite value?
- Lax: the cookie is sent on normal navigation to your site (clicking a link), but not on most cross-site sub-requests. A good default.
- Strict: sent only when the request starts on your own site. Strongest, but a user arriving from an email link may look logged out.
- None: sent everywhere. Needs
Secure. Use only when you really need cross-site cookies.
The __Host- prefix
A cookie name that starts with __Host- is accepted by browsers only if it has Secure, has Path=/ and has no Domain. That stops other subdomains from overwriting it.
<?php
setcookie("__Host-session", $value, [
"path" => "/",
"secure" => true,
"httponly" => true,
"samesite" => "Lax",
]);
A helper
<?php
function cookieOptions(int $days, bool $https): array
{
return [
"expires" => time() + $days * 86400,
"path" => "/",
"secure" => $https,
"httponly" => true,
"samesite" => "Lax",
];
}
$o = cookieOptions(7, true);
echo ($o["secure"] ? "Secure " : "") . ($o["httponly"] ? "HttpOnly " : "") . "SameSite=" . $o["samesite"];
Secure HttpOnly SameSite=Lax
What a cookie must never be
- A trusted value: roles, prices, “logged_in=1”. The user can edit it.
- A place for secrets such as passwords.
- Large. It is sent on every request.
If a value matters, store it on the server (session or database) and keep only a random ID in the cookie. A “remember me” token is a random value checked against a hashed copy in the database (see Remember Me).
Sign values if you must store data
If you need a cookie to carry data that the user must not alter, sign it with a secret key, and verify on arrival.
<?php
function signCookie(string $value, string $key): string
{
return $value . "." . hash_hmac("sha256", $value, $key);
}
function readSignedCookie(string $cookie, string $key): ?string
{
$pos = strrpos($cookie, ".");
if ($pos === false) {
return null;
}
$value = substr($cookie, 0, $pos);
return hash_equals(hash_hmac("sha256", $value, $key), substr($cookie, $pos + 1)) ? $value : null;
}
$key = "a-long-random-secret-from-the-environment";
$cookie = signCookie("lang=hi", $key);
var_dump(readSignedCookie($cookie, $key));
var_dump(readSignedCookie("lang=en." . explode(".", $cookie)[1], $key)); // tampered value
string(7) "lang=hi"
NULL
The signature proves that you created the value and nobody changed it. It does not hide it: the user can still read the value. Use encryption (sodium_crypto_secretbox) if the content must also be private.
Cookie scope
- Set
pathas narrow as makes sense. - Do not set
domainunless you need to share the cookie with subdomains. Settingdomain=example.comshares it with all subdomains, including ones run by someone else.
Delete cookies with the same attributes
To remove a cookie, send it again with an expiry in the past and the same path and domain. See Deleting Cookies.
HTTPS first
Secure flag only helps if the site really uses HTTPS everywhere. Set it up, redirect HTTP to HTTPS and consider the HSTS header (see Security Headers).