HttpOnly cookie and cookie flags: a local login experiment

A login endpoint in our fixture issued its session cookie as sid=ID; Domain=example.test; Path=/. That header has no HttpOnly, no Secure and no SameSite attribute, and the Domain attribute extends the cookie to every subdomain of example.test. We recorded the header with curl and audited each attribute with a small parser. The fixed mode names the cookie __Host-sid and adds Secure, HttpOnly and SameSite=Lax, and the audit then reports no problems while the login still works.

This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.

HttpOnly cookie: what each cookie flag does

An HttpOnly cookie is a cookie whose Set-Cookie header carries the HttpOnly attribute. MDN says the attribute forbids JavaScript from accessing the cookie, for example through document.cookie, and that the browser still sends the cookie with requests that script starts, such as fetch(). RFC 6265 section 4.1.2.6 says the attribute limits the cookie to HTTP requests and tells the user agent to omit it from non-HTTP APIs, such as script access. The flag limits what a script in the page can read. It does not stop the script from running.

  • Secure: the browser sends the cookie only over a secure channel. MDN describes this as the https: scheme, except on localhost. RFC 6265 section 4.1.2.5 says a secure cookie protects only confidentiality, and a network attacker can still overwrite it from an insecure channel. The HSTS post covers the header that keeps browsers on HTTPS.
  • SameSite: controls whether the browser sends the cookie on cross-site requests. MDN says some browsers treat a missing attribute as Lax, so the audit reports "not set". The CSRF and SameSite post records what Chromium sends for each value.
  • Domain: if omitted, the cookie returns only to the host that set it. If present, MDN says the cookie goes to that domain and its subdomains.
  • Path: MDN says it is not a security measure.
  • __Host- prefix: MDN requires the Secure attribute, a secure origin, no Domain attribute and Path=/. The 6265bis draft (draft-ietf-httpbis-rfc6265bis-22, an Internet-Draft that is not yet an RFC) lists the same conditions. MDN warns that browsers without prefix support accept the cookie anyway.

Scope of the fixture

  • Target: two short-lived HTTP servers bound to IPv4 loopback, 127.0.0.1. The runner selects free ports.
  • Host name: curl's --resolve maps app.example.test to the loopback address, so the Domain attribute has a host to apply to.
  • Requests: ten curl requests, POST /login and GET /account, five against each mode.
  • Data: user alice, the public demo password demo-password and a random in-memory session ID.
  • Transport: plain HTTP, because the standard library server has no TLS. The header is what an application behind a TLS-terminating proxy would send.

Both servers listen on loopback only. For a real test, agree hosts, accounts and stopping conditions with the system owner first. The Excession Labs Acceptable Use Policy describes authorized use, and this article gives no permission to test another system.

Run the downloadable fixture

Download fixture.py and verify.py into the same directory. You need Python 3.9 or later and curl. Inspect the source, then run:

python3 verify.py --output rerun

The output directory must be new. The runner starts each server in turn, sends five real curl requests, saves every raw response and stops the server. Each request bypasses proxies, has a three-second total timeout and does not follow redirects. No model call or paid API is needed.

Our recorded command was python3 verify.py --output recorded-run. It exited with 0 and printed {"checks": 20, "passed": true, "failures": []}. All 20 checks matched their expected outcomes, including the intentionally weak header. The run used Python 3.15.2 and curl 8.7.1 on macOS at 10:10 UTC on 11 November 2026, with the vulnerable server on port 55868 and the fixed server on port 55875. A rerun uses different ports and IDs. Versions, commands and source hashes are in results.json.

Observed headers, audit and status codes

This is the Set-Cookie line from each login response, copied from the recorded .http files:

vulnerable: Set-Cookie: sid=2f265d07d6b69a252a14a1ada46cb791; Domain=example.test; Path=/
fixed:      Set-Cookie: __Host-sid=8884ed1ca9344caf19d0e0e691659c1b; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=Lax

The parser in verify.py splits each header into a name and attributes. Python's http.cookies.SimpleCookie parsed the same headers, and the runner checked that both agree on HttpOnly, Secure, SameSite and Domain.

Attributes audited in the recorded Set-Cookie headers
AttributeVulnerable modeFixed mode
Cookie namesid__Host-sid
HttpOnlyabsentpresent
Secureabsentpresent
SameSiteabsentLax
Domainexample.testabsent (host-only)
Path//
Max-Ageabsent (session cookie)3600
Audit problems50

The five problems in vulnerable mode are the missing HttpOnly, Secure and SameSite attributes, the Domain that shares the cookie with subdomains and the missing __Host- prefix. The audit does not count Path, which the prefix requires to be / and MDN says is no security boundary.

The status codes act as controls, since a hardened cookie must not break login. The API authorization testing post uses the same control pattern.

