Embercode
✕
How the Internet WorksLesson 13 of 15

45 min read

Cookies, sessions and login state

Why this matters

Many login bugs are cookie bugs. A user is logged out for no reason. Clicking "log out" does nothing. Logging in on one tab logs out another. A link from an email shows the user as a guest. Someone else's account opens on a shared computer. Each of these has a cause you can find with the right questions: what does the browser hold, what does the server hold, and when does each one forget?

The previous HTTP lesson showed how a server sets a cookie and how the browser sends it back. This lesson starts after that point. It covers what the server does with the cookie, how a logout really ends a login, and how the Secure and SameSite settings limit where a cookie is sent. The lesson does not repeat the login exchange or the HttpOnly example.

The idea: a coat check ticket

Think of a coat check at a restaurant. You hand over your coat and get a numbered ticket. The ticket is small and easy to carry. The coat stays with the staff. When you come back, you show the ticket, and the staff use the number to find your coat.

A login session works the same way:

  • The cookie is the ticket. It is a small piece of text, such as session=MiHFJraq3J..., that the browser keeps and sends with each request.
  • The session record is the coat. It is the data the server keeps for that login: which user it belongs to, when it started and when it was last used.
  • The session table is the coat rack. It is a list on the server that maps each ticket number to its record.

The ticket proves nothing by itself. Anyone who holds a copy of the number can show it. So the server checks the number against its own table, and the strength of the whole system depends on two things: the number must be hard to guess, and the server must be able to throw a ticket away.

A common beginner mistake is to put the user's details in the cookie, such as user=maya. The browser can edit that text, so anyone can change it to user=admin. Keep the real data on the server and send only the ticket.

Server side: looking up the ticket

The server needs three things for each request: read the ticket from the Cookie header, look it up in the table, and decide whether the record still counts as a login. The example below does that with the Python standard library, so you do not need to install anything. It has no password database, and the password is written in plain text, which is acceptable only for a local demonstration.

import http.server
import secrets
import time
import urllib.parse
from http.cookies import SimpleCookie
 
USERS = {"maya": "demo-password"}
SESSIONS = {}            # session id -> {"user", "created", "last_seen"}
IDLE_LIMIT = 30 * 60     # seconds without a request before the session expires
 
 
def read_session_id(handler):
    raw = handler.headers.get("Cookie", "")
    cookies = SimpleCookie()
    try:
        cookies.load(raw)
    except Exception:
        return None
    morsel = cookies.get("session")
    return morsel.value if morsel else None
 
 
