HTTP has no memory

HTTP is a stateless protocol: the server treats every request as independent and keeps no memory of earlier ones from the same client. Even on a reused connection, request number ten carries no trace of the first nine. That design is deliberate. Any server in a cluster can answer any request, a crashed server loses nothing a client depends on, and caches and proxies can handle requests without knowing their history.

But a customer portal must know that the person asking for /account is the one who logged in a minute ago, and a shop must remember the basket. The solution is to make each request carry the state, or a reference to it. Cookies are the standard way to do that in browsers.

Cookies

A cookie is a small name-value pair that a server asks the browser to store with a Set-Cookie response header, and that the browser sends back in a Cookie request header on later requests to the same site. The mechanism is defined in RFC 6265 (2011); its successor, known as RFC 6265bis, which also covers SameSite and cookie prefixes, is in the RFC Editor queue as of 2026 but has no RFC number yet.

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Set-Cookie: __Host-session=k7Qm2xR9vL…; Path=/; Secure; HttpOnly; SameSite=Lax
Set-Cookie: theme=dark; Max-Age=31536000; Path=/; Secure; SameSite=Lax
GET /account HTTP/1.1
Host: portal.example.com
Cookie: __Host-session=k7Qm2xR9vL…; theme=dark

Each cookie gets its own Set-Cookie header, with attributes after the value. The browser returns only names and values; the attributes are instructions for the browser, and the server never sees them again. Browsers limit cookies to roughly 4 KB each, and every matching cookie travels with every request, so keep them small.

Attribute Effect
Domain Omitted: the cookie is host-only and goes back only to the exact host that set it. Set to example.com: it also goes to every subdomain. Leaving it out is the safer choice.
Path Only send for URLs under this path. Use Path=/; it is not a security boundary.
Expires / Max-Age When the cookie expires: a date, or a number of seconds. If both are set, Max-Age wins. With neither, it is a session cookie that disappears when the browser closes (although browsers that restore your previous session may keep it). Max-Age=0 deletes a cookie.
Secure Only send over HTTPS.
HttpOnly Hide the cookie from JavaScript (see below).
SameSite Strict, Lax or None: whether to send the cookie on requests that come from another site (see below).
Partitioned Store a third-party cookie separately per top-level site (CHIPS, see third-party cookies). Requires Secure.

HttpOnly

The HttpOnly attribute tells the browser to keep a cookie out of reach of JavaScript: document.cookie does not show it, but the browser still sends it with every matching request, including requests made by scripts with fetch. If an attacker manages to inject a script into the page (cross-site scripting), they cannot simply read the session ID and use it elsewhere. HttpOnly limits the damage; it does not stop the injected script from acting within the page, so it is no replacement for preventing XSS.

SameSite