Observed HTTP status codes from ten local curl requests
RequestExpectedVulnerable modeFixed mode
POST /login, correct password200 and one Set-Cookie200, 1 Set-Cookie200, 1 Set-Cookie
POST /login, wrong password401 and no cookie401, none401, none
GET /account with the issued cookie200200200
GET /account with no cookie401401401
GET /account with a forged cookie value401401401

curl sent the cookie in a hand-written Cookie header, which bypasses the Secure rule, so the fixed-mode row shows only that the server accepts the renamed cookie.

What Python models and what only a browser enforces

Python's http.cookiejar applies Domain and Secure send rules. Its documentation says it follows the de-facto Netscape cookie protocol, so treat it as a rough model of a browser. We fed it each recorded header and asked whether it would send the cookie to four URLs.

Would Python's http.cookiejar send the cookie? (a model, not a browser)
Request URLVulnerable modeFixed mode
http://app.example.test:PORT/account (same host, plain HTTP)sentnot sent
https://app.example.test/account (same host, HTTPS)sentsent
https://other.example.test/account (sibling host)sentnot sent
https://unrelated.test/accountnot sentnot sent

The vulnerable cookie reaches a sibling host and travels over plain HTTP. The fixed cookie is host-only and waits for HTTPS. RFC 6265 section 8.6 notes that sibling domains get no integrity guarantee, so one subdomain can overwrite a cookie that another reads. Browsers differ from this model, for example in the localhost exception MDN describes for Secure.

The model has two gaps, and the run records both. It kept the cookie __Host-x=1; Domain=example.test; Path=/ and sent it to a sibling host, so http.cookiejar does not enforce prefixes. A prefix-aware browser would reject that cookie, and our audit flags it from the header text. Python also has no script API. SimpleCookie parses the HttpOnly attribute, and verify.py lists the cookie names a script could read if a browser applied RFC 6265 section 4.1.2.6: sid in vulnerable mode, none in fixed mode. That list is our model of the rule.

Only a real browser hides the cookie from document.cookie, applies Secure over real HTTPS, enforces SameSite and rejects bad prefixes. To check HttpOnly, log in to an HTTPS deployment, open the browser console and read document.cookie. An HttpOnly cookie does not appear.

Verify a cookie finding before reporting it

Frameworks and proxies change headers, so a cookie finding needs evidence from the real system. Before you report one:

  1. Capture the Set-Cookie header from a real login response, over the channel users use. A header read from configuration is weaker evidence.
  2. Confirm that the cookie authenticates. In the fixture the request with the cookie returned 200 and the request without it returned 401. A missing HttpOnly flag on an analytics cookie is not a session finding.
  3. For HttpOnly, confirm in a browser that document.cookie lists the cookie.
  4. State impact only as far as you showed it. This experiment shows missing attributes and no stolen cookie.

Our decision rule: report a missing HttpOnly or Secure flag on a session cookie as a hardening finding, with the header as evidence. Raise the severity only when a separate finding shows script execution in the page or plain HTTP exposure. A session ID that survives login unchanged is a different flaw, covered in the session fixation post. This fixture issues a new ID at every login.

Fix the cookie flags and retest

One function in fixture.py holds the change:

def session_cookie(mode, token):
    if mode == "vulnerable":
        return "sid=" + token + "; Domain=example.test; Path=/"
    return "__Host-sid=" + token + "; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=Lax"

Each attribute answers one finding. HttpOnly hides the cookie from scripts, Secure keeps it off plain HTTP, and SameSite=Lax withholds it from most cross-site requests. Dropping Domain makes the cookie host-only, and the __Host- prefix asks the browser to reject a cookie of that name that carries a Domain or another Path. Max-Age=3600 is a demo value; use your own session policy. SameSite=Strict sends the cookie only on same-site requests.

The server reads only __Host-sid in fixed mode, so an old sid cookie no longer authenticates. In a real rollout that signs users out, so plan for it.

The retest used the same five requests. The audit reported no problems, login returned 200 with one Set-Cookie header, a wrong password, a missing cookie and a forged cookie still returned 401, and the model sent the cookie only to https://app.example.test.

Inspect the evidence bundle

The fixture is also on GitHub: web-security-fixtures.

The raw responses contain random session IDs from servers that no longer exist.

What this experiment does not cover

Ten requests against one toy server show how a header reads and how two Python modules treat it. We did not run a browser, use HTTPS, or test SameSite behavior, partitioned cookies, duplicate cookie names, logout, expiry, session rotation or any framework's cookie defaults. A clean audit here cannot show that another application sets its cookies safely.

Excession Labs checks cookie attributes and session handling in its security assessments, on systems whose owners have agreed the scope.