class Handler(http.server.BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass
 
    def send(self, code, body=b"", extra=()):
        self.send_response(code)
        for name, value in extra:
            self.send_header(name, value)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
 
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        form = urllib.parse.parse_qs(self.rfile.read(length).decode())
        if self.path == "/login":
            user = form.get("user", [""])[0]
            password = form.get("password", [""])[0]
            if USERS.get(user) != password:
                return self.send(401, b"wrong user or password\n")
            old_id = read_session_id(self)
            if old_id in SESSIONS:
                del SESSIONS[old_id]          # drop any id the browser already had
            sid = secrets.token_urlsafe(32)   # random, about 43 characters
            now = time.time()
            SESSIONS[sid] = {"user": user, "created": now, "last_seen": now}
            cookie = f"session={sid}; HttpOnly; Path=/; SameSite=Lax; Max-Age={IDLE_LIMIT}"
            return self.send(200, b"logged in\n", [("Set-Cookie", cookie)])
        if self.path == "/logout":
            sid = read_session_id(self)
            SESSIONS.pop(sid, None)           # the server forgets the id
            cookie = "session=; HttpOnly; Path=/; Max-Age=0"
            return self.send(200, b"logged out\n", [("Set-Cookie", cookie)])
        self.send(404, b"not found\n")
 
    def do_GET(self):
        if self.path == "/visit":
            sid = secrets.token_urlsafe(32)
            SESSIONS[sid] = {"user": None, "created": time.time(), "last_seen": time.time()}
            return self.send(200, b"anonymous session started\n",
                             [("Set-Cookie", f"session={sid}; HttpOnly; Path=/; SameSite=Lax")])
        if self.path == "/me":
            sid = read_session_id(self)
            record = SESSIONS.get(sid) if sid else None
            if record is None or record["user"] is None or time.time() - record["last_seen"] > IDLE_LIMIT:
                return self.send(401, b"not logged in\n")
            record["last_seen"] = time.time()
            return self.send(200, f"Hello, {record['user']}\n".encode())
        self.send(404, b"not found\n")
 
 
if __name__ == "__main__":
    http.server.ThreadingHTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

Read the /me route first. It is the check every protected page makes. The browser sends the ticket. The server reads it, finds a record, and confirms that the record belongs to a real user and has not expired. Only then does it answer with the user's name.

The /visit route is there to show a session that is not logged in. It creates a record with "user": None. It is not a login, and /me treats it as a guest.

Save the code above as server.py. Start the server in one terminal with python3 server.py, then run the commands below in another. curl is a command-line tool that sends web requests. They use -c to save cookies into a file called jar.txt and -b to send them back. Each line that starts with # in the commands is a note for you, not part of the command.

# Before login: no cookie, so the server does not know who you are.
curl -i http://127.0.0.1:8765/me
 
# Log in and save the cookie to jar.txt.
curl -i -c jar.txt -d "user=maya&password=demo-password" http://127.0.0.1:8765/login
 
# Use the saved cookie.
curl -i -b jar.txt http://127.0.0.1:8765/me

The real output from one run of these commands looked like this. The Date and Server header lines are removed to keep it short. Your session id is different on every run.

HTTP/1.0 401 Unauthorized
 
not logged in
 
HTTP/1.0 200 OK
Set-Cookie: session=MiHFJraq3JpZ9AncfaSyr3Tyx-IXmudZL-IZXQAuoVA; HttpOnly; Path=/; SameSite=Lax; Max-Age=1800
 
logged in
 
HTTP/1.0 200 OK
 
Hello, maya

Three facts are visible in that output. The guest gets a 401 Unauthorized because it has no ticket. The login gives a new random value that the server created. The second request with the saved jar gets a 200, because the ticket matches a record that belongs to maya.

The jar file also shows the cookie. The line in the file has seven tab-separated fields: domain, a flag, path, a secure flag, expiry, name and value. The value is the same as the Set-Cookie line. Notice that #HttpOnly_127.0.0.1 marks the cookie as HttpOnly, so page scripts cannot read it.

Worked example 2: logging out really ends the session

Many beginners think logout means the browser deletes the cookie. That deletes only the ticket. The coat still hangs on the rack unless the server removes it. If someone copied the ticket earlier, they could still show it.

The server's logout route does two things: it removes the record from its table, and it tells the browser to clear the cookie. The second step is Max-Age=0. Max-Age is how many seconds a browser keeps a cookie, so 0 tells it to delete the cookie at once.

curl -i -b jar.txt -c jar.txt -d "x=1" http://127.0.0.1:8765/logout

Output:

HTTP/1.0 200 OK
Set-Cookie: session=; HttpOnly; Path=/; Max-Age=0
 
logged out

Now replay the old ticket value, as an attacker who copied it earlier would:

curl -i -H "Cookie: session=MiHFJraq3JpZ9AncfaSyr3Tyx-IXmudZL-IZXQAuoVA" http://127.0.0.1:8765/me

Output:

HTTP/1.0 401 Unauthorized
 
not logged in

The server rejected the old value because the record was gone. Without the SESSIONS.pop(...) line, the replay would have returned 200 Hello, maya. The browser's copy of the cookie being cleared is not the protection. The protection is the server forgetting the id.

The -d "x=1" in the logout command turns the request into a POST. The route only accepts POST. Our first test sent a GET and got 404 Not Found. That mistake is shown in the Common mistakes section.

Worked example 3: changing the id at login

Session fixation is an attack where the attacker picks the victim's session id before the victim logs in. Suppose the server keeps the same id when a guest becomes a logged-in user. The attacker can set a known id in the victim's browser, wait for the victim to log in, and then use the same id. The attacker is now logged in as the victim.

The fix is to create a new id at login and delete the old one. Our /login route does that with two lines: del SESSIONS[old_id] and sid = secrets.token_urlsafe(32).

We tested the attack. The attacker's step gets a guest id from /visit, which we call id A. The victim then logs in while holding id A. The login response carries a new id, which we call id B. Copy each value from the Set-Cookie line of the response, then run these commands with the real values in place of A and B:

# Attacker gets a guest id, A.
curl -i http://127.0.0.1:8765/visit
 
# Victim logs in while holding id A. The response carries a new id, B.
curl -i -H "Cookie: session=A" -d "user=maya&password=demo-password" http://127.0.0.1:8765/login
 
# Test both ids against /me.
curl -i -H "Cookie: session=A" http://127.0.0.1:8765/me
curl -i -H "Cookie: session=B" http://127.0.0.1:8765/me

One run printed this, with the Date and Server lines removed and the ids shown as A and B:

GET /visit
HTTP/1.0 200 OK
Set-Cookie: session=A; HttpOnly; Path=/; SameSite=Lax
 
POST /login, with session=A in the Cookie header
HTTP/1.0 200 OK
Set-Cookie: session=B; HttpOnly; Path=/; SameSite=Lax; Max-Age=1800
 
logged in
 
GET /me with session=A
HTTP/1.0 401 Unauthorized
 
not logged in
 
GET /me with session=B
HTTP/1.0 200 OK
 
Hello, maya

The old id, A, no longer works after login. The new id, B, works. The attacker's copy of id A is useless. Real sites use the same pattern, usually with a framework that does this for you. Check that your framework rotates the id at login before you write one yourself.

Expiry: how long a ticket stays valid

A session needs an end. Otherwise a stolen ticket works forever, and a forgotten laptop stays logged in. There are two common limits, and most real sites use both.

  • Idle timeout. The session ends after a period with no requests. Our server uses IDLE_LIMIT = 30 * 60, which is thirty minutes. Each successful /me request sets last_seen to the current time, so an active user stays logged in.
  • Absolute timeout. The session ends a fixed time after login, no matter how active the user is. This limits the damage from a stolen ticket. The record already stores created, so a check such as time.time() - record["created"] > 12 * 60 * 60 would add it.

Work through an example. A user logs in at 09:00. The idle limit is 30 minutes and the absolute limit is 8 hours. The user makes requests at 09:10, 09:35 and 10:20, and then stops.

  • At 09:35 the last request was 25 minutes earlier, which is inside the idle limit. The session continues.
  • At 10:20 the gap since the last request, at 09:35, is 45 minutes. That is more than the 30-minute idle limit, so the session ends.
  • If the user kept making a request every 20 minutes all day, the idle check would always pass, but the absolute check would end the session at 17:00.

The cookie's Max-Age is a browser setting. It tells the browser when to delete the cookie. It does not end the server's record. A server that checks only Max-Age in the cookie has no real timeout, since a copied ticket can be used anywhere until the server checks its own record.

Secure: sending the cookie only over HTTPS

The Secure attribute tells the browser to send the cookie only on HTTPS connections. A request to http:// never includes it. Without Secure, the ticket travels in plain text on any network the request crosses, for example on a shared café Wi-Fi network. Anyone on that network can read the text and reuse the ticket. The earlier lesson on HTTPS explained why plain text can be read on the wire.

You can see the header the server must send:

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

The rule works in two places. The server should set Secure on any cookie that carries a login. The page that sets the cookie must itself be served over HTTPS, because the cookie standard says a plain http:// page cannot set a Secure cookie. Browsers follow that rule and ignore such a cookie.

We tested this with curl. We set a Secure cookie from an HTTPS server and saw curl store it and send it back over HTTPS, which is the expected result:

Set-Cookie: token=s3cret; Secure; HttpOnly; Path=/
#HttpOnly_localhost	FALSE	/	TRUE	0	token	s3cret
Cookie seen by server: token=s3cret

Our test also showed that curl stored and sent a Secure cookie that came from a plain HTTP response on localhost. Browsers treat localhost as a special case, so that result does not show what a browser does on a real site. The lesson is to test Secure in the browser itself, not with curl. Use the Application or Storage tab of the developer tools to check whether the cookie was kept, and the Network tab to see which requests carried it.

SameSite: where the cookie may travel

Cookies are sent by the browser automatically. That is convenient, but it is also a risk. Suppose you are logged in to a bank at bank.example. You then open a page on evil.example. That page can ask your browser to send a form to the bank. Your browser may attach the bank's cookie, and the bank may treat the request as coming from you. This attack is called cross-site request forgery, or CSRF.

SameSite limits when a cookie is sent on requests that start from another site. First, a definition. Two URLs are same-site when they share the scheme (https) and the registrable domain, the name you buy, such as bank.example. A different subdomain counts as same-site: shop.bank.example and bank.example are same-site. A different domain is cross-site: mail.example and bank.example are cross-site.

There are three values:

  • Strict: the cookie is sent only on same-site requests. A link from another site opens the bank page without the login, so the user looks logged out at first.
  • Lax: the cookie is sent on same-site requests, and on a cross-site top-level navigation that uses GET, such as a normal link click. It is not sent on cross-site POST requests, or on requests that load something inside a page, such as an image.
  • None: the cookie is sent everywhere. Browsers require Secure with None.

Chrome treats a cookie with no SameSite attribute as Lax. Check your browser's documentation for the exact default in other browsers.

The table below comes from a small Python model of these rules. It is a simplification. Real browsers have more edge cases, but the four rows show the main difference between the three values.

Python · editable

Output:

request                                                        Strict   Lax    None
1. page on bank.example loads its own image                    True     True   True
2. evil.example puts an img tag pointing at bank.example       False    False  True
3. you click a link on mail.example that opens bank.example    False    True   True
4. evil.example has a form that POSTs to bank.example          False    False  True

Row 4 is the CSRF attack. Lax stops it because the form is a POST, and Strict stops it for the same reason. None allows it, so a login cookie set with None needs a second defence, such as a token that the page must send with each form.

Row 3 shows the trade-off. A user who follows a link from an email is sent to the bank with Lax, so they stay logged in. With Strict they would look logged out. That is why many sites use Lax for login cookies.

Two rules to remember. First, SameSite=None without Secure is refused by browsers. Second, SameSite is a strong default, but it is not a complete protection against every cross-site attack, so keep the other checks the server makes.

Common mistakes

Each error below is real. The first three came from Python and our test server.

1. Typing the attribute name wrong. Python's cookie library checks names as you set them. A typo raises an error at once:

CookieError: Invalid attribute 'sameside'

The fix is to set the correct name, samesite. Browsers do the same check and ignore a malformed Set-Cookie header, so the cookie never appears.

2. A space in the cookie name.

CookieError: Illegal key 'sess ion'

Cookie names cannot hold spaces or some other symbols. Use a plain name such as session.

3. Logging out with a GET request. Our first test sent a GET to /logout. The route accepts POST only, so the server answered:

HTTP/1.0 404 Not Found
 
not found

The user was not logged out. The fix is to make logout a POST button in the page, or to accept the method the route is written for. Logout that changes state should never be a plain link.

4. Clearing the cookie but not the server record. Setting Max-Age=0 deletes the browser's copy only. If the server still has the record, a copied ticket still works. Our replay test returns 200 in that case. The fix is the SESSIONS.pop(...) step.

5. Expecting Max-Age=0 to match a different path. A cookie is identified by its name, domain and path. If you set a cookie with Path=/ and try to delete it with Path=/account, the browser keeps the original. Repeat the same name, domain and path when you clear it.

6. Using SameSite=None without Secure. Browsers ignore a cookie that sets None without Secure. The login then fails with no visible error, and the cookie does not show up in the Application tab. Check the Issues tab in the developer tools, which lists cookies the browser refused and the reason. The exact wording changes between browser versions, so read the reason rather than matching the text.

7. Using a short or guessable id. A counter (1, 2, 3) or a user id is easy to guess. Use secrets.token_urlsafe(32) or your framework's session id generator. The secrets module draws from the system's random source, which is designed for this use. The random module is not.

A debugging walkthrough

Suppose a user says they are logged out every few minutes. Work through these steps in order.

  1. Open the Application tab (Chrome) or the Storage tab (Firefox) and find the cookie. Check its name, domain, path, expiry and flags. If it is missing, the browser never stored it, so the problem is in Set-Cookie. If it is present, go on.
  2. Check the expiry. A Max-Age of 1800 means thirty minutes. If the user is logged out after thirty minutes with no activity, that is the idle timeout working as designed. If the expiry is in the past, the server sent a bad time.
  3. Open the Network tab and watch the request that fails. Look at the Cookie request header. If the cookie is missing from the request, the browser did not send it. Check the domain, the path, Secure on an http:// page, and SameSite on a cross-site request.
  4. Check the server's response. A 401 on a protected page means the server did not find a valid record. Compare the session id in the browser with the id the server created at login. A different value means the server created a new id somewhere, or the cookie was overwritten.
  5. Check whether the server keeps state between requests. Our SESSIONS dictionary lives in one process. If the site runs several copies of the server, a request sent to a copy that does not have the record fails. Sites solve this with a shared store, such as a database or Redis (a fast store that servers share). This is a common cause of "random" logouts in deployed apps.

Variations and edge cases

Tokens instead of cookies. Some apps send the ticket in an Authorization header from JavaScript, and keep it in browser storage instead of a cookie. The server logic is the same: read a random id, look it up, check it has not expired. The difference is that page scripts can read browser storage, so a cross-site script bug can steal the token. A cookie with HttpOnly is harder to steal, but it is sent automatically, which is why SameSite matters. Choose one approach on purpose and do not mix them without a reason.

Signed cookies. A server can put data in the cookie and sign it with a secret key, so any change makes the signature fail. This avoids a server table. The user can still read the data, and the signature only shows whether someone changed it. Never store a secret in it.

Several devices. One user usually has several sessions, one per device. A "log out everywhere" button deletes every record for that user, which the table makes easy if each record stores the user id.

Practice

Exercise 1. A cookie is set with Set-Cookie: cart=7; Path=/; SameSite=None. Why will a browser refuse it, and what header would fix it?

Show the answer

Browsers require Secure on any cookie with SameSite=None. The fix is Set-Cookie: cart=7; Path=/; SameSite=None; Secure, and the page that sets it must be served over HTTPS.

Exercise 2. Using the SameSite table, decide what happens to a Lax login cookie when a user clicks a link from an email to bank.example, and what happens to the same cookie when a form on evil.example POSTs to the bank.

Show the answer

The link is a cross-site top-level GET, so Lax sends the cookie, and the user stays logged in. The form is a cross-site top-level POST, so Lax does not send it, and the attack fails.

Exercise 3. A user logs in at 13:00. The idle limit is 20 minutes and the absolute limit is 4 hours. Requests arrive at 13:15, 13:30 and 14:00, and then stop. Which requests succeed?

Show the answer

The 13:15 request is 15 minutes after login, inside the idle limit, so it succeeds. The 13:30 request is 15 minutes after the previous one, so it also succeeds. The 14:00 request is 30 minutes after 13:30, which is more than the 20-minute idle limit, so it fails with a 401. The absolute limit would end the session at 17:00, so it does not matter here.

Exercise 4. Using the server in this lesson, a user logs in and gets id X. The user then clicks log out. A script replays X to /me and gets 401. Which line of the server made that happen, and what would the result be if that line were removed?

Show the answer

The line SESSIONS.pop(sid, None) in the logout route removed the record. Without it, the record would still be in the table, and replaying X would return 200 with the user's name.

Exercise 5. A developer writes c["session"]["sameite"] = "Lax" in Python and the program stops. What is the error, and what is the fix?

Show the answer

The error is CookieError: Invalid attribute 'sameite'. The attribute name is misspelled. The fix is to write samesite.

Summary

  • The cookie is a ticket, and the server holds the record. Keep user data on the server and send only a random id.
  • Logout must delete the server's record, not only the browser's cookie. Test by replaying the old id.
  • Create a new id at login and delete the old one, so that an id chosen by an attacker stops working.
  • Use an idle timeout and an absolute timeout. The browser's Max-Age alone does not end a server session.
  • Secure keeps the cookie off plain HTTP. Test it in the browser, because curl does not behave the same way.
  • SameSite=Lax blocks most cross-site POST attacks while keeping logins after a link click. None needs Secure.

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Routers, hops and traceroute