The SameSite attribute controls whether a cookie is sent on cross-site requests: requests to your site that are triggered by a page on another site. "Site" here means the registrable domain, so portal.example.com and api.example.com are the same site, while example.net is not.

  • Strict: never sent on cross-site requests. Even following a link from webmail or another website (including a newsletter's click-tracking redirect) arrives without the cookie, so the user first appears logged out.
  • Lax: sent when the user navigates to your site with a top-level GET (clicking a link), but not on cross-site POSTs, or when another site loads your pages in a frame or calls them with fetch. A good default for session cookies.
  • None: always sent, also cross-site. Required for cookies that must work inside embedded content on other sites, and only allowed together with Secure.

SameSite is the browser's main built-in defense against cross-site request forgery (CSRF). Chrome and other Chromium-based browsers (Edge, Opera) have treated cookies without a SameSite attribute as Lax since 2020. Firefox and Safari do not: Firefox tried the same change and rolled it back because sites broke. So never rely on the default. Always set SameSite explicitly, and keep CSRF tokens or Origin checks for state-changing requests as well.

A cookie name can start with a prefix that makes the browser enforce rules, so a cookie cannot be overwritten with a weaker one by a subdomain or an insecure page:

  • __Secure-: only accepted with Secure, from an HTTPS page.
  • __Host-: additionally requires Path=/ and no Domain, so the cookie is locked to exactly one host. The best choice for a session cookie.
  • Newer prefixes, __Http- (requires Secure and HttpOnly) and __Host-Http- (both sets of rules), are listed by MDN but browser support is still uneven as of 2026.

Browsers that do not know a prefix accept the cookie anyway, they just do not enforce its rules; so a prefix adds protection without breaking anything.

Python's http.cookies module parses and builds Set-Cookie headers. The script reviews three headers as you might when auditing a site, then builds a good session cookie. (For an HTTP client that stores and sends cookies, Python has http.cookiejar.)

from http.cookies import SimpleCookie

# Set-Cookie headers as three different servers might send them
headers = [
    "__Host-session=k7Qm2xR9vL; Path=/; Secure; HttpOnly; SameSite=Lax",
    "theme=dark; Max-Age=31536000; Path=/; SameSite=Lax",
    "session=k7Qm2xR9vL; Domain=example.com; Path=/",
]

for header in headers:
    cookie = SimpleCookie()
    cookie.load(header)
    for name, morsel in cookie.items():
        if morsel["max-age"] or morsel["expires"]:
            print(f"{name}: persistent (Max-Age={morsel['max-age']})")
        else:
            print(f"{name}: deleted when the browser closes")
        if "session" not in name:
            continue                      # only judge session-ID cookies
        problems = []
        if not morsel["secure"]:
            problems.append("no Secure: also sent over plain HTTP")
        if not morsel["httponly"]:
            problems.append("no HttpOnly: JavaScript can read it")
        if not morsel["samesite"]:
            problems.append("no SameSite: the default differs per browser")
        if morsel["domain"]:
            problems.append(f"Domain={morsel['domain']}: sent to all subdomains too")
        for problem in problems or ["OK for a session ID"]:
            print("  -", problem)

# Building a good session cookie on the server side
cookie = SimpleCookie()
cookie["__Host-session"] = "k7Qm2xR9vL"
cookie["__Host-session"]["path"] = "/"
cookie["__Host-session"]["secure"] = True
cookie["__Host-session"]["httponly"] = True
cookie["__Host-session"]["samesite"] = "Lax"
print(cookie.output())
__Host-session: deleted when the browser closes
  - OK for a session ID
theme: persistent (Max-Age=31536000)
session: deleted when the browser closes
  - no Secure: also sent over plain HTTP
  - no HttpOnly: JavaScript can read it
  - no SameSite: the default differs per browser
  - Domain=example.com: sent to all subdomains too
Set-Cookie: __Host-session=k7Qm2xR9vL; HttpOnly; Path=/; SameSite=Lax; Secure

The third header is the kind of thing you still find on older applications: the session ID would travel over plain HTTP, be readable by any script on the page, follow the default SameSite behavior of whichever browser is used, and be sent to every subdomain, including ones run by a marketing agency.

Server-side sessions

A session is the server's memory of one user's ongoing interaction: who is logged in, when, with what rights, what is in the basket. The data lives on the server, in memory, a database or a store such as Redis. The browser only gets a session ID: a long, random, unguessable value that the server uses as the key to look the session up. Whoever presents the ID is treated as that user, which makes the ID as valuable as the password while the session lasts.

A server-side session. 1: the browser posts the login form to the web server. 2: the server checks the credentials, creates a session with a new random ID and stores it in the session store. 3: the server answers with Set-Cookie containing only the session ID. 4: on a later request the browser sends the cookie automatically. 5: the server looks up the ID in the session store. 6: the store returns the session data, customer 1042. 7: the server returns the account page. Browser Web server Session store 1. POST /login 2. new random ID k7Qm… → customer 1042 3. Set-Cookie: k7Qm… 4. GET /account Cookie: k7Qm… 5. look up k7Qm… 6. customer 1042 7. 200 account page
The browser only ever holds the ID; who the user is and what they may do stays on the server.

The script below plays out that flow with a tiny server whose session store is a Python dictionary. The client uses http.client and carries the cookie by hand, which is exactly what a browser does automatically. Because the test runs over plain HTTP on your own machine, the cookie lacks Secure and the __Host- prefix (both require HTTPS); the comment shows the production version.

import secrets
import threading
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

sessions = {}  # session ID -> data; lives on the server, not in the browser


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def session_id(self):
        for part in self.headers.get("Cookie", "").split(";"):
            name, _, value = part.strip().partition("=")
            if name == "session":
                return value
        return None

    def reply(self, status, text, extra=()):
        body = text.encode("utf-8")
        self.send_response(status)
        for name, value in extra:
            self.send_header(name, value)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if self.path == "/login":       # (password check left out)
            sid = secrets.token_urlsafe(32)  # long and random
            sessions[sid] = {"customer": 1042}
            # Local test over plain HTTP, so no Secure flag; in production:
            # __Host-session=...; Path=/; Secure; HttpOnly; SameSite=Lax
            cookie = f"session={sid}; Path=/; HttpOnly; SameSite=Lax"
            self.reply(200, "Welcome\n", [("Set-Cookie", cookie)])
        elif self.path == "/logout":
            sessions.pop(self.session_id(), None)  # invalidate on the server
            self.reply(200, "Signed out\n",
                       [("Set-Cookie", "session=; Path=/; Max-Age=0")])

    def do_GET(self):
        data = sessions.get(self.session_id())
        if data is None:
            self.reply(401, "Please sign in\n")
        else:
            self.reply(200, f"Account of customer {data['customer']}\n")

    def log_message(self, format, *args):
        pass


server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
conn = HTTPConnection("127.0.0.1", server.server_address[1])


def call(method, path, cookie=None):
    headers = {"Cookie": cookie} if cookie else {}
    conn.request(method, path, body=b"" if method == "POST" else None, headers=headers)
    response = conn.getresponse()
    text = response.read().decode("utf-8").strip()
    print(f"{method} {path} -> {response.status} {text}")
    return response.getheader("Set-Cookie")


call("GET", "/account")                      # no cookie yet
set_cookie = call("POST", "/login")
name_value, attributes = set_cookie.split(";", 1)
print("  Set-Cookie: session=<random>;" + attributes)
browser_cookie = name_value                  # what a browser would store
call("GET", "/account", browser_cookie)      # the cookie identifies the session
call("POST", "/logout", browser_cookie)
call("GET", "/account", browser_cookie)      # old ID no longer works
print("Sessions on the server:", len(sessions))

conn.close()
server.shutdown()
server.server_close()
GET /account -> 401 Please sign in
POST /login -> 200 Welcome
  Set-Cookie: session=<random>; Path=/; HttpOnly; SameSite=Lax
GET /account -> 200 Account of customer 1042
POST /logout -> 200 Signed out
GET /account -> 401 Please sign in
Sessions on the server: 0

Two details matter. The logout deletes the session on the server; clearing the cookie in the browser alone would leave a stolen copy of the ID working. And the session ID comes from secrets.token_urlsafe(32), 32 random bytes, so it cannot be guessed. (Like every example in this topic, this server uses http.server, which its documentation says is not for production.)

Tokens instead of cookies?

Many single-page applications and mobile apps authenticate API calls with a token in the Authorization header rather than with a session cookie, often an OAuth access token in the form of a JSON Web Token (JWT). The trade-offs:

Session cookie Token in a header
Sent automatically Yes, so CSRF needs attention No, the code adds it; no CSRF by design
Readable by JavaScript No, with HttpOnly Yes: the code must hold it, so XSS can steal it
State on the server Yes, a session store Often none: a signed JWT is checked on its own
Revoking access Delete the session: immediate Hard before the token expires; keep lifetimes short
Works for non-browser clients Awkward Natural

For browser applications, a common recommendation is the "backend for frontend" pattern: a small server-side component holds the OAuth tokens and calls the APIs, while the browser only gets an HttpOnly session cookie. For scripts, services and mobile apps, tokens in headers are the norm; see API Authentication.

Web storage

Web storage is the browser's key-value store for scripts: localStorage keeps data per origin until it is deleted, and sessionStorage keeps it per tab until the tab closes. Unlike cookies, the values are never sent to the server automatically, and the limit is a few megabytes rather than a few kilobytes. It is good for user preferences, a draft message or a cached list of postcodes.

It is a poor place for secrets. Every script running on the page can read web storage: your own code, every third-party library and analytics snippet, and any script an attacker manages to inject. An access token in localStorage is one XSS bug away from being copied off the user's machine, and unlike a session it may stay valid for hours. If a token must live in the browser, keep it in memory, make it short-lived, and invest in preventing XSS with a Content Security Policy (see Browsers and the Same-Origin Policy).

Third-party cookies

A third-party cookie is a cookie belonging to a site other than the one in the address bar, set or sent when a page embeds content from that other site: an advertisement, a social media button, a video player, an analytics script. Because the same tracker is embedded on thousands of sites and sees its own cookie on all of them, third-party cookies can follow one person across the web. That is their main use, and the reason browsers restrict them.

  • Safari (Intelligent Tracking Prevention) and Firefox (Total Cookie Protection) block or partition third-party cookies by default.
  • Chrome planned for years to phase them out, but in 2024 and 2025 Google changed course. As of 2026 third-party cookies remain enabled by default in Chrome, with no removal date; users can block them in the settings. In October 2025 Google also retired most of its "Privacy Sandbox" replacement technologies, keeping CHIPS, FedCM (Federated Credential Management, for sign-in with an identity provider) and Private State Tokens.

CHIPS (Cookies Having Independent Partitioned State) is the legitimate-use answer: a cookie set with the Partitioned attribute is stored separately for each top-level site. An embedded chat widget can still remember its state on shop.example, but it gets a different cookie jar on news.example, so it cannot link the two visits.

Cookies and the law

In the European Union the rules for cookies come from the ePrivacy Directive (article 5(3)), and in the Netherlands from article 11.7a of the Telecommunicatiewet (Telecommunications Act). In short, as of 2026:

  • Storing or reading information on a user's device requires prior consent. This covers cookies, but also localStorage, device fingerprinting and similar techniques.
  • Exceptions exist for what is strictly necessary for the service the user asked for (a session cookie, a shopping basket, a load-balancer cookie) and, in the Dutch law, for analytics with little or no impact on privacy.
  • Valid consent follows the GDPR standard: freely given, specific, informed and unambiguous. Pre-ticked boxes and "by continuing to browse you agree" do not count, and refusing must be as easy as accepting.
  • When cookies identify people (tracking, profiles), the GDPR applies to the personal data as well.

The United States has no general rule that cookies need prior consent. The strongest rules come from state privacy laws, above all California's CCPA. In short, as of October 2026:

  • Cookies, device identifiers and IP addresses count as personal information under the CCPA when they can be linked to a person or household.
  • Instead of opting in, consumers can opt out of the "sale" or "sharing" of their personal information. Sharing means giving it to a third party for cross-context behavioral advertising, which is what most advertising cookies do. A business that sells or shares personal information must usually offer a "Do Not Sell or Share My Personal Information" link.
  • A business must treat the Global Privacy Control (GPC) browser signal as a valid opt-out request.
  • For children under 13, COPPA requires verifiable parental consent before an online service collects their personal information.

In the United Kingdom the rules for cookies are in regulation 6 of the Privacy and Electronic Communications Regulations (PECR), amended by the Data (Use and Access) Act 2025. In short, as of October 2026:

  • Storing or reading information on a user's device requires prior consent, as in the EU. This covers cookies, but also localStorage and similar techniques.
  • Since 5 February 2026 no consent is needed for what is strictly necessary (including security and fraud prevention). Nor is it needed for statistics to improve a service or for remembering a user's choices about how a site looks or works, provided users get clear information and a simple, free way to object.
  • Valid consent follows the UK GDPR standard: freely given, specific, informed and unambiguous. Pre-ticked boxes do not count.
  • The Information Commission (formerly the ICO) enforces PECR, with fines up to £17.5 million or 4% of worldwide turnover.

This is a summary for orientation, not legal advice: check with your privacy officer before deciding which cookies need a banner. The wider privacy picture is in Privacy and Data Protection.

Keeping sessions secure

A session ID is a bearer credential: anyone who has it is logged in. Most session attacks therefore try to steal, plant or misuse that ID. The defenses are well established; the attacks themselves are covered in Web Application Security.

  • Generate IDs with a secure random generator, at least 128 bits, and let the framework do it.
  • Issue a new session ID at login (and when privileges change). If an attacker managed to plant an ID in the victim's browser beforehand, it becomes worthless the moment the victim logs in.
  • Invalidate on the server at logout, not just by deleting the cookie.
  • Expire sessions: an idle timeout (for example 15–30 minutes for sensitive applications) and an absolute maximum, and ask for re-authentication before risky actions such as changing the email address or bank account.
  • Set the cookie attributes: Secure, HttpOnly, an explicit SameSite, no Domain, preferably the __Host- prefix.
  • Never put session IDs in URLs and never log them; they leak through history, logs and the Referer header.
  • Protect state-changing requests against CSRF with SameSite plus a CSRF token or an Origin check.