Skip to content
wannacode

Sessions, cookies and CSRF

In short: CSRF is an attack in which another site sends a request to yours and the browser attaches the session cookie; SameSite cookies and a CSRF token stop it.

Problem

The address Maria did not change

Maria, a buyer, writes to the shop support: her parcel went to "13 Swindle St", but she lives at "15 Vitosha Blvd". Her password is strong and nobody knows it. The shop log has a request POST /account/address at 21:14, with her session, from her browser.

Maria swears she was not in the shop at 21:14. She was on a "Win a prize!" page in another tab. Nobody stole her password or her cookie. So how did her own browser change her address?

The idea

How the shop remembers that you are logged in

HTTP has no memory: every request stands alone (you saw it in the network lesson). So after the login the shop makes a session: it writes in a table "this id is Maria" and sends the id to the browser in a cookie, with the Set-Cookie header. From then on the browser sends the cookie with every request to the shop by itself, and the shop finds Maria by the id.

The id is like a cloakroom ticket: whoever holds it gets the coat. So it must be:

  • random: 32 bytes from a cryptographic generator, not a counter and not a plain Random. Otherwise the next id can be guessed;
  • HttpOnly: the scripts of the page do not see it. Even a foreign script brought in with XSS cannot read it;
  • Secure: it only goes over HTTPS, never over an open channel;
  • SameSite: when it goes to the shop from a page of another site. That is the second half of the lesson.

The password is checked once, at login (as in the passwords lesson), and after that only the id works. So on every login the shop gives a new id and forgets the old one. Why is in "More".

See it

The session in code

The cookie and the request are objects. The site the request comes from is a field: that way, in one process and without a network, you see everything the browser and the server do.

record Cookie(String name, String value, String sameSite) {
String header() {
return name + "=" + value + "; HttpOnly; Secure; SameSite="
+ sameSite + "; Path=/";
}
}
// site: the site of the page the request comes from
record Request(String site, String method, String path,
Map<String, String> cookies, Map<String, String> form) {}
class Shop {
static final SecureRandom RANDOM = new SecureRandom();
final String sameSite;
final Map<String, String> sessions = new HashMap<>(); // id → buyer
final Map<String, String> addresses = new HashMap<>();
Shop(String sameSite) {
this.sameSite = sameSite;
}
static String newId() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes);
}
Cookie login(String user, String oldId) {
// a new id on every login; the old one is dead
if (oldId != null) sessions.remove(oldId);
String id = newId();
sessions.put(id, user);
return new Cookie("session", id, sameSite);
}
String who(Request request) {
return sessions.get(request.cookies().get("session"));
}
}
SecureRandom gives cryptographically random bytes, and Base64.getUrlEncoder().withoutPadding() turns them into text that can sit in a cookie: 43 characters.

Maria logs in twice, then the browser tries the new id, the old one and a made-up one:

Shop shop = new Shop("Lax");
Browser browser = new Browser();
Cookie first = shop.login("maria", null);
Cookie second = shop.login("maria", first.value()); // logs in again
System.out.println("id: " + second.value().length() + " characters");
System.out.println("the two logins give different ids: " + (first.value().equals(second.value()) ? "no" : "yes"));
System.out.println("Set-Cookie: " + second.header().replace(second.value(), "…"));
browser.store(second);
System.out.println("the new id → " + visit(shop, browser));
browser.store(first);
System.out.println("the old id → " + visit(shop, browser));
browser.store(new Cookie("session", "123456", "Lax"));
System.out.println("a made-up id → " + visit(shop, browser));
Output
id: 43 characters
the two logins give different ids: yes
Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax; Path=/
the new id → maria
the old id → nobody
a made-up id → nobody

The ids are different on every run, so the program prints only the length and "yes/no". After the second login the old id leads to nobody.

The idea

The browser sends the cookie by itself

This is what the "Win a prize!" page had:

<form action="https://shop.example/account/address" method="post">
<input type="hidden" name="address" value="13 Swindle St">
</form>
<script>document.forms[0].submit()</script>

A hidden form to the shop and a line that sends it at once. The attacker does not know Maria's cookie and does not need it. The browser sees a request to shop.example and adds the shop cookie by itself. The handler finds Maria by it and changes the address.

This is called CSRF (cross-site request forgery). The cookie proves who is in the browser, but not which page started the request. A handler that trusts only the cookie trusts every page on the internet that Maria opens.

See it

The other form and the two defences

