Skip to main content
Being Idea Innovations
PHP Cookies: setcookie(), SameSite, and What Changed in 2026
Back to Blog

PHP Cookies: setcookie(), SameSite, and What Changed in 2026

16 July 20186 min readPHP
Share:

A cookie is a small piece of data your server asks the browser to store and send back on subsequent requests. In PHP that is setcookie() — a function whose signature and security defaults have both changed significantly, and most tutorials still show the old form.

Use the array syntax

The positional signature is the one you will see everywhere:

// Legacy — cannot set SameSite
setcookie($name, $value, $expires, $path, $domain, $secure, $httponly);

Since PHP 7.3 there is an array form, and it is the one to use — because the positional version has no way to set SameSite, which is now the single most important cookie attribute:

setcookie('preferred_theme', 'dark', [
    'expires'  => time() + 60 * 60 * 24 * 30,   // 30 days
    'path'     => '/',
    'domain'   => '',            // current host only — safer than a wildcard
    'secure'   => true,          // HTTPS only
    'httponly' => true,          // unreachable from JavaScript
    'samesite' => 'Lax',
]);

People historically worked around the gap by smuggling SameSite into the path parameter ('/; SameSite=Lax'). Do not — the array form exists precisely so you do not have to.

The gotcha that confuses everyone first

setcookie('user', 'Ada', ['path' => '/']);

echo $_COOKIE['user'];   // Undefined array key "user"

setcookie() sends a Set-Cookie header. The browser stores it and returns it on the next request. $_COOKIE is populated from the incoming request, so it will not contain a cookie you set moments ago on this one.

If you need the value in the same request, set both:

setcookie('user', 'Ada', ['path' => '/']);
$_COOKIE['user'] = 'Ada';   // so the rest of this request can read it

Because it is a header, setcookie() must be called before any output — including whitespace before <?php. That is the cause of nearly every "headers already sent" error.

Reading and checking

$theme = $_COOKIE['preferred_theme'] ?? 'light';

if (isset($_COOKIE['user'])) {
    // Cookie values are user-controlled. Escape on output, validate before use.
    echo 'Welcome back, ' . htmlspecialchars($_COOKIE['user'], ENT_QUOTES, 'UTF-8');
}

Treat every cookie value as untrusted input. A user can edit cookies freely in DevTools, so a cookie saying role=admin means nothing unless you signed it.

Deleting

setcookie('user', '', [
    'expires'  => time() - 3600,
    'path'     => '/',        // must match how it was set
    'domain'   => '',         // must match how it was set
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

unset($_COOKIE['user']);      // also clear it for the current request

The critical detail: path and domain must match the original exactly. A cookie set on /app is not deleted by an expiry on / — you end up with a "ghost" cookie you cannot remove and cannot see why. If deletion is not working, check those two values first.

The attributes that matter

SameSite

ValueSent on cross-site requests?Use for
StrictNever, not even following a linkBanking, admin panels
LaxOnly on top-level GET navigationSessions — the sensible default
NoneAlways — requires SecureGenuine cross-site embedding

SameSite is what makes CSRF hard. With Lax, another site cannot make an authenticated POST on your users' behalf, because the browser withholds the cookie.

Strict has a real usability cost worth knowing: a user following a link from an email to your site arrives logged out, because the cookie is not sent on that navigation. It appears on refresh, which is baffling. Use Lax for session cookies unless you have a specific reason.

SameSite=None without Secure is rejected outright by browsers — the cookie is silently dropped.

HttpOnly

Makes the cookie invisible to document.cookie. If an attacker lands an XSS payload on your page, an HttpOnly session cookie cannot be read and exfiltrated. Set it on anything your JavaScript does not genuinely need.

Secure

Send only over HTTPS. Without it, a cookie leaks in plaintext on any accidental http:// request.

Cookie prefixes

Underused and worth knowing. Browsers enforce rules based on the cookie's name:

// __Host- requires Secure, path=/, and NO domain attribute.
// A subdomain cannot overwrite it — this is the strongest binding available.
setcookie('__Host-session', $token, [
    'expires'  => 0,
    'path'     => '/',
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

Without the prefix, a compromised or attacker-controlled subdomain can set a cookie that shadows yours on the parent domain — a "cookie tossing" attack. __Host- makes that impossible. __Secure- is a weaker variant that only requires the Secure flag.

Signing values against tampering

If a cookie holds anything a user should not be able to change, sign it:

function signCookie(string $value, string $secret): string
{
    $mac = hash_hmac('sha256', $value, $secret);
    return $value . '.' . $mac;
}

function readSignedCookie(?string $raw, string $secret): ?string
{
    if ($raw === null || !str_contains($raw, '.')) {
        return null;
    }

    [$value, $mac] = explode('.', $raw, 2);

    // hash_equals compares in constant time — a plain === leaks timing info.
    if (!hash_equals(hash_hmac('sha256', $value, $secret), $mac)) {
        return null;
    }

    return $value;
}

Signing proves the value came from you. It does not hide it — the value is still readable. For anything genuinely sensitive, keep the data server-side and store only an opaque session ID in the cookie.

Limits worth knowing

  • ~4 KB per cookie, including name and attributes. Exceed it and the browser silently drops it.
  • ~50 cookies per domain, and a global cap across all domains.
  • Every cookie is sent on every matching request — including images and CSS. Large cookies on a static-asset domain waste real bandwidth.
  • Cookie values must be URL-encoded. setcookie() does this for you; setrawcookie() does not.

For larger client-side data, use localStorage or IndexedDB — neither is sent to the server, and neither is a good place for anything sensitive, since both are readable by JavaScript.

Third-party cookies

Safari and Firefox have blocked third-party cookies by default for years, and Chrome has spent a long time restricting them. The practical consequence: a cookie set by a domain other than the one in the address bar is unreliable.

This affects cross-site tracking, some embedded widgets, and any SSO flow relying on an iframe reading a cookie from another origin. First-party cookies — the kind covered here — are unaffected. Sessions, preferences and CSRF tokens continue to work exactly as before.

Consent

Under GDPR and the ePrivacy Directive, cookies that are not strictly necessary require consent before being set. "Strictly necessary" is narrow: session cookies for a logged-in area, a shopping cart, a CSRF token. Analytics and advertising cookies are not, and setting them before the user agrees is the most common compliance failure.

// Only set analytics cookies once consent is recorded.
if (($_COOKIE['cookie_consent'] ?? '') === 'accepted') {
    setcookie('_analytics_id', $id, [ /* … */ ]);
}

Summary

  • Use the array syntax — the positional form cannot set SameSite.
  • $_COOKIE reflects the incoming request; a cookie you just set is not there yet.
  • Call setcookie() before any output.
  • Default to Secure, HttpOnly, SameSite=Lax.
  • Match path and domain exactly when deleting.
  • Use __Host- for session cookies.
  • Sign anything users must not tamper with; keep sensitive data server-side.
  • Get consent before setting non-essential cookies.
Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers