Cookies: setcookie() and Secure Attributes

Set and read cookies with setcookie(), configure every security-relevant attribute (expiry, path, domain, Secure, HttpOnly, SameSite, and __Host-/__Secure- prefixes), and explain the privacy/security implications of each.

A cookie is a small piece of data your server asks the browser to store and send back on future requests to the same site — the mechanism that makes PHP sessions, 'remember me' logins, and preference storage (like a theme choice) possible across an otherwise stateless HTTP protocol. setcookie() is PHP's function for sending the Set-Cookie response header that creates, updates, or deletes a cookie, and it accepts a set of attributes that control exactly when the browser sends that cookie back, to which pages, and how protected it is from being read or tampered with by scripts or attackers. Getting these attributes right is a security fundamental — a misconfigured cookie can leak a session ID over an insecure connection, be read by a malicious script, or be forged by a subdomain you don't fully trust.

setcookie() Basics and Reading Cookies

setcookie(name, value, options) must be called before any output is sent (cookies are set via an HTTP header, and headers must precede the response body) — calling it after any echo, or after whitespace/HTML outside PHP tags, triggers a 'headers already sent' warning and the cookie silently fails to be set. As of PHP 7.3+, the third argument can be an associative array of options (expires, path, domain, secure, httponly, samesite) instead of the older positional-argument form, which is both clearer and the only way to set SameSite cleanly.

