Basis Lab

Answers · The network and the web

How can another site make a request as me if it can't read my cookies?

In shortA foreign page does not need to read your cookie. It makes your browser send a request to the site that owns the cookie, and the browser attaches the cookie automatically.

It does not need to read the cookie. It causes your browser to send a request to the site that owns the cookie, and the browser attaches that cookie because of where the request is going, not because of which page started it. Nothing has been stolen, guessed, or read: the attacker supplies the request, and your browser supplies the credential.

FIG 1A form aimed at the bank
plaintext
<form method='post' action='https://bank.northwind.co/transfer'>
  <input type='hidden' name='to' value='attacker-account'>
  <input type='hidden' name='amount' value='4000'>
</form>
<script>document.forms[0].submit()</script>

<img src='https://bank.northwind.co/transfer?to=attacker&amount=4000'>
The foreign page contains a form aimed at the bank and submits it without being asked, or uses an image tag if a read method performs the action. The person sees a blank page or a broken image. The attacker cannot read the response, and does not need to.

Why the browser sends it

When the browser prepares a request, it looks at the destination and attaches every cookie for that destination. A session for bank.northwind.co was stored by the browser when the person signed in; a request to bank.northwind.co/transfer therefore arrives with that session attached. The browser does not ask which page created the request, because refusing cookies on that basis would break ordinary images, stylesheets, and links from other sites. The hostile page supplies the action and the field values; the browser supplies the session. Neither side needs anything from the other.

The server receives a well-formed POST to /transfer with a valid session cookie and the fields to=attacker-account and amount=4000. The path and field names are public: the attacker copied them from the bank's own page. The values are chosen to look ordinary. To the server, the request is indistinguishable from one the person made in the bank's own tab, because every field in it is exactly what a genuine request would contain. The attacker cannot read the reply, since the same-origin policy stops the foreign page reading the bank's response, but the transfer has already happened.

FIG 2
fieldgenuine requestforged requestdistinguishable
session cookiepresent and validpresent and validno
method and pathPOST /transferPOST /transferno
form fieldsnormal payee and amountnormal payee and amountno
value placed in the pagepresentabsentyes
browser declaration of originsays same sitesays cross siteyes

A defence must therefore change the set of fields, not merely inspect the ones that are already there. Filtering on amount, checking that the method is POST, or requiring a content type that a form can produce all leave the forged request looking acceptable. The useful question is not whether the request looks like the bank's own. It is whether the foreign page has supplied something it could not have supplied.

The wrong assumption

The natural guess is that the attacker must first read document.cookie, capture the cookie in transit, or otherwise obtain the session string. That is a different attack, session hijacking, and reading another site's cookie is exactly what the browser prevents. HttpOnly helps against that reading attack by keeping JavaScript away from the cookie, but it does nothing here: the browser still attaches an HttpOnly cookie to a request aimed at the bank. The authority of a session cookie comes from being sent, not from being visible to the page. That is why a site can be forged against while its cookie remains unreadable to the attacker.

What actually stops it

Every working defence adds something to the request that a foreign page cannot supply. A random token placed in the bank's page and required back with every state-changing request works because the foreign page cannot read the bank's page to learn the token; the server compares the returned token with the one bound to the session. A same-site restriction on the session cookie makes the browser withhold the cookie when the request was started elsewhere. The strict form covers everything and breaks arriving by an ordinary link from an email, which signs the person out just when they clicked. The relaxed form allows ordinary top-level link navigation while withholding the cookie from form submissions and background requests, which is where this attack lives. A browser declaration of where the request came from lets the server refuse state-changing requests that declare themselves cross-site; a page cannot change that declaration because the browser writes it. The token works in older browsers, the declaration is a single check in a single place, and doing both is cheap.

The SameSite strict form stops the attack but breaks normal inbound links, so many sites use the relaxed form. Relaxed still sends the cookie on top-level GET navigation, so if a state change is performed by GET and the attacker can get a click, the action can still be triggered; requiring a write method closes image tags and prefetches but not a form, since a foreign form can use POST. The browser declaration is absent in old browsers, which is why the token remains useful. Checking the referrer is not a reliable substitute: the header is often missing on legitimate requests, and a check that refuses when it is missing breaks real users while a check that allows when it is missing is no check at all. An unguessable address is not a defence, because the address appears in the site's own pages, logs, browser history, and analytics. A write method is necessary and correct, but it is not sufficient: the form above uses POST.

Worth keeping

  • A session cookie is attached by the browser according to the request's destination, not according to the page that caused the request.
  • HttpOnly prevents JavaScript from reading a cookie but does not prevent the browser from sending it on a forged request.
  • A defence works only if it adds a value the foreign page cannot read or a browser-written declaration the foreign page cannot alter.

Where this goes next

This question is one step of Proving Who You Are, a course that works through its subject from the beginning. The page that teaches this part of it is Requests You Did Not Make, and it reads the whole way through whether or not you have an account.

What an account changes is the practice: problems standing under every idea, marked with a reason rather than a tick, in a copy of the course that is yours.

Read that pageThe whole course

Other questions people ask about this

  1. Where should a session ID be stored so JavaScript can't read it?Store it in an HttpOnly cookie set by the server; that is the only browser storage JavaScript running on the page cannot read.
  2. Does signing out actually invalidate my token on the server?No. Signing out clears your browser's copy of the token, but the server keeps accepting that token until it expires.
  3. Why isn't a password plus a security question really two-factor authentication?A security question is another thing you know, so it is the same kind of proof as a password, not a second kind.
  4. What does it mean for an API method to be safe?A safe method is one that changes nothing on the server when it is called, so a caller may repeat it freely and a cache or a crawler may use it without anything being altered.
  5. Why does the TLS handshake take so many round trips before any data is sent?It is not one key exchange. The handshake agrees a secret, proves the server's identity and checks for tampering, and modern TLS fits those into one round trip; a fresh connection still needs three before the page starts.
  6. If a website has HTTPS, can someone on the same Wi-Fi read my password?No. HTTPS encrypts the form contents and cookies, so someone on the same Wi-Fi cannot read the password itself; they can still see the site, timing and packet sizes.

Read alongside