Lesson 04 / 16
Cookies and State Transfer
A cookie's scope rule determines which messages it attaches to, its flag rule determines who the stored response goes to; across forty exchanges, three scope rules carry the cookie on 11, 24 and 40 messages in turn, and two exchanges left unflagged cost the intermediary two wrong shareability decisions.
Contents
The previous lesson set up content negotiation: the same address corresponds to more than one representation, and the intermediary derives which copy goes to whom from the header fields. Negotiation selects a representation by the request’s format preference — language, encoding, media type — and reads every one of these directly from the request.
So what if the response is personalized not to format but to a person? Then the selecting field has to carry an identity, not a preference. A cookie is the field that carries the personalization itself. This lesson’s question is not what a cookie is for, but what the intermediary knows when it sees one, and what it cannot know when it does not.
State in a Stateless Protocol
HTTP is defined as stateless: every request has to be understandable on its own, and the server is not required to remember the previous one. A cookie does not break this definition; it works around it. The server does not keep state itself — it keeps it at the client, which sends it back with every request. Each request stays understandable on its own after all, because the state now sits inside the message.
The exchange comes down to two fields. In its response, the server declares a
name–value pair and its attributes through Set-Cookie; in later requests, the client
sends the name–value pair back through Cookie. The attributes travel out and not
back: the client sends only the pair.
# teaching dump, not run
server -> client
HTTP/1.1 200 OK
Set-Cookie: oturum=k7; Path=/oturum; Domain=station.example; Max-Age=3600;
HttpOnly; SameSite=Strict; Secure
Cache-Control: private
Vary: Cookie
client -> server
GET /oturum/ozet HTTP/1.1
Host: station.example
Cookie: oturum=k7
Each attribute answers one question. Domain and Path set the cookie’s scope:
which host and path prefix it is sent for. Max-Age sets its lifetime; left unset,
the cookie is a session cookie and drops when the client closes. HttpOnly closes it
off from scripts inside the document. SameSite determines whether it travels on
requests triggered from another origin. Secure ties it to the type of transport.
Two response-side fields concern the intermediary directly. Cache-Control: private
declares that the response must not go into a shared cache. Vary: Cookie declares
which request field the stored copy depends on — the same address has separate
copies keyed by Cookie. The model’s personalization flag is exactly what these
two fields declare.
The authentication flow itself is not this course’s subject; logging in, session renewal, and token use belong to the Authentication and Authorization course. Here the cookie is measured as a scope rule, not an identity mechanism.
What the Cookie’s Value Tells the Intermediary
A cookie is a name–value pair, and only the server knows what the value means. To the
intermediary, the string oturum=k7 is just a string: it does not say whose session
it is, how much privilege it carries, or whether it is a session token or a language
preference. Decoding the cookie’s value would require knowing the application, and
the intermediary does not.
This has a direct consequence: the intermediary cannot infer that a response is personalized just because a cookie is present. If it could, it would give up caching a shareable response over a cookie carrying nothing but a language preference. The reverse fails too: no cookie does not mean the response is shareable, since the server may have read identity from some other field.
The decision lives not in the cookie, therefore, but in the response’s flag. Whatever the server declares, the intermediary knows; whatever it does not declare, it does not know. The model’s personalization flag is exactly this declaration, and the only sign of personalization visible in the message. The cookie itself adds no decision to the measurement; what enters is which messages the cookie is present in, not what it carries.
The lifetime attribute falls into the same class: the difference between a cookie
with Max-Age set and a session cookie is how long the client keeps it, but the
decision drawn from that message is the same either way. The intermediary does not
know, and does not need to know, when the cookie will drop.
Two Separate Rules
Two decisions concern a cookie, and confusing them is the most common mistake.
The scope rule concerns the request: which messages does the cookie travel on?
Domain and Path set this, and the client decides. The flag rule concerns the
response: who does the stored response go to? Cache-Control and Vary set this,
the server declares it, and the intermediary carries it out.
The two are independent. A correctly scoped cookie can travel alongside a response with no flag set — it reaches the right place, but the response can go to the wrong person. The reverse happens too: a correctly flagged response can travel alongside a cookie scoped wider than it needs to be.
Assumptions of the measurement:
- HA41 — The forty exchanges come from the shared setup; the oracle is known because we generated it ourselves, and says whether each response is actually personalized.
- HA42 — Three scope rules are tried: the session path alone, the measurement-and-session prefix, the whole host. A scope rule looks only at the path; it does not use the oracle.
- HA43 — The personalization flag is the only declaration visible in the message. The server skips setting it on some personalized responses; nothing verifies what is written in the message.
- HA44 — When the intermediary sees the flag, it makes the
shareableandstorabledecisions without an extra round trip; when it does not, it makes neither and owes the server one more round trip. - HA45 — The byte count comes from field name, value and separator length; this is a count of the message’s content, not a network measurement.
The Measurement
"""Cookie scope and state transfer: the decision the intermediary draws from the private flag. Part 1 - which messages the scope rule attaches the cookie to. Part 2 - the decision table with the flag visible, and folded into the envelope. """ SEED = 20260809 SAFE = {"GET", "HEAD"} IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE"} METHOD_POOL = ["GET", "GET", "GET", "HEAD", "POST", "PUT", "DELETE"] PATH_POOL = ["/olcum/kuzey", "/olcum/yamac", "/ozet", "/kayit", "/oturum"] DECISIONS = ("storable", "shareable", "fresh", "repeatable", "redirectable") def generator(seed): d = seed % 2147483646 + 1 def r(n): nonlocal d d = (d * 48271) % 2147483647 return d % n return r def exchanges(count=40): r, out = generator(SEED), [] for i in range(count): method, path = METHOD_POOL[r(7)], PATH_POOL[r(5)] private = path == "/oturum" or r(5) == 0 stale = r(3) == 0 out.append({"num": i + 1, "method": method, "path": path, "private": private, "stale": stale, "private_marker": private and r(4) != 0, "tag_missed": stale and r(6) == 0}) return out def oracle(a): return {"storable": a["method"] in SAFE and not a["private"], "shareable": not a["private"], "fresh": not a["stale"], "repeatable": a["method"] in IDEMPOTENT, "redirectable": True} def message(a, visible): full = {"method": a["method"], "path": a["path"], "host": "station.example", "private_marker": a["private_marker"], "validator": a["stale"] and not a["tag_missed"]} return {k: v for k, v in full.items() if k in visible} def byte_count(i): return sum(len(k) + len(str(v)) + 4 for k, v in i.items()) def infer(i): k = {} if "method" in i: k["repeatable"] = i["method"] in IDEMPOTENT if "private_marker" in i: k["shareable"] = not i["private_marker"] if "method" in i: k["storable"] = i["method"] in SAFE and not i["private_marker"] if "validator" in i: k["fresh"] = not i["validator"] if "host" in i: k["redirectable"] = True return k VISIBLE = {"open": ("method", "path", "host", "private_marker", "validator"), "in envelope": ("method", "path", "host", "validator")} SCOPE = {"session path": lambda a: a["path"] == "/oturum", "measurement prefix": lambda a: a["path"].startswith(("/olcum", "/oturum")), "whole host": lambda a: True} batch = exchanges() print(f"exchanges {len(batch)} | decisions {len(batch) * len(DECISIONS)} | personalized " f"{sum(a['private'] for a in batch)} | left unflagged " f"{sum(a['private'] and not a['private_marker'] for a in batch)}") print("exchanges left unflagged:", [(a["num"], a["method"], a["path"]) for a in batch if a["private"] and not a["private_marker"]]) print() print(f"{'scope rule':<19s} {'carries':>9s} {'priv+carries':>13s} " f"{'priv, no cookie':>16s} {'needless carry':>15s}") for name, rule in SCOPE.items(): print(f" {name:17s} {sum(rule(a) for a in batch):9d}" f" {sum(rule(a) and a['private'] for a in batch):13d}" f" {sum(not rule(a) and a['private'] for a in batch):16d}" f" {sum(rule(a) and not a['private'] for a in batch):15d}") print() print("decision open: correct wrong in envelope: correct wrong unavailable") for decision in DECISIONS: s = {} for name in VISIBLE: d = y = e = 0 for a in batch: g, v = oracle(a), infer(message(a, VISIBLE[name])) if decision not in v: e += 1 elif v[decision] == g[decision]: d += 1 else: y += 1 s[name] = (d, y, e) print(f" {decision:18s} {s['open'][0]:5d} {s['open'][1]:6d}" f" {s['in envelope'][0]:16d} {s['in envelope'][1]:6d}" f" {s['in envelope'][2]:10d}") print() print("visibility correct wrong unavailable extra round bytes") for name in VISIBLE: d = y = e = t = b = 0 for a in batch: g, v = oracle(a), infer(message(a, VISIBLE[name])) missing = False for decision in DECISIONS: if decision not in v: e += 1 missing = True elif v[decision] == g[decision]: d += 1 else: y += 1 t += 1 if missing else 0 b += byte_count(message(a, VISIBLE[name])) print(f" {name:12s} {d:5d} {y:7d} {e:11d} {t:13d} {b:6d}")
exchanges 40 | decisions 200 | personalized 17 | left unflagged 2 exchanges left unflagged: [(1, 'POST', '/kayit'), (34, 'POST', '/kayit')] scope rule carries priv+carries priv, no cookie needless carry session path 11 11 6 0 measurement prefix 24 12 5 12 whole host 40 17 0 23 decision open: correct wrong in envelope: correct wrong unavailable storable 40 0 0 0 40 shareable 38 2 0 0 40 fresh 37 3 37 3 0 repeatable 40 0 40 0 0 redirectable 40 0 40 0 0 visibility correct wrong unavailable extra round bytes open 195 5 0 0 3724 in envelope 117 3 80 40 2819
What Scope Costs
The upper table places the three scope rules side by side, and none is free of trouble.
The rule narrowed to the session path carries the cookie on 11 messages, and
all eleven are genuinely personalized; needless carrying is 0. Its price sits in
the column to the right: 6 personalized responses fall outside the scope. The
setup says this directly — personalization does not come from the path itself; the
server also produces some personalized responses outside /oturum.
The rule opened to the whole host closes those six: personalized responses left outside scope drop to 0. In exchange, the cookie is carried needlessly on 23 messages — on more than half of the forty exchanges, for no purpose at all. The measurement prefix rule sits between the two: 12 needless carries and 5 personalized responses left outside scope.
The pattern: the scope rule looks at the path, and personalization does not stay confined to a path. Widening the rule reduces what falls outside scope but grows needless carrying linearly. No path zeroes out both at once, because the address is the only thing a scope rule can know.
What the Flag Costs
The lower tables look at the second rule. In 17 of the forty exchanges the response
is personalized, and the server skipped setting the flag on 2 of those; the
intermediary takes both for shareable, so the shareable row reads 38 correct,
2 wrong.
The storable row reads 40 correct, and that is not a coincidence. Both
exchanges left unflagged are POST /kayit, and that method is already not safe. A
missing flag only costs this decision on a response that would otherwise be
storable; no exchange here meets that condition, so the row stays clean. Had the
same gap occurred on a safe-method exchange, the wrong count would have risen from
two to four.
Two wrong decisions, in a set of two hundred, is a share of 0.0100. The smallest measurable difference in this set is 1/200 = 0.0050; two wrong decisions is twice that and sits inside the measurement band.
In the last table, the open regime reads 195 correct, 5 wrong, 0
unavailable. 2 of the five wrong decisions come from the missing flag measured
here; the remaining three sit in the fresh row and come from an entirely different
field, the validator tag, not measured in this lesson.
Misconfigured Scope and Its Narrowings
A broadly configured scope is not an attack, it is a configuration flaw: the rule is written, the syntax is correct, the cookie works, and nothing signals a problem. Three surfaces follow, each with the field that closes it.
First surface — needless carrying. With scope opened to the whole host, the
cookie is carried on 23 messages for no purpose at all. Narrowing: pulling
Path back to the session path drops needless carrying to 0; its price is the
6 personalized responses left outside scope. Leaving Domain unset ties scope
to a single host; setting it opens scope to subdomains. SameSite narrows scope by
the context that triggered the request rather than by path, closing a direction
Path cannot: a request reaching the same path but from another origin.
Second surface — a stored response with no flag. In two exchanges the
intermediary makes the wrong shareable decision; putting such a response into a
shared cache means handing a personalized copy to a different request.
Narrowing: setting Cache-Control: private corrects the decision; Vary: Cookie
binds the stored copy to the Cookie field and keeps the same address’s copies
separate. Both are the server’s declaration, and when they are not set, the
intermediary has no way to know.
Third surface — the intermediary seeing the flag. The intermediary reads which
message is personalized; that is what lets it decide without a round trip.
Narrowing: folding the flag field into the transport envelope hides it from the
intermediary. The in envelope row gives the price: 80 decisions move from the
middle to the endpoints, all forty exchanges owe one more round trip each, and bytes
drop from 3724 to 2819. The gain is that wrong decisions drop from 5 to
3 — a decision the intermediary cannot make is one it cannot get wrong.
Summary
- A cookie carries state in a stateless protocol by keeping the state at the client;
attributes travel out with
Set-Cookie, and only the name–value pair travels back, inCookie. - The scope rule determines which message carries the cookie, the flag rule determines who the stored response goes to; the two are independent and each can be misconfigured on its own.
- The three scope rules carry the cookie on 11, 24, and 40 messages in turn; as scope widens, personalized responses left outside it drop from 6 to 0, and needless carrying rises from 0 to 23.
- 17 of the forty exchanges are personalized, and 2 have no flag set; the
intermediary makes two wrong
shareabledecisions, whilestorablestays clean because those two already use a method that is not safe. - Folding the flag field into the transport envelope drops wrong decisions from 5 to 3, but moves 80 decisions to the endpoints and creates a debt of forty round trips; that is the price of hiding it.
Next Step
A cookie is a header field, and it is resent on every request within its scope. Across forty exchanges, the host name repeats forty times, the method repeats forty times, and the cookie repeats as many times as its scope reaches — all over the same connection, all written out from scratch. The next lesson looks at how this repetition gets packed: what the move from a text line to a binary frame buys the intermediary, which field header compression turns unreadable for it, and where the decision tied to an unread field lands.
To keep your progress and take notes, Log in
My notes
Log in to take notes.