Once a cookie has been set and the browser sends it back on a later request, PHP exposes it via $_COOKIE['name'] — but only starting on the request after it was set; a cookie set during the current request is not retroactively added to $_COOKIE in that same request (you'd read it from the variable you already have). As with every superglobal, $_COOKIE values are attacker-controlled data and must be validated the same way any other external input is.

To delete a cookie, call setcookie() again with the same name, path, and domain as when it was created, an empty value, and an expires timestamp in the past — the browser then removes it. Mismatching the path/domain from the original setcookie() call is a very common bug: the deletion call creates a second, different cookie instead of actually removing the original.

• setcookie() must run before any output (headers-already-sent otherwise). • PHP 7.3+ array-options form: setcookie($name, $value, ['expires'=>..., 'path'=>..., 'domain'=>..., 'secure'=>..., 'httponly'=>..., 'samesite'=>...]). • $_COOKIE reflects cookies from previous requests, not ones just set in the current response. • Delete a cookie by re-setting it with the SAME name/path/domain, an empty value, and a past expires time.

expires, path, and domain

expires (a Unix timestamp, e.g. time() + 86400 for one day) makes a cookie 'persistent' — the browser keeps it on disk and sends it back until that time passes, surviving browser restarts. Omitting expires (or setting it to 0) creates a 'session cookie' that the browser only keeps in memory and discards when the browser itself closes — appropriate for things that genuinely should not outlive the browsing session, like a short-lived CSRF token cookie, though PHP's own session cookie behavior is separately controlled by session.cookie_lifetime.

path restricts which URL paths on your domain will have the cookie attached to their requests — path => '/' (the usual default) sends it on every request to the domain, while path => '/admin' would only attach it to requests under /admin/.... This is a scoping mechanism, not a security boundary on its own (anything with access to set cookies on the domain at all can still set an overlapping-path cookie), but it does reduce unnecessary cookie transmission and accidental cross-feature leakage.

domain controls which hostnames receive the cookie. Leaving it unset scopes the cookie to the exact host that set it; explicitly setting domain => 'example.com' makes the cookie also sent to every subdomain (shop.example.com, blog.example.com, ...). Widening the domain like this means every subdomain — including ones with weaker security practices or ones you don't control — can read (if not HttpOnly) and even overwrite that cookie, so it should only be done deliberately, never by default.

• expires => time() + N — persistent cookie, survives browser restart until it expires. • expires omitted/0 — session cookie, cleared when the browser closes. • path => '/' — sent on every request to the domain (typical default); narrower paths reduce unnecessary transmission. • domain unset — exact-host only (safer default); domain => 'example.com' — shared across all subdomains (wider attack surface).

Secure, HttpOnly, and SameSite

secure => true tells the browser to only ever send this cookie over an HTTPS connection, never plain HTTP — without it, a cookie set on a page served over HTTPS could still be sent in the clear if the visitor (or an attacker forcing a downgrade) later makes an HTTP request to the same host, exposing the cookie to anyone able to observe network traffic. Every cookie carrying anything sensitive (a session ID, an auth token) should set secure => true on any site served over HTTPS, which in production should be all of them.

httponly => true tells the browser to withhold the cookie from JavaScript entirely — document.cookie simply won't include it. This is one of the most effective mitigations against session-hijacking via Cross-Site Scripting (XSS): even if an attacker manages to inject a script into your page, an HttpOnly session cookie can't be read and exfiltrated by that script. It should be set on essentially every cookie that isn't specifically meant to be read by client-side JS.

samesite controls whether the cookie is sent along with cross-site requests (e.g. a request triggered by a link, form, or script on a different site) and is a direct defense against CSRF. SameSite=Strict never sends the cookie on a cross-site request, even a top-level navigation (a user clicking a link from another site to yours won't have the cookie on that first request — the safest but occasionally-annoying-for-login-flows setting). SameSite=Lax (the modern browser default when unspecified) sends it on top-level cross-site navigations (like following a link) but not on cross-site subrequests like an <img>, background fetch, or an auto-submitting form — a good default for most session cookies. SameSite=None sends it on every cross-site request regardless, and modern browsers require it to be paired with secure => true or reject it outright — only needed for cookies that legitimately must work across sites, like an embedded third-party widget.

• secure => true — cookie only sent over HTTPS; required for anything sensitive. • httponly => true — cookie hidden from JavaScript/document.cookie; major XSS-session-theft mitigation. • samesite => 'Strict' — never sent cross-site (safest, can break some login/link flows). • samesite => 'Lax' — sent on top-level navigations only, not subrequests (good general default). • samesite => 'None' — sent on all cross-site requests; MUST be paired with secure => true.

__Host- / __Secure- Prefixes and Privacy

Cookie name prefixes are a browser-enforced way to guarantee certain attributes were actually set, closing a gap where a subdomain or a network attacker on plain HTTP could otherwise set a cookie that collides with and overrides one your secure page set. A cookie named __Secure-session will only be accepted by the browser if it also has secure => true — the prefix itself is meaningless without the browser checking that guarantee at set-time. A cookie named __Host-session is stricter still: it requires secure => true, forbids setting a domain at all (so it's locked to the exact host, no subdomain sharing), and requires path => '/' — making it effectively impossible for another origin or subdomain to shadow or overwrite that cookie.

Beyond the technical attributes, cookies raise genuine privacy considerations: any cookie that persists (has expires in the future) and is sent on every request lets a site — and any third party whose script runs on that site — recognize the same visitor across multiple visits, which is the mechanism behind most cross-site tracking. Regulations like GDPR and ePrivacy/CCPA generally require informed consent before setting non-essential cookies (analytics, advertising) and distinguish them from strictly necessary cookies (session/auth/CSRF) that don't require consent to function. Minimizing what you store in a cookie (an opaque session ID rather than personal data directly), setting the shortest reasonable expiry, and being honest in a cookie/privacy policy about what's set and why are basic responsible practice, independent of the specific legal regime that applies.

• __Secure- prefix — browser enforces secure=>true was set on the cookie. • __Host- prefix — browser enforces secure=>true, no domain set, and path=>'/'; strongest protection against subdomain cookie-shadowing attacks. • Persistent, widely-sent cookies enable cross-visit/cross-site tracking — a privacy concern, not just a security one. • Store an opaque session identifier in the cookie, not personal data directly; keep expiry as short as the use case allows.

setcookie() — Full Secure Configuration

Must run before ANY output — no echo, no whitespace outside <?php ?> tags, no error output first. The __Host- prefix requires secure=>true, no domain key at all, and path=>'/' or the browser silently refuses the cookie. samesite is only clean to set via the PHP 7.3+ array-options form of setcookie().

setcookie('__Host-session', $sessionId, [
    'expires'  => time() + 3600,   // 1 hour from now
    'path'     => '/',             // required by the __Host- prefix
    // 'domain' must be OMITTED for __Host- cookies
    'secure'   => true,            // HTTPS only
    'httponly' => true,            // hidden from JavaScript
    'samesite' => 'Lax',           // sent on top-level nav, not cross-site subrequests
]);

// Deleting a cookie: same name/path/domain, empty value, past expiry
setcookie('theme', '', ['expires' => time() - 3600, 'path' => '/']);

Code Examples

A session-lifetime (expires => 0), HTTPS-only, JS-invisible, CSRF-resistant cookie using the __Host- prefix, which forces no domain sharing and locks the cookie to path '/' on the exact host. Expected result: Session cookie set.

setcookie('__Host-app_session', $sessionId, ['expires' => 0, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);

For a non-sensitive UI preference that client-side JS legitimately needs to read (e.g. to flip a class on <html> before the page finishes loading), httponly is deliberately left false — but the value is still whitelisted against known-good options before being trusted, and secure/samesite are still set. Whitelisting the theme value matters even for something 'harmless' — an unvalidated cookie value that later gets echoed into a page (e.g. as a CSS class name) without escaping could still become an XSS vector.

$theme = in_array($requested, $allowedThemes, true) ? $requested : 'light';

Cookie values read back from $_COOKIE are validated the same as any other external input; deleting the __Host-app_session cookie re-sends the exact same path and required attributes (secure) with a past expiry so the browser actually removes it, paired with session_destroy() for the server-side session data.

setcookie('__Host-app_session', '', ['expires' => time() - 3600, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);

Common mistake: Calling setcookie('__Host-app_session', '', ['expires' => time() - 3600]) without repeating path/secure — the browser won't recognize it as the same cookie, so the original session cookie is never actually deleted.

<?php
declare(strict_types=1);

$sessionId = bin2hex(random_bytes(32));

setcookie('__Host-app_session', $sessionId, [
    'expires'  => 0,          // session cookie: cleared when the browser closes
    'path'     => '/',
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

echo "Session cookie set.\n";
<?php
declare(strict_types=1);

$allowedThemes = ['light', 'dark'];
$requested = $_POST['theme'] ?? 'light';
$theme = in_array($requested, $allowedThemes, true) ? $requested : 'light';

setcookie('theme', $theme, [
    'expires'  => time() + 60 * 60 * 24 * 365, // 1 year
    'path'     => '/',
    'secure'   => true,
    'httponly' => false, // client-side JS is allowed to read this one, on purpose
    'samesite' => 'Lax',
]);

echo "Theme preference saved as '{$theme}'.\n";
<?php
declare(strict_types=1);

// Reading: still untrusted client data — validate before use
$theme = $_COOKIE['theme'] ?? 'light';
if (!in_array($theme, ['light', 'dark'], true)) {
    $theme = 'light';
}
echo "Rendering with theme={$theme}\n";

// Logging out: delete the session cookie by matching name/path exactly
// and any attributes the browser requires (e.g. secure for __Host-).
if (($_GET['action'] ?? '') === 'logout') {
    setcookie('__Host-app_session', '', [
        'expires'  => time() - 3600,
        'path'     => '/',
        'secure'   => true,
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
    session_destroy();
    echo "Logged out.\n";
}

Cookie Attribute Decision Guide

Start from 'secure + httponly + SameSite=Lax' for anything sensitive, and only loosen an attribute for a specific, understood reason.

Does the cookie hold anything sensitive (session ID, auth token)?
    │
    ├─ YES ─▶ secure=true, httponly=true, samesite=Lax (or Strict)
    │          consider __Host- prefix (no domain, path="/", secure required)
    │
    └─ NO (e.g. a UI preference) ─▶ secure=true still recommended,
               httponly can be false IF client JS genuinely needs to read it,
               ALWAYS whitelist/validate the value when reading it back

  Does it need to work embedded on/from another site (widget, cross-site iframe)?
    │
    ├─ YES ─▶ samesite=None  (browsers REQUIRE secure=true alongside it)
    └─ NO  ─▶ samesite=Lax (general default) or Strict (tightest, may affect login links)

Important Rules

Call setcookie() before any output — no echoed text or stray whitespace/HTML before it.

Set secure=>true on every cookie once your site is served over HTTPS (which should be always in production).

Set httponly=>true on any cookie that doesn't specifically need to be read by client-side JavaScript, especially session/auth cookies.

Choose samesite deliberately: Lax as a sane default, Strict for maximum CSRF resistance, None only when cross-site use is required (and always paired with secure=>true).

Prefer the __Host- prefix for session cookies when possible — it forces no domain sharing and path="/", closing subdomain cookie-shadowing attacks.

Delete a cookie by re-setting the exact same name/path/domain/required-attributes with an empty value and a past expires time.

Treat $_COOKIE values as untrusted input on read, the same as any other superglobal.

Cookies, Sessions, and Consent Banners

In practice, PHP's built-in session mechanism (session_start() / $_SESSION) sets its own cookie under the hood, configurable via session.cookie_secure, session.cookie_httponly, and session.cookie_samesite ini directives (or session_set_cookie_params()), so the same attribute reasoning from this lesson applies whether you call setcookie() yourself or let PHP's session machinery do it. The 'consent banner' visitors see on most modern sites exists specifically because of the privacy angle here: strictly-necessary cookies (session, CSRF, load-balancer affinity) generally don't need consent, but anything used for analytics, advertising, or cross-site tracking usually does under GDPR/ePrivacy and similar laws — which is why real applications carefully separate what's 'essential' from what isn't before a single line of setcookie() code runs.

setcookie('session_id', $sessionId, time() + 3600);

setcookie('__Host-session_id', $sessionId, [ 'expires' => time() + 3600, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ]);

The bare form sets no secure, httponly, or samesite attributes at all — the session cookie would be sendable over plain HTTP, readable by any injected JavaScript (a full XSS-to-session-hijack path), and attachable to cross-site requests, defeating the point of protecting a session identifier.

// Set during login on www.example.com: setcookie('auth', $token, ['path' => '/', 'domain' => 'example.com']); // Logout tries to clear it: setcookie('auth', '', ['expires' => time() - 3600]);

// Logout must repeat the SAME domain and path used at set-time: setcookie('auth', '', [ 'expires' => time() - 3600, 'path' => '/', 'domain' => 'example.com', ]);

Because the original cookie was scoped to domain=>'example.com', a deletion call that omits domain targets a different cookie (scoped to the exact host only) — the real 'auth' cookie for example.com is never removed, leaving the visitor's browser still holding a live, unexpired auth cookie.

[multiple-choice] What does the httponly attribute on a cookie protect against? A. The cookie being sent over an insecure HTTP connection B. The cookie being sent on cross-site requests C. Client-side JavaScript (e.g. injected via XSS) reading the cookie's value via document.cookie D. The cookie expiring too soon

C. Client-side JavaScript (e.g. injected via XSS) reading the cookie's value via document.cookie — httponly hides the cookie from JavaScript entirely — document.cookie will not include it — which is one of the strongest defenses against an XSS vulnerability being used to steal a session cookie.

[multiple-choice] What extra restrictions does the __Host- cookie name prefix enforce, compared to __Secure-? A. It requires httponly=>true in addition to secure=>true B. It requires secure=>true, forbids setting a domain at all, and requires path=>'/' C. It makes the cookie last forever regardless of the expires value D. It disables SameSite protection

B. It requires secure=>true, forbids setting a domain at all, and requires path=>'/' — __Secure- only enforces secure=>true. __Host- is stricter: it also forbids any 'domain' attribute (locking the cookie to the exact host, no subdomain sharing) and requires path=>'/', which closes off subdomain cookie-shadowing attacks.

[true-false] True or false: setting samesite => 'None' is allowed by modern browsers without also setting secure => true. A. True B. False

B. False — Modern browsers require SameSite=None cookies to also be marked Secure; a cookie declaring SameSite=None without Secure is rejected outright.

Write PHP that: (1) on a POST to /login.php with valid credentials, generates a random session token and sets it as a cookie named __Host-auth with a 2-hour expiry, path '/', secure, httponly, and SameSite=Lax; (2) on GET /logout.php, deletes that cookie correctly (matching path/attributes) and also calls session_destroy(); (3) anywhere the app reads a 'lang' preference cookie, validates it against an allowed list ['en','es','fr'] before using it, falling back to 'en'. Hint: Remember __Host- forbids setting 'domain' at all, and the delete call must repeat path=>'/' and secure=>true or the browser won't match it to the original cookie.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.