Cookies, Sessions and State
How a stateless protocol remembers who you are: cookies and their attributes, server-side sessions, tokens in browsers, third-party cookies and the privacy rules around them.
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.
Cookie attributes
| 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.
Cookie prefixes
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 withSecure, from an HTTPS page. -
__Host-: additionally requiresPath=/and noDomain, so the cookie is locked to exactly one host. The best choice for a session cookie. -
Newer prefixes,
__Http-(requiresSecureandHttpOnly) 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.
Example: reviewing Set-Cookie headers
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.
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
localStorageand 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 explicitSameSite, noDomain, preferably the__Host-prefix. -
Never put session IDs in URLs and never log them;
they leak through history, logs and the
Refererheader. -
Protect state-changing requests against CSRF
with SameSite plus a CSRF token or an
Origincheck.