---
title: 'Cookies and State Transfer'
source: 'https://academia.sh/en/courses/application-protocols/cookies-and-state-transfer'
course: 'Application Layer Protocols'
language: en
updated: '2026-08-17T18:06:58+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```text
# 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

```python
"""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.
