Skip to content
academia.sh

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 shareable and storable decisions 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, in Cookie.
  • 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 shareable decisions, while storable stays 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.

Start typing to search.

↑↓ Esc navigate · open · close