// POST /account/address
int changeAddress(Request request) {
String user = who(request);
if (user == null) return 401; // no session
addresses.put(user, request.form().get("address"));
return 200;
}
Step by step, in words
  1. Maria logs in to shop.example. The password is right, so the shop opens a session for her.
  2. The session id is 32 random bytes. The shop remembers: this id is Maria.
  3. The id goes back in the session cookie. From now on the browser sends it with every request to the shop.
  4. In another tab Maria opens free-gifts.example. Its page has a hidden form that posts to the shop and sends itself.
  5. The cookie is SameSite=None, so the browser puts it on the request. The handler finds Maria by it.
  6. It trusts the cookie and changes the address: 200, 13 Swindle St. Maria clicked nothing in the shop.
  7. Fix 1: SameSite=Lax. On a POST from another site the browser leaves the cookie at home. Pages of the shop still get it.
  8. The same form, but without the cookie the handler sees no session: 401. The address stays 15 Vitosha Blvd.
  9. Fix 2: a CSRF token. The session holds a random token, and the shop page puts it in a hidden field of its form.
  10. Even with SameSite=None the other page cannot read that token. Its form arrives with the cookie but without the token: 403.
  11. The form of the shop sends the token, and it matches: 200, 7 Shipka St.
  12. The cookie says who you are. Only the token says that the request comes from a page of the shop.

The browser decides whether the cookie goes. The rule is in the SameSite attribute: None (always goes), Lax (from another site only with GET) and Strict (from another site never).

class Browser {
final Map<String, Cookie> jar = new HashMap<>(); // the cookies of shop.example
void store(Cookie cookie) {
jar.put(cookie.name(), cookie);
}
// a page of some site sends a request to the shop
Request send(String site, String method, String path,
Map<String, String> form) {
Map<String, String> cookies = new HashMap<>();
for (Cookie c : jar.values()) {
if (attach(c, site, method)) cookies.put(c.name(), c.value());
}
return new Request(site, method, path, cookies, form);
}
static boolean attach(Cookie cookie, String site,
String method) {
// the same site: the cookie always goes
if (site.equals("shop.example")) return true;
return switch (cookie.sameSite()) {
case "None" -> true;
case "Lax" -> method.equals("GET");
default -> false; // Strict
};
}
}

The handler from the problem trusts the cookie:

// POST /account/address
int changeAddress(Request request) {
String user = who(request);
if (user == null) return 401; // no session
addresses.put(user, request.form().get("address"));
return 200;
}

Maria, the shop and the two forms, first with SameSite=None, then with Lax:

for (String sameSite : List.of("None", "Lax")) {
Shop shop = new Shop(sameSite);
Browser browser = new Browser();
browser.store(shop.login("maria", null));
shop.addresses.put("maria", "15 Vitosha Blvd");
System.out.println("SameSite=" + sameSite);
// a page of another site sends its form to the shop
show(shop, browser.send("free-gifts.example", "POST", "/account/address",
Map.of("address", "13 Swindle St")));
// the form of the shop itself
show(shop, browser.send("shop.example", "POST", "/account/address",
Map.of("address", "7 Shipka St")));
}
Output
SameSite=None
free-gifts.example → 200, address: 13 Swindle St
shop.example → 200, address: 7 Shipka St
SameSite=Lax
free-gifts.example → 401, address: 15 Vitosha Blvd
shop.example → 200, address: 7 Shipka St

With Lax the other form gets 401, and the form of the shop itself goes through: it is from the same site.

Try it

Predict the output

When does the cookie go? What does this print?

Cookie lax = new Cookie("session", "…", "Lax");
Cookie strict = new Cookie("session", "…", "Strict");
System.out.println(Browser.attach(lax, "free-gifts.example", "GET"));
System.out.println(Browser.attach(lax, "free-gifts.example", "POST"));
System.out.println(Browser.attach(strict, "free-gifts.example", "GET"));
System.out.println(Browser.attach(strict, "shop.example", "POST"));
Answer

The idea

The token the other page cannot read

SameSite is a defence of the browser. It is good, but it is not enough alone: old browsers, subdomains of the same site and handlers that change something on GET get around it. So the shop has a defence of its own too: a CSRF token.

At login the session gets one more random value, the token. The shop page puts it in a hidden field of its form. The handler accepts the request only if the token in the form matches the one in the session.

The other page can send a request to the shop, but it cannot read the answer: the browser does not let it (this is the same-origin policy). So it does not know the token and cannot put it in the form.

