Cookies
Under what circumstances cookies are accepted or rejected by user agents and send back to web servers can be a bit confusing. Let’s experiment a bit.
TL;DR
Actually, it is pretty simple:
The origin domain is the domain you’re visiting. An origin domain can set cookies for its own domain and any higher-level domains. A client will send cookies for the origin domain and any higher-level domains. Conversely, a higher-level domain can read nor write cookies for subdomains.
So: example.com
can read and write cookies for example.com
but not
sub.example.com
. sub.example.com
can read and write cookies for
sub.example.com
and example.com
, but not for its sibling
sub2.example.com
. Top-level domains (TLDs) are always off-limit for
everyone.
Test Drive
We’re going to prove all the cases (and some special ones) to ourselves
using our favorite user agent: curl(1)
! Please note that I
won’t be showing all of the command-line output everywhere to prevent
this post from becoming too large.
Setup
-
Add the following lines to
/etc/hosts
:127.0.0.1 example.com sub1.example.com sub2.sub1.example.com 127.0.0.1 other.com sub1.other.com sub2.sub1.other.com
-
Create a self-signed certificate. Every
curl
invocation should specify--cacert <cert>
socurl
will accept and verify the self-signed certificate. -
Run the following Golang program:
cookie.go
// expirements with setting cookies package main import ( "fmt" "log" "net/http" ) var counter = 0 var inc = func() string { counter++ return fmt.Sprintf("%d", counter) } func main() { http.HandleFunc("/other-domain", handleOtherDomain) http.HandleFunc("/tld", handleTLD) http.HandleFunc("/subdomain", handleSubdomain) http.HandleFunc("/domainless", handleNoDomain) http.HandleFunc("/host-prefix-secure", handleHostPrefixSecure) http.HandleFunc("/host-prefix-not-secure", handleHostPrefixNotSecure) http.HandleFunc("/secure", handleSecure) http.HandleFunc("/not-secure", handleNotSecure) go func() { log.Fatal(http.ListenAndServeTLS("127.0.0.1:443", "cert.pem", "key.pem", nil)) }() go func() { log.Fatal(http.ListenAndServe("127.0.0.1:80", nil)) }() select {} } // handleOtherDomain tries to set cookie for different domain func handleOtherDomain(w http.ResponseWriter, _ *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "other", Value: inc(), Domain: "other.com", }) } // handleTLD tries to set a "supercookie" for a TLD func handleTLD(w http.ResponseWriter, _ *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "tld", Value: inc(), Domain: "com", }) } // handleSubdomain sets 3 cookies for parent and 2 subdomains func handleSubdomain(w http.ResponseWriter, _ *http.Request) { // can be set on parent or any subdomain of parent http.SetCookie(w, &http.Cookie{ Name: "parent", Value: inc(), Domain: "example.com", }) // can be set from sub1 domain or any subdomain of sub1 http.SetCookie(w, &http.Cookie{ Name: "sub1", Value: inc(), Domain: "sub1.example.com", }) // can be set from sub2 domain or any subdomain of sub2 http.SetCookie(w, &http.Cookie{ Name: "sub2", Value: inc(), Domain: "sub2.sub1.example.com", }) } // handleNoDomain set cookie w/ no Domain attribute: can be set on origin // domain only and will be sent only to origin domain, not subdomains func handleNoDomain(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: fmt.Sprintf("domainless-%s", r.Host), Value: inc(), }) } // handleHostPrefixSecure sets "domain-locked" cookie: has Secure attr, no // Domain and is only accepted when sent over secure connection func handleHostPrefixSecure(w http.ResponseWriter, _ *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "__Host-host", Value: inc(), Secure: true, }) } // handleHostPrefixNotSecure tries to set __Host- cookie w/o Secure attr // these "domain-locked" cookies should have Secure attr, no Domain and // sent over secure connection func handleHostPrefixNotSecure(w http.ResponseWriter, _ *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "__Host-host", Value: inc(), Secure: false, }) } // handleSecure tries to set secure cookie // should only be accepted (and send) by clients when sent over secure connection func handleSecure(w http.ResponseWriter, _ *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "secure", Value: inc(), Domain: "example.com", Secure: true, }) } // handleNotSecure sets cookie with name "secure" but w/o Secure attr set func handleNotSecure(w http.ResponseWriter, _ *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "secure", Value: inc(), Domain: "example.com", Secure: false, }) }
Can a Server Set a Cookie for Another Domain?
Let’s begin with an empty cookie jar:
$ > jar
Let’s get “setting a cookie for another domain” out of the way first:
$ curl -v -b jar -c jar https://example.com/other-domain
... snip ...
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
* skipped cookie with bad tailmatch domain: other.com
< set-cookie: other=other; Domain=other.com
< content-length: 0
< date: Sat, 25 May 2024 12:15:58 GMT
curl
informs us that example.com
can’t set a cookie for other.com
.
Check.
Can a Server Set a Cookie for a Top-Level Domain?
$ curl -v -b jar -c jar https://example.com/tld
... snip ...
* cookie 'tld' dropped, domain 'example.com' must not set cookies for 'com'
< set-cookie: tld=tld; Domain=com
... snip ...
We can’t set a cookie for .com
or any other TLD. Another check.
Can a Server Set a Cookie for Subdomains?
$ curl -v -b jar -c jar https://example.com/subdomains
... snip ...
* Added cookie parent="parent" for domain example.com, path /, expire 0
< set-cookie: parent=parent; Domain=example.com
* skipped cookie with bad tailmatch domain: sub1.example.com
< set-cookie: sub1=sub1; Domain=sub1.example.com
* skipped cookie with bad tailmatch domain: sub2.sub1.example.com
< set-cookie: sub2=sub2; Domain=sub2.sub1.example.com
... snip ...
Only the cookie set for itself is accepted. Cookies for subdomains are rejected. Another check.
Let’s examine the cookie jar at this point:
$ tail -n +5 jar | column -t
.example.com TRUE / FALSE 0 parent parent
We notice that the cookie with name parent
was accepted and put in the
jar. It’s a cookie that the client will send to subdomains as we’ll
see in a bit. This is indicated by the leading dot .example.com
and
the TRUE
in the second column. The jar file format is explained
here.
Do User Agents Send Cookies for Parents to Subdomains?
Let’s make a request to sub1.example.com
:
$ curl -v -b jar -c jar https://sub1.example.com/subdomains
... snip ...
> GET / HTTP/2
> Host: sub1.example.com
> User-Agent: curl/8.5.0
> Accept: */*
> Cookie: parent=parent
>
< HTTP/2 200
* Replaced cookie parent="parent" for domain example.com, path /, expire 0
< set-cookie: parent=parent; Domain=example.com
* Added cookie sub1="sub1" for domain sub1.example.com, path /, expire 0
< set-cookie: sub1=sub1; Domain=sub1.example.com
* skipped cookie with bad tailmatch domain: sub2.sub1.example.com
< set-cookie: sub2=sub2; Domain=sub2.sub1.example.com
- The client sends a cookie set on the parent domain to the subdomain.
- The client accepts a cookie with the parent domain, replacing the previous cookie, and for the origin domain.
- The cookie for a subdomain,
sub2.sub1.example.com
is rejected.
To really drive this point home, let’s make a request to
sub2.sub1.example.com
:
$ curl -v -b jar -c jar https://sub2.sub1.example.com/subdomains
> GET / HTTP/2
> Host: sub2.sub1.example.com
> User-Agent: curl/8.5.0
> Accept: */*
> Cookie: sub1=sub1; parent=parent
>
< HTTP/2 200
* Replaced cookie parent="parent" for domain example.com, path /, expire 0
< set-cookie: parent=parent; Domain=example.com
* Replaced cookie sub1="sub1" for domain sub1.example.com, path /, expire 0
< set-cookie: sub1=sub1; Domain=sub1.example.com
* Added cookie sub2="sub2" for domain sub2.sub1.example.com, path /, expire 0
< set-cookie: sub2=sub2; Domain=sub2.sub1.example.com
Now the jar contains:
$ tail -n +5 jar | column -t
.sub2.sub1.example.com TRUE / FALSE 0 sub2 sub2
.sub1.example.com TRUE / FALSE 0 sub1 sub1
.example.com TRUE / FALSE 0 parent parent
Are Subdomain Cookies Send to the Parent Domain?
What cookies are send to example.com
?
$ curl -v -b jar -c jar https://example.com
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
> Cookie: parent=parent
... snip ...
Only the ones set for the origin domain, not for any subdomains.
Special Cases
A cookie without a Domain
attribute will be accepted for the origin
domain only. It will not be sent with requests to subdomains.
$ # empty jar
$ > jar
$ curl -v -b jar -c jar https://example.com/domainless
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
* Added cookie domainless-example.com="domainless-example.com" for domain example.com, path /, expire 0
< set-cookie: domainless-example.com=domainless-example.com
Check the jar:
$ tail -n +5 jar | column -t
example.com FALSE / FALSE 0 domainless-example.com domainless-example.com
Notice the missing dot in the cookie jar and the FALSE
in the second
column. This cookie is not send to subdomains:
$ curl -v -b jar -c jar https://sub1.example.com
> GET / HTTP/2
> Host: sub1.example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
* Added cookie domainless-sub1.example.com="domainless-sub1.example.com" for domain sub1.example.com, path /, expire 0
< set-cookie: domainless-sub1.example.com=domainless-sub1.example.com
... snip ...
Cookie Prefixes
Mozilla writes that there are certain prefixes that are respected by
HTTP user agents. Setting a cookie that has a name starting
with __Host-
will be accepted only if (1) the Secure
attribute has
been set, (2) it was sent from a secure origin, (3) the Domain
attribute is missing and Path
is set to /
:
$ curl -v -b jar -c jar https://example.com/host-prefix-secure
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
* Added cookie __Host-host="__Host-host" for domain example.com, path /, expire 0
< set-cookie: __Host-host=__Host-host; Secure
Let’s see what happens when it is sent over an insecure channel:
$ # empty jar first
$ > jar
$ curl -v -b jar -c jar http://example.com/host-prefix-secure
> GET / HTTP/1.1
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Set-Cookie: __Host-host=__Host-host; Secure
... snip ...
The jar stays empty:
$ tail -n +5 jar | column -t
$
Sending over secure channel but not setting Secure
attribute:
$ curl -v -b jar -c jar https://example.com/host-prefix-not-secure
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
< set-cookie: __Host-host=__Host-host
... snip ...
$ tail -n +5 jar | column -t
$
Another prefix is __Secure-
. Cookies sent with this prefix are only
accepted when it’s marked Secure
and send over a secure channel. So
it’s weaker than __Host-
.
The reason these prefixes are used is that normally a server can’t be
sure that a cookie was set from a secure origin or even what exact
origin domain set the cookie. Remember that subdomains can set cookies
for parent domains. A subdomain can insecurely set a cookie that the
parent domain also sets with Secure
and therefore expects to be
secured.
The Secure
Attribute
Clients reject cookies marked Secure
if received over an insecure
channel:
$ curl -v -b jar -c jar http://example.com
> GET / HTTP/1.1
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Set-Cookie: secure=secure; Domain=example.com; Secure
$ tail -n +5 jar | column -t
$
Over a secure channel, this cookie is accepted. However, if a subdomain
overrides this cookie and leaves out the Secure
attribute:
$ curl -v -b jar -c jar https://sub1.example.com/not-secure
> GET / HTTP/2
> Host: sub1.example.com
> User-Agent: curl/8.5.0
> Accept: */*
> Cookie: secure=secure
>
< HTTP/2 200
* Replaced cookie secure="secure" for domain example.com, path /, expire 0
< set-cookie: secure=secure; Domain=example.com
$ tail -n +5 jar | column -t
.example.com TRUE / FALSE 0 secure secure
Notice that the 4th field is FALSE
, indicating this
cookie is not Secure
. Now, when we make a request to the parent
domain:
$ curl -v -b jar -c jar https://example.com
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.5.0
> Accept: */*
> Cookie: secure=secure
>
< HTTP/2 200
... snip ...
So that’s why __Host-
and __Secure-
prefixes exist.
Source Code
You can get the source code used in this post from Source Hut: https://git.sr.ht/~neftas/cookie.