The browser holds a cookie and the server holds the state the cookie stands for. Confusing the two — or letting the cookie carry more meaning than it should — is where authentication bugs start.

A session cookie is usually a high-entropy random value the server mints, stores server-side, and later looks up to find the matching session. It exists only as a pointer: the cookie identifies a session; the session holds the authority. That split matters because the browser sees and can re-send the cookie verbatim on every request, so putting meaningful data inside it — a user id, a role, an email — means giving the client every value it would need to forge or tamper with state. Keep the cookie opaque: a long random string with no structure the client is meant to read, and let the server resolve it to the actual privileges.

Define the attributes honestly

Each cookie attribute is a distinct boundary and must be set deliberately, not left to a browser default. Secure restricts transmission to HTTPS so the value is not sent in the clear; HttpOnly stops JavaScript from reading it via document.cookie, which neutralizes the most common XSS payload; SameSite controls whether the cookie is attached to a cross-site request — Lax for most sites, Strict where a session must never ride requests from another origin; and Max-Age/Expires bounds how long the token lives. A short, session-scoped life with rotation on privilege changes is the honest default. Set each one explicitly because each default the browser chooses is a policy you did not choose.

The session is the server’s obligation

The cookie is only the pointer; the server owns the session and must decide how long it lives, when it is idle-expired, and what causes it to be invalidated. Bind the session to real signals: an absolute expiry so a session does not live forever, an idle timeout so an abandoned tab cannot be resumed weeks later, and server-side revocation — delete or mark the session when the user logs out so the cookie becomes a dud rather than a standing key. Because the token can still be exfiltrated, also rotate it after login and privilege change. When the cookie stays opaque and the server enforces these rules, the pair remains a small, honest contract rather than a time bomb.

When it breaks

A broad Domain turns one bug into every subdomain’s problem. With Domain=example.com, the session rides to static hosts and logs that never needed it, so XSS on a forgotten subdomain replays the main login. A shallow Path=/ leaks the same way inside one host, and Path never replaces server-side authorization.

Fixation is the second break. If the server accepts an attacker-planted id or keeps the pre-login value after authentication, that value owns the later login. Without absolute expiry, idle timeout, and logout revocation, the cookie stays a standing key. Add SameSite=None without Secure, or leave state changes on GET, and a cross-site request carries the cookie exactly when it should not.

Bloat fails quietly. Roles or a JWT in the cookie push it past about four kilobytes or the eight-kilobyte header limit, so real requests die with 400 or 431 while every request pays the weight. Missing Secure leaks on plain HTTP; missing HttpOnly hands the value to any injected script.

Check it quickly

Save the snippet as cookie_boundary_demo.py and run python3 cookie_boundary_demo.py. It uses only http.cookies, opens no sockets, and writes no files. Each Set-Cookie string is parsed into domain, path, and flags, then should_send decides whether a host, path, and transport would receive it.

from http.cookies import SimpleCookie


def parse(header: str) -> dict:
    jar = SimpleCookie()
    jar.load(header)
    name = next(iter(jar))
    m = jar[name]
    return {
        "name": name,
        "value": m.value,
        "domain": m["domain"].lstrip(".").lower(),
        "path": m["path"] or "/",
        "secure": bool(m["secure"]),
        "httponly": bool(m["httponly"]),
        "samesite": (m["samesite"] or "Lax").lower(),
    }


def domain_ok(cookie_domain: str, host: str) -> bool:
    host = host.lower()
    return host == cookie_domain or host.endswith("." + cookie_domain)


def path_ok(cookie_path: str, req_path: str) -> bool:
    if req_path == cookie_path:
        return True
    if req_path.startswith(cookie_path):
        if cookie_path.endswith("/"):
            return True
        if len(req_path) > len(cookie_path) and req_path[len(cookie_path)] == "/":
            return True
    return False


def should_send(cookie: dict, raw_domain: str, origin: str,
                host: str, req_path: str, is_https: bool) -> bool:
    if not raw_domain:
        if host.lower() != origin.lower():
            return False
    elif not domain_ok(cookie["domain"], host):
        return False
    if not path_ok(cookie["path"], req_path):
        return False
    if cookie["secure"] and not is_https:
        return False
    if cookie["samesite"] == "none" and not cookie["secure"]:
        return False
    return True


def check(cond: bool, msg: str) -> None:
    if not cond:
        raise AssertionError(msg)
    print(f"ok: {msg}")


ORIGIN = "app.example.com"
good = parse("sid=abc123; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600")
check(good["secure"] and good["httponly"] and good["samesite"] == "lax",
      "good cookie carries Secure/HttpOnly/SameSite=Lax")
check(should_send(good, "", ORIGIN, ORIGIN, "/dashboard", True),
      "host-only cookie sent to same host over HTTPS")
check(not should_send(good, "", ORIGIN, "evil.com", "/dashboard", True),
      "host-only cookie rejected for evil.com")
check(not should_send(good, "", ORIGIN, ORIGIN, "/dashboard", False),
      "Secure cookie rejected over plain HTTP")

broad = parse("sid=abc123; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax")
check(should_send(broad, "example.com", ORIGIN, "shop.example.com", "/", True),
      "Domain=example.com reaches subdomain")
check(not should_send(broad, "example.com", ORIGIN, "evil.com", "/", True),
      "Domain cookie rejected for evil.com")
check(not should_send(broad, "example.com", ORIGIN, "example.com.evil.com", "/", True),
      "suffix trick example.com.evil.com rejected")

admin = parse("adm=1; Path=/admin; Secure; HttpOnly; SameSite=Strict")
check(should_send(admin, "", ORIGIN, ORIGIN, "/admin/users", True),
      "Path=/admin sent under /admin/users")
check(not should_send(admin, "", ORIGIN, ORIGIN, "/public", True),
      "Path=/admin rejected for /public")

bad = parse("sid=leaked; SameSite=None")
check(bad["samesite"] == "none" and not bad["secure"],
      "SameSite=None without Secure detected")
check(not should_send(bad, "", ORIGIN, ORIGIN, "/", True),
      "browser would drop SameSite=None without Secure")

plain = parse("pref=light; Path=/; Secure; SameSite=Lax")
check(not plain["httponly"], "missing HttpOnly is visible to document.cookie")
check(len("a=" + "x" * 5000) > 4096, "oversized cookie detected (5002 bytes > 4096)")

print("PASS: 13 checks; out-of-scope Domain/Path and insecure transport rejected")

Thirteen checks cover those boundaries. A host-only cookie reaches its own host over HTTPS but is refused for evil.com and plain HTTP. A Domain=example.com cookie reaches a subdomain yet rejects evil.com and example.com.evil.com. A Path=/admin cookie stays under /admin/users and is refused for /public. SameSite=None without Secure is flagged as dropped, missing HttpOnly is reported as script-readable, and an oversized value trips the size warning. Expect PASS: 13 checks; out-of-scope Domain/Path and insecure transport rejected. The demo checks matching rules, not browser storage or your logout path, so repeat the probes against a real response before shipping.

Further reading

Sources