Cookie login(String user, String oldId) {
if (oldId != null) sessions.remove(oldId);
String id = newId();
sessions.put(id, new Session(user, newId())); // and a CSRF token for the forms
return new Cookie("session", id, sameSite);
}
Session session(Request request) {
return sessions.get(request.cookies().get("session"));
}
// the shop page puts the token in a hidden field
String formToken(Request request) {
return session(request).csrf();
}
int changeAddress(Request request) {
Session s = session(request);
if (s == null) return 401; // no session
String sent = request.form().getOrDefault("csrf", "");
if (!same(sent, s.csrf())) return 403;
addresses.put(s.user(), request.form().get("address"));
return 200;
}
The token is compared with a function that looks at all bytes, without stopping at the first difference. Otherwise the response time tells how much of the start is right.
static boolean same(String a, String b) {
// compares all bytes, without stopping at the first difference
return MessageDigest.isEqual(a.getBytes(UTF_8),
b.getBytes(UTF_8));
}

Even with SameSite=None the other form stops:

Shop shop = new Shop("None");
Browser browser = new Browser();
browser.store(shop.login("maria", null));
shop.addresses.put("maria", "15 Vitosha Blvd");
System.out.println("SameSite=None + CSRF");
show(shop, browser.send("free-gifts.example", "POST", "/account/address",
Map.of("address", "13 Swindle St")));
// the other site does not know the token and guesses
show(shop, browser.send("free-gifts.example", "POST", "/account/address",
Map.of("address", "13 Swindle St", "csrf", "123456")));
// the shop page puts the token in a hidden field
String token = shop.formToken(browser.send("shop.example", "GET", "/account", Map.of()));
show(shop, browser.send("shop.example", "POST", "/account/address",
Map.of("address", "7 Shipka St", "csrf", token)));
Output
SameSite=None + CSRF
free-gifts.example → 403, address: 15 Vitosha Blvd
free-gifts.example → 403, address: 15 Vitosha Blvd
shop.example → 200, address: 7 Shipka St

Try it

Fill in the blank

Which function compares the tokens without stopping at the first difference?

return MessageDigest.(a.getBytes(UTF_8), b.getBytes(UTF_8));

Try it

Find the bug

A colleague adds a CSRF token and the tests pass: a wrong token gives 403. A week later addresses are changed from another site again. Which line has the bug?

Pick the line with the bug.

More precisely, if you are curious

Session fixation. The attacker gets an id from the shop (for example as a guest) and plants it on Maria: in a link, in a cookie of a subdomain. If the shop keeps the same id after her login, the attacker is logged in as Maria. So at login the old id is deleted and a new one is given, as login in the lesson does.

Logout. At logout the session is deleted on the server, not only the cookie in the browser. Otherwise a stolen id keeps working after the logout.

Lax, more precisely. Lax lets the cookie go from another site only on a navigation of the whole page with a "safe" method (GET, HEAD): when Maria clicks a link to the shop. Hidden requests with fetch or in an <iframe> do not get it. That is why GET must never change anything: otherwise one link is enough for the attack. If the cookie has no SameSite, Chrome and Edge treat it as Lax, but Firefox and Safari not always. So the attribute is written explicitly.

Checking Origin. The browser sends the headers Origin and Sec-Fetch-Site, which say where the request comes from, and a page of another site cannot fake them. Checking them is a good third layer.

In a real project the framework does the token: Spring Security (on by default), ASP.NET Core antiforgery, Django (CsrfViewMiddleware), and in Express a library like csrf-csrf. Sessions are ready-made too: HttpSession in Java, ISession in ASP.NET Core, the sessions of Django and Flask, express-session. The prefix __Host- in the cookie name (__Host-session) makes the browser require Secure, Path=/ and no Domain.

HTTPS (the certificates and the server setup) is part of the infrastructure and is taught in the DevOps course ZeroToOps.

Exercise

Cancelling an order

Solve it in your own editor.

The shop also has a "Cancel order" form: POST /orders/cancel with a field order. Write the handler cancelOrder: without a session it returns 401, without a correct CSRF token 403, otherwise it marks the order as cancelled and returns 200. Try it with three requests, with the cookie SameSite=None: from the other site with Maria's cookie but no token; from a browser that is not logged in; from the shop page with the token.

Output
other site: 403, order 1042: active
not logged in: 401, order 1042: active
the shop: 200, order 1042: cancelled

More tasks

  • A new token. Give a new CSRF token after every successful address change. What happens if Maria has the form open in two tabs?
  • Origin. Add a field origin to the request and reject every request that changes something and is not from shop.example.

Test

Check yourself

1. Why does the session cookie have HttpOnly?
2. What does a CSRF attack use?
3. Why does the CSRF token stop the other form when the cookie still goes along?
4. Why does the shop give a new session id on every login?
5. Why is the token compared with MessageDigest.isEqual / FixedTimeEquals / compare_digest / timingSafeEqual and not with equals or ==?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·