Skip to content
KEDBYTE
How Identity Works
Chapter
36

The Session

Part IV · Identity Between Systems|11,999 words|about 52 min read|Volume 4

36.0 What this chapter gives you#

  1. You will be able to state precisely what it means to say that HTTP is stateless, quote the sentence in the specification that says it, and explain why every site that knows your name is working around it.
  2. You will be able to size and generate a session identifier correctly, in bits, with the right random source, and say why a shorter one fails.
  3. You will be able to choose between a server-side session store and a signed stateless session, and defend the choice on revocation grounds.
  4. You will be able to carry out a session fixation attack on paper, and write the one line of code that removes it.
  5. You will be able to list the six routes by which a live session is stolen, and say exactly what each countermeasure does and does not cover.
  6. You will be able to set an idle timeout and an absolute timeout with real numbers, and justify each against a published requirement.
  7. You will be able to say what logout must destroy, and name the five things that most implementations forget to destroy.
  8. You will be able to build a device list with remote revocation, make revocation take effect in a stateless design, and write the whole lifecycle as server-side pseudocode without leaving a hole in it.

Almost everything a website appears to know about you is a repair job. The protocol underneath the web was designed to forget, and so thoroughly that a well-behaved server is not permitted to assume that two requests arriving one after another down the same open connection came from the same person. Every shopping basket, every “welcome back”, every logged-in page you have ever seen exists because somebody built a memory on top of a protocol that has none.

That repair job has a name. We call it a session. A session is the fiction that a series of separate, unrelated requests is a single continuous visit by a single identified person, and it has to be maintained deliberately, request by request, by the server. When it is maintained well, you do not notice it. When it is maintained badly, somebody else becomes you, and no password was ever stolen, no multi-factor prompt was ever answered, and no alarm was ever raised.

This matters more than it used to. For most of the web’s history the industry’s attention was on the login itself: the password, the second factor, the phishing-resistant key. That work has been broadly successful, and so attackers moved. If the front door is hard, you do not pick the lock; you take the key that the door hands out after it opens. The session identifier is that key, and it is a bearer credential, which means exactly what it sounds like: whoever bears it is treated as you, with no further questions asked. This chapter is about that key. What it is, how long it must be, where it is kept, how it is given out, how it is taken away, how it is stolen, and what a correct implementation looks like written out in full. The chapter after this one takes apart the cookie, attribute by attribute, which is the container the key usually travels in; here we are concerned with the thing inside the container, and with the memory on the server that it points at.

The plain version#

The clerk who forgets you the moment you turn around#

Picture a very large government office. Behind one long counter there are two thousand clerks. When you step up to the counter you are served by whichever clerk is free at that instant, and you never get the same one twice. That would be inconvenient but survivable, except for one more thing: every clerk in that building has been given a drug that wipes their short-term memory the moment you step away from the counter. Not gradually. Instantly and completely.

So you walk up and say, “I am Priya Nair, here is my identity card, I would like to see my file.” The clerk checks the card, fetches the file, answers your question, and you step back to read the answer. Then you step forward again to ask a follow-up. The clerk in front of you now, who may or may not be the same person, looks at you with total incomprehension. Who are you. What file. There is no record in that clerk’s head that the last thirty seconds happened.

This is not a flaw in the office. A clerk who remembers nothing can be swapped for any other clerk at any moment. You can hire a thousand more of them on a busy morning and send them home in the afternoon. None of them can get confused about which conversation they are in, because none of them is in a conversation. That is why the office can serve millions of people. The forgetting is the feature.

The web works exactly like this. When your browser asks a website for a page, that ask is complete and self-contained, and the machine that answers it is under no obligation to remember it happened. That machine may be one of hundreds of identical machines. It may be replaced between one of your clicks and the next. This is what people mean when they say the web is “stateless”: state is the technical word for memory of what came before, and the web has none built in.

The numbered ticket#

So how does the office ever get anything done. The answer is the same one the office had before computers: it gives you a ticket.

When you first prove who you are, the clerk does two things. First, the clerk takes an empty box from a shelf in the back room, writes your details on a card, and puts the card in the box. Second, the clerk writes a long number on a small slip of paper, sticks the same number on the box, and hands you the slip. You walk away with a number. The office keeps the box.

From then on, every time you step up to the counter, you put the slip down first. The clerk reads the number, walks to the back room, finds the box with that number on it, reads the card inside, and now knows everything about you that the office decided to remember. The clerk still has no memory of you and does not need one. The memory is in the box, and the number is the pointer to the box.

That is a session. The box on the shelf is the session record on the server. The number on the slip is the session identifier. The act of putting the slip down at the start of every interaction is what your browser does automatically on every single request, forever, without telling you.

Notice what the slip does not contain. It does not say “Priya Nair”. It does not say “account balance 84,300”. It does not say “manager”. It says a number and nothing else. Everything real is in the box, and the box never leaves the building. The slip is worthless to anybody who cannot walk into that particular office and hand it over.

What makes a good ticket number#

The whole scheme rests on one property: nobody must be able to work out a valid number that they were not given.

Suppose the office numbered its boxes 1, 2, 3, in the order people arrived. You come in and get ticket 4,181. You now know, without doing any work at all, that there are almost certainly boxes numbered 4,180 and 4,182 on that shelf, belonging to the people who arrived either side of you. You write 4,180 on a blank slip, hand it over, and the clerk cheerfully fetches somebody else’s box and tells you what is in it. Sequential numbering is a catastrophe. Numbering by date of birth and arrival time is better and still hopeless, because an attacker who knows roughly when you visited and can guess your birthday has a small enough set of possibilities to try them all.

The only safe rule is that the number must be picked at random from a set so enormous that guessing is hopeless, and it must be picked by a source of randomness that nobody can predict. In practice that means a number of about thirty-two hexadecimal digits, drawn from the machine’s cryptographic randomness source. Written out, one looks like this:

05579f0c8241ea3a9bf24afd79ceaf2e

There is no pattern in it. It does not encode the time, your name, your account number, or the machine that issued it, and it cannot be incremented to reach somebody else’s. If you guessed ten thousand numbers every second, and the office had a hundred thousand boxes on the shelf, you would still be guessing for a length of time with more digits in it than the age of the universe. That is the entire point.

One ticket, followed through a morning#

Let us follow a single ticket from start to finish, with real values and real times. The date is 17 August 2026 and the office is a bank’s website.

At 09:13 in the morning, Priya opens the bank’s home page. She has not logged in. The bank still gives her a box, because it wants somewhere to remember which language she has chosen and what she put in a form. That box carries the number 05579f0c8241ea3a9bf24afd79ceaf2e, and the card inside it says almost nothing: no name, not logged in.

At 09:14:03 she types her username and password, and passes the code from her authenticator app. The bank now knows who she is. Here is the important part, and it is the part that is most often skipped: the bank throws the old box away and starts a new one. The new box gets a new number, 95ecc4afec97188a57e233c3ee0db247, and Priya’s browser is given the new slip to replace the old one. The card in the new box says: this is customer 41837, she proved it with a password and an app code, at 09:14:03.

Why throw the first box away. Because Priya was walking around with that first number before she proved who she was, and there is no way to be certain that number was not known to somebody else. The moment the number becomes valuable is the moment it must change. We will come back to this, because it is the whole of one of the classic attacks.

At 09:15 she clicks “accounts”. Her browser sends the slip. The clerk fetches box 95ec..., sees customer 41837, and returns her accounts.

At 09:22 she pays a bill. Same slip, same box.

At 09:41:52 she comes back from a phone call and clicks something else. Same slip, same box. But the office has been watching the clock. Written on the outside of the box are two times. One says “throw this away if nobody touches it for thirty minutes” and it is pushed forward every time she touches it, so it now reads 10:11:52. The other says “throw this away at 17:14:03 no matter what”, eight hours after the box was created, and that one never moves.

At 09:58 she clicks “log out”. The clerk does not simply take the slip back. The clerk walks to the shelf, takes box 95ec..., and destroys it. Then the clerk tells Priya’s browser to throw the slip away too. If Priya, or anybody else, presents that number again, there is no box. There is no “logged out box”. There is nothing.

Two clocks, not one#

The two times written on the box are worth a paragraph of their own, because people routinely implement only one of them.

The first is the idle clock. It answers the question of whether this person has wandered off. If Priya walks away from a shared computer in a library without logging out, the idle clock is what saves her. Thirty minutes after her last click, the box is destroyed. The idle clock is reset by activity.

The second is the total clock. It answers a different question: how long should one act of proving who you are be good for. Priya proved herself once, at 09:14:03. That proof does not stay fresh forever. If she sits at the keyboard clicking something every twenty-nine minutes for three days, the idle clock will never fire, and without a total clock she would still be signed in on Thursday on the strength of a password she typed on Monday. The total clock is not reset by anything. It fires when it fires.

You need both. The idle clock protects against the abandoned screen. The total clock protects against the never-ending session. Each is blind to the case the other one catches.

When somebody else has your ticket#

Now the bad news, which follows directly from how simple the scheme is.

The slip is a bearer item. It is like a cinema ticket or a cloakroom docket. The clerk does not check that the person holding it is the person it was issued to, because the clerk has no memory and no way to check. If somebody takes a photograph of your slip over your shoulder and writes the number on their own piece of paper, the clerk will fetch your box for them. They did not need your password. They did not need your phone. They needed thirty-two characters.

There are only a handful of ways to get those characters. Somebody can listen to you handing the slip over, which is the old problem of sending things across a network unencrypted. Somebody can persuade the office to read your slip out to them, which involves getting malicious instructions to run inside your browser. Somebody can steal the slip directly off your desk, which is what a virus on your laptop does to your browser’s stored data. Somebody can find a copy of your slip lying in a file that should never have contained it, such as a diagnostic log. And somebody can hand you a slip on the way in and wait for the office to make it valuable.

Each of those routes has a specific defence and the defences do not overlap neatly. That is the uncomfortable part, and it is the subject of a large part of this chapter.

Where the plain version stops being true#

The slip is copied, never handed over#

In the office story, when Priya puts the slip on the counter, the clerk can see it and Priya keeps it. In the real thing, something stronger and worse is true: the number is transmitted in full, in the clear at the application layer, on every single request, in both directions at the start. Not shown. Sent.

The honest version: the session identifier is not presented for inspection, it is broadcast. A single page load of a moderately complex website may send it forty or fifty times, once for the page and once for every image, script and stylesheet from the same site. Over a working day a browser may send the same secret several thousand times. Any single one of those transmissions, observed by anybody, is a full compromise. That is why encrypting the connection is not a nice-to-have, and why the number must never be written into anything that gets recorded, such as a web address, because addresses end up in server logs, browser history, bookmarks and the “where did this visitor come from” field sent with the next request.

There is no single shelf, and the box may not exist#

The picture of one back room with shelves is comforting and often wrong.

The honest version: the “shelf” may be memory inside one server process, in which case it vanishes when that process restarts and is invisible to the other forty servers behind the load balancer. It may be a shared database or an in-memory data store that all the servers can reach, which is the common design. It may be split across regions with a replication delay, which means a logout in Mumbai can take a measurable number of milliseconds to be visible in Frankfurt, and during that window the destroyed session still works. Or there may be no box at all: some designs put the whole card inside the slip, sealed so that it cannot be altered, and keep nothing on the server. That design has real advantages and one very large disadvantage, and it gets its own section below.

Throwing away the slip is not logging out#

In the story, logout meant the clerk destroyed the box. In a great many real systems, logout means the office politely asks you to throw your slip away and does nothing else.

The honest version: if the server does not delete or mark the session record, then anybody who has a copy of the identifier is still logged in after you log out. Clearing the cookie in the browser is a client-side action, and the client is not trusted. This is one of the most common real defects in production systems, and it is invisible from the outside, because the person logging out sees the login page and assumes everything worked.

The second half of the same problem: even a correct server-side logout usually kills exactly one session. The one you are sitting in front of. The session on your phone, the session on the laptop you left at the office, the long-lived “keep me signed in” credential, the refresh token held by the mobile app, the session the website created for you at a partner site, all survive. When a user clicks logout because they think somebody has their password, killing one session is close to useless.

The office can be handed a ticket before you arrive#

The story assumed the number always comes from the clerk. It does not have to.

The honest version: many web frameworks will accept any session identifier the browser presents, and if there is no box with that number, they will create one with that number rather than issuing a fresh one. That single behaviour is enough for an attacker to pick a number, get you to start using it before you log in, and then walk in on your session afterwards. This is session fixation, first written up properly by Mitja Kolsek of ACROS Security in December 2002, and it is still found in code today. The fix is short and we will write it out.

The identifier is not the only thing that identifies you#

The story implies that the number is the sole link between requests. In a modern browser it is one of several, and some of the others were not designed for the job.

The honest version: the network connection itself carries continuity that the protocol says must not be relied upon. A single encrypted connection may carry hundreds of requests, and a resumed one may carry your traffic across page loads and even across restarts of the browser. There are also credentials the browser attaches automatically which have nothing to do with sessions, and a whole industry of fingerprinting that identifies a browser without any stored identifier at all. None of these is a session, and using any of them as one is a mistake, but a reader who thinks the identifier is the only thread between requests will be surprised by real traffic.

“Random” is doing a lot of work#

The story said the number is picked at random. Randomness on a computer is not free and not automatic.

The honest version: a computer has two kinds of random number generator available, and they look identical in the source code. One produces numbers that are hard to predict even by somebody who has seen millions of previous outputs. The other produces numbers that are evenly spread and fast to compute, and is entirely predictable to anybody who works out its internal state. Using the second for session identifiers has been a shipped bug in real products more than once, and the resulting numbers are guessable in seconds rather than in eons.

The technical version#

What statelessness actually says#

The current definition of HTTP semantics is RFC 9110, published in June 2022 and designated STD 97, which replaced the older RFC 7230 series from 2014 and, before that, RFC 2616 from 1999. Section 3.3 of RFC 9110 states it directly: “HTTP is defined as a stateless protocol, meaning that each request message’s semantics can be understood in isolation”. The same section adds a consequence that surprises people: “a server MUST NOT assume that two requests on the same connection are from the same user agent unless the connection is secured and specific to that agent”. RFC 9110 even names a real extension that broke this rule, calling out RFC 4559, the June 2006 document describing SPNEGO-based Kerberos and NTLM authentication in Microsoft Windows, as a non-standard extension “known to violate this requirement, resulting in security and interoperability problems”.

Read those two sentences carefully, because they are more specific than the folk version. Statelessness in HTTP does not mean that no state exists anywhere. It means two things:

  1. Each request carries everything needed to interpret it. The meaning of a request does not depend on what came before.
  2. The transport connection is not an identity. Requests that arrive on the same socket may be from different users, different tabs, or different people behind a shared proxy.

This is a design constraint, not an oversight. It is the constraint that lets a website be served by a fleet of interchangeable machines, lets a request be retried against a different one, and lets caches and proxies sit in the middle. Roy Fielding’s 2000 doctoral dissertation, which named the REST architectural style, lists statelessness as one of its defining constraints for exactly these reasons: visibility, reliability and scalability.

Statelessness has one immediate consequence for authentication. If the server cannot remember that you authenticated, then every request must carry evidence of authentication. There are only two families of answer. Either the request carries the original credential every time, or it carries a substitute that the server can resolve back to an authentication event.

HTTP’s own answer is the first family. Section 11 of RFC 9110 defines the authentication framework: the server answers with 401 Unauthorized (section 15.5.2) and a WWW-Authenticate field (section 11.6.1), and the client repeats the request with an Authorization field (section 11.6.2). The browser then attaches those credentials to every subsequent request to that protection space automatically. This works, and it is genuinely stateless. It also has no logout: the specification defines no mechanism by which a user agent can be told to discard credentials it has cached, which is why HTTP Basic authentication ended up as a tool for machines and small internal pages rather than for consumer websites. The second family is the session, and everything that follows here is the engineering of it.

GET /accounts HTTP/1.1
Host: bank.example
Cookie: id=95ecc4afec97188a57e233c3ee0db247
Accept: text/html

HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: text/html; charset=utf-8

That Cookie line is the entire memory of the web. The container it travels in, and every attribute you can put on it, is the subject of chapter 37, The Cookie.

The session identifier: what it is, how big, how made#

A session identifier is a value with no meaning of its own, issued by the server, which the server can use to look up an authenticated session. Three properties define it.

It is opaque. It carries no information. A client cannot parse it and learn anything, and a server must not derive anything from its contents beyond using it as a lookup key. The OWASP Session Management Cheat Sheet is explicit that the value “must be meaningless to prevent information disclosure attacks” and must “never include sensitive information or Personally Identifiable Information”.

It is unguessable. This is a quantitative property and we will put numbers on it below.

It is a bearer credential. Possession is sufficient. Nothing about the identifier proves the holder is the subject, which is why the whole second half of this chapter is about compensating for that.

On size, the OWASP cheat sheet gives two numbers that are often confused. The floor on entropy is 64 bits: “Session identifiers must have at least 64 bits of entropy to prevent brute-force session guessing attacks”, which “when using hexadecimal encoding” means “at least 16 hexadecimal characters long”. The recommendation for a generated value is higher: if you generate your own, use a cryptographically secure pseudorandom number generator “with a size of at least 128 bits”. Take 128 bits as the working number. The 64-bit figure is the point below which the design is broken, not a target.

The cheat sheet’s own worked calculation is worth repeating and correcting slightly. It says that with 64 bits of entropy, 100,000 valid sessions live at once and 10,000 guesses per second, “an attacker can expect to spend approximately 585 years to successfully guess a valid session ID”. That is the time to exhaust the whole space; the expected time to the first hit is about half of it, roughly 292 years. Both are comfortable. What is not comfortable is what happens when the guessing rate is not 10,000 per second, because an attacker with a botnet and an endpoint that does not rate-limit is not restricted to a modem’s pace.

Encoding Chars Bits
Hex, 8 random bytes 16 64
Hex, 16 random bytes 32 128
Base64url, 16 bytes 22 128
Base64url, 24 bytes 32 192

Those bit figures are the entropy of the random input, not the length of the string. Sixteen random bytes carry 128 bits whether you print them as 32 hex characters or 22 base64url characters. Encoding never adds entropy.

Here is what real frameworks do, as of August 2026.

Framework Default identifier Bits
PHP sessions 32 chars, 4 bits each 128
Apache Tomcat 16 bytes as hex 128
Django 32 chars from 36 ~165
ASP.NET 24 chars from 32 120

PHP’s default comes from session.sid_length, whose default is 32, combined with session.sid_bits_per_character, whose default is 4, giving a 32- character hexadecimal identifier. Both settings were deprecated in PHP 8.4.0 and are scheduled for removal in PHP 9, at which point the identifier becomes a fixed 32-character hexadecimal value, which is 128 bits. Tomcat’s SessionIdGenerator has a sessionIdLength attribute whose default is 16, documented as 16 random bytes rendered as 32 hexadecimal characters. Django’s session backend calls get_random_string with a length of 32 over an alphabet of lowercase letters and digits, which is 36 symbols, giving about 165 bits. ASP.NET’s SessionIDManager.CreateSessionID is documented as “a randomly generated number encoded into a 24-character string consisting of lowercase characters from a to z and numbers from 0 to 5”, a 32-symbol alphabet at 5 bits a character, which is 120 bits.

On generation there is one absolute rule: use the platform’s cryptographically secure random source and nothing else. That means os.urandom or secrets in Python, crypto.randomBytes in Node, java.security.SecureRandom in Java, random_bytes in PHP, RandomNumberGenerator in .NET, or /dev/urandom on Linux. Do not use rand, Math.random, java.util.Random, mt_rand, or anything seeded from the clock or the process identifier.

The reason that rule is absolute is one of the oldest published lessons in web security. In January 1996, Ian Goldberg and David Wagner published “Randomness and the Netscape Browser” in Dr. Dobb’s Journal. Netscape Navigator 1.1 seeded its random number generator from the time of day in seconds and microseconds, the process identifier and the parent process identifier, mixed with MD5. Their analysis found at most 47 bits of real randomness, and with knowledge of the process identifiers they recovered a session key in about 25 seconds on an HP 712/80. The lesson generalizes: the size of the printed value tells you nothing; only the entropy of the seed matters.

Where the identifier travels#

There are three transports in practice and they are not equal.

The cookie is the default. The server sets it once, the browser attaches it automatically to every matching request, and script access can be blocked. Every attribute that makes this safe, and the exact scope rules, belong to chapter 37.

An explicit header, usually Authorization, is the normal choice for programmatic clients and single-page applications that manage their own credential storage. It is not attached automatically, which removes one whole class of cross-site problem and adds the burden of storing the value somewhere in the page, where script can reach it.

The web address is the wrong answer and has been for twenty years. Identifiers placed in the address end up in server access logs, proxy logs, browser history, bookmarks, shared links, and the referrer field sent to the next site. RFC 9110 section 17.9, “Disclosure of Sensitive Information in URIs”, makes the general point. The OWASP Top Ten of 2021 lists “Exposes session identifier in the URL” as a defining symptom of category A07, Identification and Authentication Failures, called Broken Authentication in 2017 when it sat at position two. PHP’s session.use_only_cookies has defaulted to 1 for years, and in PHP 8.4.0 both disabling it and enabling session.use_trans_sid, which rewrites addresses to carry the identifier, were deprecated.

Server-side session stores#

The classical design keeps a record on the server keyed by the identifier. The identifier is a pointer; the record is the truth. A minimal record for our worked example looks like this.

{
  "sid": "95ecc4afec97188a57e233c3ee0db247",
  "user_id": 41837,
  "created_at": 1786958043,
  "last_seen_at": 1786959712,
  "absolute_expires_at": 1786986843,
  "idle_expires_at": 1786961512,
  "auth_method": "password+totp",
  "aal": 2,
  "amr": ["pwd", "otp"],
  "ip_created": "203.0.113.44",
  "ua_hash": "3f1c9a0e",
  "csrf_secret": "c1f0...",
  "revoked": false
}

Every field earns its place. The user identifier is what makes it a session rather than a scratchpad. The timestamps drive the two clocks and the activity tracking. Recording how the user authenticated, and to what assurance level, is what lets you demand a step-up later for a sensitive action without forcing a full re-login. The creation address and a hash of the browser string are for the device list and for anomaly signals, not for hard binding, for reasons given below. The revoked flag lets a remote sign-out take effect before the record is physically deleted.

Where the record lives is an operational decision with security consequences.

Store Survives restart Shared across nodes
Process memory No No
Sticky + local No No
Shared cache Configurable Yes
Database Yes Yes

Process memory is the default in several frameworks and is a trap. The express-session documentation says so in its own words: the default MemoryStore “is purposely not designed for a production environment. It will leak memory under most conditions”. Sticky sessions, where the load balancer pins a browser to one node, make process memory appear to work until that node is restarted during a deployment and every user is signed out at once.

A shared in-memory cache such as Redis or Memcached is the common production answer. It gives a native expiry mechanism, and one trap: the store’s own expiry must be treated as a floor under your application’s expiry check, never as a replacement for it. If you set a key to expire in eight hours and rely on that alone, you have implemented an absolute timeout and no idle timeout. Write both checks in the application and set the store’s expiry to the later of the two, so that the store cleans up after itself but never decides policy.

A relational database gives durability and easy querying, which matters for the device list and for audit. It costs a write on every request if you update last_seen_at every time. The usual compromise is to write the activity timestamp only when it has moved by more than some granularity, say sixty seconds. Django exposes exactly this trade-off as SESSION_SAVE_EVERY_REQUEST, which defaults to False.

Two more practical points. Session data is read on essentially every request, so the store is on the hot path of the whole application: when it is slow everything is slow, and when it is down nobody is logged in. And two browser tabs share one session record, so concurrent writes to the session blob are a real source of lost updates. Store as little as possible, and keep genuinely concurrent state out of the session.

Stateless signed sessions#

The alternative is to put the record itself in the client’s hands, sealed so it cannot be altered. The server keeps nothing.

The seal is a message authentication code, which is a fixed-size value computed from the data and a secret key, such that anybody with the key can recompute it and anybody without the key cannot forge it. The usual construction is HMAC with SHA-256. Here is a complete worked example using our session, with a key that you can reproduce: the SHA-256 hash of the ASCII string “kedbyte-demo-session-key”.

key (hex):
  6f4b6ca516fafadbc9c9007e13f10bc13822d9eb0baedc0c3a0d2a18
  9c7c9fc4

message:
  u=41837|iat=1786958043|exp=1786986843

HMAC-SHA-256(key, message), hex:
  63a8554f93091473704a253cb088bb853d823bc006bee2d276eddae0
  764e9eb4

The cookie carries the message and the code together. On each request the server recomputes the code over the received message with its key, compares the two in constant time, checks that the expiry has not passed, and believes the user identifier. It never looks anything up. Real deployments usually use a structured format rather than a hand-rolled one, and the JSON Web Token family is by far the most common; its exact structure, its registered claims and its long catalogue of failure modes are the subject of chapter 39, and bearer-token semantics generally are chapter 38.

The trade-off is sharp and it is worth stating as a table rather than as prose.

Property Server store Signed cookie
Read cost Store lookup Local verify
Revoke one Delete record Hard
Size on wire Tiny Grows
Key leak impact Limited Total forgery

The revocation row is the one that decides most designs. With a server store, logging somebody out is a delete. With a signed cookie there is no record to delete, so the credential remains valid until it expires, and the only ways to stop it early are to keep a deny-list of revoked sessions, which reintroduces the server-side state you were trying to avoid, or to bump a per-user counter that is included in the signed data and checked on every request, which reintroduces a lookup. Both work. Neither is stateless.

The key leak row is not theoretical. In its annual report filing, disclosed in press coverage on 2 March 2017, Yahoo said that an intruder had been able to forge authentication cookies, and that forged cookies had been used or taken in 2015 and 2016 affecting approximately 32 million accounts. The intruder did not need any user’s password. They needed the ability to produce a valid seal. A separate and instructive example is CVE-2019-5420, published on 27 March 2019 with a CVSS v3.1 base score of 9.8, in which Ruby on Rails in development mode derived its secret key from a predictable value, so an attacker could guess the key and forge signed data, which in that framework led to remote code execution.

The practical middle path, and the one most large systems converge on, is a hybrid. Issue a short-lived signed credential, minutes rather than hours, so the common case needs no lookup, and back it with a long-lived server-side record that must be consulted to mint a new one. Revocation then takes effect within one short-credential lifetime, which is a number you choose, can defend and can state as a worst case.

Session fixation, worked end to end#

Mitja Kolsek of ACROS Security published “Session Fixation Vulnerability in Web-based Applications” in December 2002; the paper was announced publicly on 18 December 2002 and revised in February 2007. Its definition is exact: “In a session fixation attack, the attacker fixes the user’s session ID before the user even logs into the target server, thereby eliminating the need to obtain the user’s session ID afterwards.”

The paper divides server behaviours into two kinds. A permissive system will accept a session identifier it never issued and create a session with that value. A strict system only accepts identifiers it issued, which forces the attacker to obtain a real one first by visiting the site. Kolsek named the Macromedia JRun server and PHP as permissive at the time, and Microsoft Internet Information Server as strict. Strictness prevents nothing on its own, because obtaining a valid unauthenticated identifier is a single request.

Here is the attack with our values.

1. Attacker visits bank.example, is issued the identifier
   05579f0c8241ea3a9bf24afd79ceaf2e for an anonymous session.

2. Attacker gets that value into Priya's browser as the
   session cookie for bank.example. Routes include:
     - a link with the identifier in the address, on a site
       that accepts identifiers from the address
     - script injected into any page on a sibling host that
       can write cookies for the parent domain
     - a network position that lets an unencrypted response
       be modified to include a Set-Cookie header

3. Priya visits bank.example and logs in normally. She types
   her real password and her real one-time code. The server
   marks session 0557... as authenticated for user 41837.

4. Attacker sends a request with 0557... and is user 41837.

Step 3 is what makes this attack unpleasant. The victim’s login is entirely genuine. Multi-factor authentication does not help, because the attacker is not trying to authenticate; they are waiting for the victim to authenticate on their behalf. No credential is ever stolen. Nothing looks anomalous in the authentication logs.

The fix is one line, and it is the same line in every language: when the privilege level of a session changes, issue a new identifier and destroy the old record. Not “copy the data across and hope”. Destroy the old record, so that the value the attacker holds resolves to nothing.

Platform Call
PHP session_regenerate_id
Servlet 3.1 plus changeSessionId
Django request.session.cycle_key
Rails reset_session
express-session req.session.regenerate

In PHP the argument matters: session_regenerate_id(true) deletes the old session file, while the default of false leaves it valid and defeats the purpose. In Java, changeSessionId was added in Servlet 3.1, whose final release under JSR 340 was 28 May 2013; before that the idiom was to invalidate and recreate by hand. Spring Security documents changeSessionId as the default on Servlet 3.1 and newer containers, migrateSession as the default on older ones, and a none option it explicitly warns against.

There is a second half to the fix that is easy to miss. Regenerating at login stops the attacker’s fixed identifier from surviving the privilege change. It does not stop the server from adopting attacker-chosen values in the first place, which matters for the period before login and for any other state kept in the anonymous session. Configure the server to refuse identifiers it did not issue. In PHP that is session.use_strict_mode, which the manual describes as protection against session fixation and calls mandatory for general session security, and whose default is 0. Turn it on.

The rule is broader than login. Regenerate on any privilege change: sign in, sign in as a different user, elevate to an administrative mode, step up after a second factor, and after a password change. The OWASP cheat sheet puts it as “The session ID must be renewed or regenerated by the web application after any privilege level change within the associated user session.”

Session hijacking: the theft routes#

Hijacking is the other half of the attack surface: the session is legitimate, and somebody else obtains its identifier. There are six routes worth separating, because the countermeasures are different for each and none of them covers more than a couple.

The first is observation on the network. This was the dominant route for years, and it was made unignorable on 24 October 2010 when Eric Butler released Firesheep at Toorcon 12, a browser extension that watched a shared wireless network, picked up session cookies from popular sites that were not fully encrypted, and presented the hijacked accounts as a list of names and photographs to click on. The technical content was old; the demonstration was the point. Within a few years full-site encryption went from a feature to a default across the consumer web.

The second is script running in your page. If an attacker can execute JavaScript in the origin of the site, they can read anything the page can read. Marking the cookie so that script cannot read it removes the simplest version of this, and that attribute belongs to chapter 37. It does not remove the attack: a script in your page does not need to steal the identifier, because it can simply make requests and the browser will attach the identifier for it. Treat cross-site scripting as a full session compromise regardless of cookie flags.

The third is malicious software on the device. Information-stealing malware targets the browser’s cookie storage directly, decrypts it using the operating system credentials of the logged-in user, and exfiltrates whole cookie jars. Nothing in the cookie model resists this, because the malware is inside the trust boundary. This is the motivation for the cryptographic device binding described in the next section.

The fourth is leakage into places that keep records. Identifiers in web addresses end up in logs; identifiers in diagnostic captures end up in support tickets. The clearest public example is the Okta support case management system incident: according to Okta’s own root-cause article of 3 November 2023, an attacker had access from 28 September to 17 October 2023, detected on 19 October, and among the files taken were HAR files uploaded by customers for troubleshooting that contained live session tokens; files belonging to 134 customers were accessed and five customers experienced actual session hijacking. A HAR file is a complete recording of a browser’s network traffic, headers included, so it contains session cookies by construction.

The fifth is guessing, which we have already priced. It is a real route only when the identifier is short or the generator is weak, as in the Netscape 1.1 case of 1996. The sixth is physical or shared access: an unlocked screen, a family computer, a kiosk. That is what idle timeouts exist for.

Theft route Primary countermeasure
Network observer TLS everywhere, HSTS
Script in page CSP, output encoding
Device malware Device-bound sessions
Logs and HARs Never log the value
Guessing 128 bits from a CSPRNG
Shared machine Idle timeout, logout

Read that table with its limitations in mind. Transport encryption stops the observer and does nothing about malware. Blocking script access to the cookie stops exfiltration and does nothing about a script acting in place. Device binding stops a stolen cookie being replayed elsewhere and does nothing about an attacker operating from the victim’s own machine. There is no single control here, only a set of controls with disjoint coverage, and the honest way to present a session design is a table like this one with the gaps left visible.

Binding a session to something other than the bearer#

If bearer semantics are the problem, the obvious response is to bind the session to something the attacker cannot copy. Several generations of this idea exist and they differ enormously in how well they work.

Binding to the client address is the oldest and the most tempting, and the one most likely to be regretted. A mobile user changes address when moving between mobile data and wireless. Carrier-grade address translation puts thousands of unrelated users behind one address, so the check passes for all of them. Corporate networks egress through pools. A hard address check produces a stream of spurious logouts and stops an attacker only if the attacker is not behind the same proxy as the victim. A softer version, where a change of address contributes to a risk score and may trigger a step-up challenge for a sensitive action, is defensible. A hard check on every request is not, unless you control the network.

Binding to the browser identification string is weaker still. It is a header, it is sent by the client, and an attacker who has the cookie almost certainly has the header too, because both came from the same capture. Treat it as a signal, not a control.

Binding at the transport layer is the version that actually works, because it involves a private key the attacker cannot copy. The standardized attempt was Token Binding, specified in October 2018 across RFC 8471, “The Token Binding Protocol Version 1.0”, RFC 8472 for the TLS negotiation extension, and RFC 8473, “Token Binding over HTTP”. It bound cookies to a key held by the browser and proved possession during the handshake. Browser support was withdrawn and it did not achieve deployment. [UNVERIFIED: the Chrome version that removed Token Binding support]

The current attempt is Device Bound Session Credentials. The browser generates a key pair in the device’s hardware security module, gives the site the public key at login, and the site issues short-lived cookies that the browser refreshes by proving possession of the private key at a dedicated refresh endpoint. A stolen cookie is then useful only until it expires, and the thief cannot mint another because the key cannot leave the hardware. Google ran a second origin trial from October 2025 to early February 2026 on Chrome 142 and on Windows devices with a Trusted Platform Module, and the design uses a Secure-Session- header prefix and a 403 response rather than a 401 to signal a challenge. On 28 May 2026 Google announced that DBSC on Chrome for Windows is generally available and enabled by default for Google Workspace users. As of August 2026 it is a Chrome-and-Windows capability rather than a cross-browser standard in wide deployment, and a site that adopts it needs the classic controls anyway for every other client.

The related idea that requires no new browser feature is continuous evaluation: recording the signals in the session record, comparing each request against them, and demanding re-authentication when the picture changes materially or when the action is sensitive.

Idle timeout and absolute timeout#

Two clocks, stated precisely.

The idle timeout is the maximum time between two requests on a session before the session is destroyed. It is reset by activity. It bounds the window during which an abandoned but still valid session can be used by somebody who reaches the device.

The absolute timeout, also called the session lifetime or the reauthentication timeout, is the maximum time from the authentication event to the destruction of the session, regardless of activity. It is never reset. It bounds how long one act of proving identity is allowed to be worth something, which in turn bounds the damage from a session identifier stolen early and used continuously.

Implement both. Store both as absolute timestamps in the record, not as durations, and compare the current time against both on every request. The common bug is to implement the idle timeout as the store’s own expiry and to forget the absolute one entirely, which means a session that is polled by an open browser tab every thirty seconds never ends.

Published requirements differ, and the differences are informative.

Source Idle Absolute
OWASP, high value 2-5 min 4-8 hours
OWASP, low risk 15-30 min 4-8 hours
NIST AAL2 1 hour 24 hours
NIST AAL3 15 min 12 hours

The OWASP Session Management Cheat Sheet gives “2-5 minutes for high-value applications and 15-30 minutes for low risk applications” for the idle timeout, and says that for applications used through a working day “an appropriate absolute timeout range could be between 4 and 8 hours”.

The NIST figures are from Special Publication 800-63B revision 4, published on 1 August 2025 as part of SP 800-63-4. Section 5 covers session management and section 5.2 covers reauthentication, with the per-level requirements in the assurance level sections. At AAL1, “a definite reauthentication overall timeout SHALL be established, which SHOULD be no more than 30 days”. At AAL2, the overall timeout “SHOULD be no more than 24 hours” and “the inactivity timeout SHOULD be no more than 1 hour”. At AAL3, “the overall timeout for reauthentication SHALL be no more than 12 hours” and “the inactivity timeout SHOULD be no more than 15 minutes”. Note that revision 4 relaxed the AAL2 numbers relative to revision 3 of June 2017, which had used 12 hours and 30 minutes; if you are working from an older compliance document, check which revision it cites.

The same document also specifies the session secret itself: session bindings must use secrets generated by an approved random bit generator and “at least 64 bits in length”, they must be erased or invalidated at logout, and they must be transferred only over an authenticated protected channel.

Payment card rules are more prescriptive and shorter. PCI DSS requirement 8.2.8 requires reauthentication after 15 minutes of idle time; the PCI Security Standards Council’s own FAQ on that requirement explains the purpose as preventing “an unauthorized person from using an unattended console/PC to gain access to the user’s computer and accounts”.

Choosing values is a business decision with three inputs: what an attacker can do in the window, how often a legitimate user would be interrupted, and what a regulator requires. Write them down in that order, pick numbers, and put them in configuration where they can be changed without a code release. For our worked bank session, thirty minutes idle and eight hours absolute are the values used earlier, and both appear in the record as computed timestamps rather than as durations.

A third interval is worth adding, and it is not a timeout. Rotate the identifier periodically during a long session, say every fifteen minutes of activity, in addition to rotating it at every privilege change. Rotation limits the useful life of an identifier captured at an arbitrary moment, and costs one extra write. Tolerate a brief overlap so that requests already in flight with the old value do not fail.

Finally, “keep me signed in” is not a long absolute timeout. It is a separate long-lived credential that can mint a session, and it should be built as one: a distinct random value, stored hashed on the server against the user and a device label, single-use, and rotated on every use so that if an old value is ever presented again you know a copy exists and can revoke the whole family. Charles Miller described this rotating scheme in “Persistent Login Cookie Best Practice” on 19 January 2004 and it has survived unchanged in its essentials. A session minted from a remembered credential should be marked as such and not trusted for high-value actions without a fresh authentication.

Logout, and what it usually forgets#

Logout is the operation most often implemented as a cosmetic change.

The minimum correct behaviour has three parts, in this order. First, destroy the server-side record, or if you are using a deny-list design, record the revocation before returning. Second, instruct the browser to remove the cookie, by setting it to an empty value with an expiry in the past and with exactly the same scope attributes it was set with, because a cookie set with different attributes is a different cookie and the original will survive. Third, respond in a way that does not leave the previous authenticated page in a cache that the back button can reach, which means Cache-Control: no-store on responses that carry personal content. The OWASP cheat sheet states this last one directly: session identifiers “must never be cached”, and responses containing them should include Cache-Control: no-store.

Order matters. Destroy the record first. If you clear the cookie first and the record deletion then fails, the user believes they are logged out and an attacker holding the identifier is still inside. Logout must also be idempotent: requesting it with a session that has already ended must succeed quietly rather than error. It should be a POST carrying a cross-site request forgery token, because a forced cross-site logout is a real if minor nuisance attack; that token belongs to chapter 37.

Now the list of things that a logout usually does not invalidate, and should.

The other sessions of the same user. A logout button ends one session. A “sign out everywhere” control, and an automatic global sign-out on password change and on multi-factor enrolment change, are separate features and both are needed.

Long-lived credentials that can mint a new session. The remembered-login cookie, and any refresh token held by a mobile or single-page client, must be revoked as part of logout, or the user is signed back in on the next visit. Refresh token mechanics and rotation are covered in chapter 38.

Connections already established. An open WebSocket or long-lived streaming connection was authorized when it was opened and typically is not re-checked afterwards. Sessions must be linked to their live connections so that revocation can close them.

Cached content in the browser. Personal pages already rendered remain in the back-forward cache unless the responses forbade it. The Clear-Site-Data response header exists for exactly this, and a value of “cookies” is specified to remove cookies for the origin along with HTTP authentication credentials and origin-bound tokens; the specification also defines cache, storage, executionContexts and clientHints directives and a wildcard. State its status honestly: it is a W3C Working Draft of 30 November 2017 with an editor’s draft of 10 November 2023, and browser support for individual directives is uneven. Use it as an addition, not as your logout mechanism.

Sessions at other parties. If your site signs users in to downstream applications through federation, those applications hold their own sessions, and ending yours does nothing to theirs. Propagating a logout across a federation is a defined but frequently unimplemented capability; front-channel and back-channel logout are handled in chapter 41.

A short table, because this list is the one most often reduced to its first row in code review.

Artefact Ended by logout?
Current session record Should be, often is
Other device sessions Usually not
Remember-me cookie Often not
Refresh tokens Often not
Open sockets Almost never
Federated sessions Almost never

Concurrent sessions, device lists and remote revocation#

A user is not one session. They are a set of sessions, and the set is the thing that should be visible and controllable.

The data structure is straightforward: alongside the session records, keep an index from user identifier to the set of that user’s live session identifiers. With a relational store this is just an index on the user column. With a key-value store it is an explicit set that must be maintained on create and on delete, and reconciled periodically, because sets and individual keys can drift apart when keys expire on their own.

That index buys three features.

Concurrency limits. Spring Security implements these with maximumSessions, where a second login invalidates the first, and maxSessionsPreventsLogin, where the second login is refused instead. Both are defensible and they have opposite failure modes: the first lets an attacker who has the password evict the real user, the second lets them lock the real user out. For most consumer products, no limit plus a good device list is better.

The device list. A page that shows the user every live session, with enough information to recognize which is which, and a button to end each one. Useful columns are when the session started, when it was last used, an approximate location derived from the address, a description of the browser and operating system, and a clear marker for the session the user is currently sitting in. The information should be descriptive, not decisive: it is there to help a person recognize an intruder, not to make an access control decision.

Remote revocation. Ending another session must take effect quickly and must be verifiable. With a server-side store, delete the record, or set the revoked flag and delete it later if you want the record for audit. With a stateless signed session, deletion is impossible and there are exactly two workable patterns.

Pattern A: deny-list
  Store revoked session identifiers until their own
  expiry passes. Check the list on every request.
  Cost: one lookup per request, list size bounded by
  the absolute timeout.

Pattern B: user epoch
  Keep an integer per user, included in the signed
  session data. Increment it to revoke everything for
  that user at once. Check the value on every request.
  Cost: one lookup per request, but cacheable, and it
  revokes all sessions rather than one.

Hybrid, common in practice:
  Signed credential with a 5 to 15 minute lifetime,
  refreshed against a server-side record. Revocation
  takes effect within one credential lifetime, with no
  per-request lookup at all.

Whichever you choose, state your revocation latency as a number and test it. “Sign out everywhere” that takes effect within fifteen minutes is a defensible product. “Sign out everywhere” that quietly does nothing for an hour is a defect that surfaces at the worst possible moment, when somebody is trying to lock an intruder out.

The events that must trigger a global revocation are worth listing: password change, password reset, a second factor added or removed, recovery email or phone changed, account recovery completed, and any administrative action taken because of suspected compromise. Each is a moment where the set of people who can authenticate has changed, and every existing session predates the change.

A correct lifecycle, as pseudocode and as a trace#

Here is the whole thing written out. It is deliberately explicit; every line in it corresponds to something discussed above.

CONSTANTS
  ID_BYTES        = 16          # 128 bits
  IDLE_SECONDS    = 1800        # 30 minutes
  ABSOLUTE_SECS   = 28800       # 8 hours
  ROTATE_SECONDS  = 900         # 15 minutes
  OVERLAP_SECONDS = 10

FUNCTION new_id():
  RETURN hex(csprng_bytes(ID_BYTES))

FUNCTION create_session(user_id, auth_method, aal, req):
  now = current_time()
  s = {
    sid:        new_id(),
    user_id:    user_id,
    created_at: now,
    last_seen:  now,
    idle_exp:   now + IDLE_SECONDS,
    abs_exp:    now + ABSOLUTE_SECS,
    rotate_at:  now + ROTATE_SECONDS,
    auth:       auth_method,
    aal:        aal,
    ip:         req.client_ip,
    ua:         hash(req.user_agent),
    csrf:       hex(csprng_bytes(32)),
    revoked:    false
  }
  store.put(s.sid, s, ttl = ABSOLUTE_SECS)
  index.add(user_id, s.sid)
  RETURN s

FUNCTION on_login(req, credentials):
  user = verify_credentials(credentials)    # may fail
  IF req.session EXISTS:
      destroy_session(req.session.sid)      # fixation fix
  s = create_session(user.id, "pwd+totp", 2, req)
  set_session_cookie(s.sid)
  audit("login", user.id, s.sid, req.client_ip)
  RETURN s

FUNCTION load_session(req):
  sid = read_session_cookie(req)
  IF sid IS EMPTY: RETURN ANONYMOUS
  IF NOT looks_like_our_id(sid): RETURN ANONYMOUS
  s = store.get(sid)
  IF s IS NULL: RETURN ANONYMOUS       # strict mode:
                                       # never adopt
  now = current_time()
  IF s.revoked:            destroy(s); RETURN ANONYMOUS
  IF now > s.abs_exp:      destroy(s); RETURN ANONYMOUS
  IF now > s.idle_exp:     destroy(s); RETURN ANONYMOUS
  IF user_epoch(s.user_id) > s.epoch_at_issue:
                           destroy(s); RETURN ANONYMOUS
  RETURN s

FUNCTION touch_session(s, req):
  now = current_time()
  s.last_seen = now
  s.idle_exp  = min(now + IDLE_SECONDS, s.abs_exp)
  IF now > s.rotate_at:
      old = s.sid
      s.sid       = new_id()
      s.rotate_at = now + ROTATE_SECONDS
      store.put(s.sid, s, ttl = s.abs_exp - now)
      store.expire(old, OVERLAP_SECONDS)
      index.replace(s.user_id, old, s.sid)
      set_session_cookie(s.sid)
  ELSE:
      store.put(s.sid, s, ttl = s.abs_exp - now)

FUNCTION on_privilege_change(s, new_aal, req):
  old = s.sid
  s.sid = new_id()
  s.aal = new_aal
  store.put(s.sid, s, ttl = s.abs_exp - current_time())
  store.delete(old)
  index.replace(s.user_id, old, s.sid)
  set_session_cookie(s.sid)

FUNCTION on_logout(req):
  s = load_session(req)
  IF s IS NOT ANONYMOUS:
      destroy_session(s.sid)
      revoke_remember_me(s.user_id, req.device_id)
      revoke_refresh_tokens(s.user_id, s.sid)
      close_open_sockets(s.sid)
      audit("logout", s.user_id, s.sid, req.client_ip)
  clear_session_cookie()          # same attributes
  send_header("Cache-Control", "no-store")
  send_header("Clear-Site-Data", "\"cookies\"")
  RETURN redirect("/")            # idempotent

FUNCTION destroy_session(sid):
  s = store.get(sid)
  IF s IS NOT NULL:
      index.remove(s.user_id, sid)
  store.delete(sid)

FUNCTION revoke_all_for_user(user_id, reason):
  FOR sid IN index.list(user_id):
      destroy_session(sid)
  revoke_all_remember_me(user_id)
  revoke_all_refresh_tokens(user_id)
  increment_user_epoch(user_id)
  audit("revoke_all", user_id, reason)

Four details in that listing are easy to skim past and are the difference between correct and nearly correct. In load_session, a missing record returns anonymous and never creates a session with the presented identifier; that single line is strict mode, and it is what stops an attacker choosing the value. In touch_session, the idle expiry is capped at the absolute expiry with a min, without which the idle clock can push a session past its absolute limit by one idle period. In on_login, the old session is destroyed before the new one is created, and the new identifier is a fresh random value rather than a copy of anything. In on_logout, the cookie is cleared with the same attributes it was set with, and every derived credential is revoked, not just the session record.

Here is the same lifecycle as a diagram, with our worked values, on 17 August 2026.

09:13:41  GET /                     no cookie
          -> create anon session    sid=0557...2e
          -> Set-Cookie: id=0557...2e

09:14:03  POST /login               cookie 0557...2e
          verify password + TOTP    OK, user 41837
          -> destroy   0557...2e    <- fixation fix
          -> create    95ec...47    aal=2
          idle_exp = 09:44:03
          abs_exp  = 17:14:03
          -> Set-Cookie: id=95ec...47

09:15:10  GET /accounts             cookie 95ec...47
          -> touch, idle_exp = 09:45:10

09:22:58  POST /pay                 cookie 95ec...47
          -> touch, idle_exp = 09:52:58

09:41:52  GET /statements           cookie 95ec...47
          -> touch, idle_exp = 10:11:52
          -> rotate due, new sid    a41c...9b
             old kept 10s, then gone

09:58:20  POST /logout              cookie a41c...9b
          -> destroy a41c...9b
          -> revoke remember-me, refresh tokens
          -> clear cookie, Cache-Control: no-store

09:58:31  GET /accounts             cookie a41c...9b
          -> store miss -> ANONYMOUS -> 302 /login

The last two lines are the test. If a request with the old identifier eleven seconds after logout returns account data, the logout was cosmetic. That test belongs in every codebase that has a login form.

36.98 Common wrong ideas#

Wrong: HTTPS makes session hijacking impossible. Right: Transport encryption defeats exactly one theft route, the passive network observer, which is the route Firesheep demonstrated in October 2010. It does nothing against script running in the page, information-stealing malware reading the browser’s cookie store, session identifiers written into logs or diagnostic captures, or somebody sitting at an unlocked screen.

Wrong: A session identifier only needs to be unique. Right: Uniqueness is trivial and irrelevant; a counter is unique. The requirement is unpredictability, which means at least 64 bits of entropy as a floor and 128 bits as the working target, drawn from a cryptographically secure random source, so that knowing any number of previous identifiers gives no help in producing a valid one.

Wrong: Logging out clears the cookie, so the session is over. Right: Clearing the cookie is a request to the client, and the client is not trusted. Unless the server destroys or explicitly revokes the session record, anyone holding a copy of the identifier remains authenticated, and the user who clicked logout has no way to tell.

Wrong: Because there is no server-side record, a signed stateless session cannot be hijacked. Right: It is still a bearer credential and is stolen the same ways. Removing the record removes the ability to revoke, which makes theft worse, and it concentrates all security in one signing key whose compromise permits forging any user’s session, as Yahoo disclosed for the forged cookies used against about 32 million accounts in 2015 and 2016.

Wrong: Multi-factor authentication protects against session fixation. Right: In a fixation attack the victim performs the full genuine authentication, second factor included, on an identifier the attacker already holds. Stronger authentication makes the login harder and makes no difference at all to the attack. The fix is to issue a new identifier and destroy the old record at the moment the privilege level changes.

Wrong: An idle timeout is enough; an absolute timeout just annoys users. Right: They answer different questions. The idle timeout bounds the abandoned session; the absolute timeout bounds how long one act of authentication remains worth something. A session polled by an open tab every thirty seconds never trips an idle timeout, which is why NIST SP 800-63B revision 4 specifies both an inactivity timeout and an overall reauthentication timeout at each assurance level.

Wrong: Binding a session to the client’s IP address stops hijacking. Right: It breaks legitimate users on mobile networks and behind carrier-grade address translation, and it fails to stop an attacker who shares the victim’s egress address, which includes anyone on the same corporate network or the same public wireless. Use address change as a risk signal that can trigger a step-up challenge, not as a hard per-request check.

Wrong: Marking the session cookie so that JavaScript cannot read it makes cross-site scripting a low-severity issue. Right: It stops the identifier being exfiltrated, which is worth having, but a script running in your origin does not need the identifier; it can issue requests and the browser attaches the identifier for it. Any script injection into an authenticated origin should be treated as full session compromise.

Wrong: Session data is a convenient place to keep the user’s details so we do not have to look them up. Right: Anything cached in the session goes stale, including permissions and role membership, so a revoked administrator keeps administering until their session ends. Keep the session small and read authorization data fresh, or with a short cache.

Wrong: Regenerating the session identifier at login is the whole fix for fixation. Right: It is the essential half. The other half is refusing to adopt an identifier the server never issued, which in PHP is session.use_strict_mode and defaults to off. Without it the server will still create sessions with attacker-chosen values before login, and any pre-login state can be manipulated.

36.99 Chapter summary in 20 lines#

  1. RFC 9110, published June 2022 as STD 97, states in section 3.3 that HTTP is a stateless protocol in which each request’s semantics can be understood in isolation.
  2. The same section forbids a server from assuming that two requests on one connection came from the same user agent unless the connection is secured and specific to that agent.
  3. Statelessness is a deliberate design constraint that allows interchangeable servers, retries, caches and proxies, and it is the reason the web scales.
  4. Because the server may not remember, every request must carry evidence of authentication, either the original credential or a substitute that resolves to an authentication event.
  5. A session is that substitute: a server-issued identifier that points at a record of who authenticated, how, and when.
  6. A session identifier must be opaque, must carry no information, and must be unguessable, and it is a bearer credential, so possession alone is treated as identity.
  7. OWASP sets a floor of 64 bits of entropy, equal to 16 hexadecimal characters, and recommends generating at least 128 bits from a cryptographically secure random source.
  8. Identifiers must come from a cryptographic random source, a lesson published by Goldberg and Wagner in January 1996 when Netscape 1.1’s time-and-process-identifier seed yielded at most 47 bits.
  9. A server-side store keeps the truth on the server and makes revocation a delete; a signed stateless session removes the lookup and removes the ability to revoke.
  10. A leaked signing key in a stateless design permits forging any user’s session, which is what Yahoo disclosed for the forged cookies used against about 32 million accounts in 2015 and 2016.
  11. Session fixation, published by Mitja Kolsek of ACROS Security in December 2002, has the attacker fix an identifier in the victim’s browser and wait for the victim to authenticate it.
  12. The fix is to issue a new identifier and destroy the old record at every privilege change, and to refuse identifiers the server never issued.
  13. Hijacking has six routes: network observation, script in the page, device malware, leakage into logs and diagnostic captures, guessing, and physical access to the device.
  14. Each countermeasure covers one or two routes and none covers all, so a session design should be presented as a route-by-countermeasure table with the gaps visible.
  15. Device-bound sessions using a hardware-held key are the current answer to stolen cookies; Chrome’s Device Bound Session Credentials reached general availability on Windows on 28 May 2026.
  16. The idle timeout bounds the abandoned session and is reset by activity; the absolute timeout bounds the value of one authentication and is never reset; both are required.
  17. NIST SP 800-63B revision 4, published 1 August 2025, sets an inactivity timeout of no more than one hour and an overall timeout of no more than 24 hours at AAL2, and 15 minutes and 12 hours at AAL3.
  18. Correct logout destroys the server record first, then clears the cookie with the same attributes it was set with, and prevents caching of authenticated content.
  19. Logout usually forgets other device sessions, remembered-login cookies, refresh tokens, open sockets and federated sessions, and each of those must be revoked explicitly.
  20. Every user is a set of sessions, so keep a user-to-sessions index, show it as a device list, allow remote revocation, and state the revocation latency as a number you have measured.

Chapter sources: RFC 9110, “HTTP Semantics”, June 2022, STD 97, sections 3.3 on statelessness, 11 and 11.6.1 to 11.6.2 on the authentication framework, 15.5.2 on 401 and 17.9 on disclosure of sensitive information in URIs; RFC 4559, June 2006, cited within RFC 9110 as a non-standard extension violating statelessness; RFC 8471, 8472 and 8473 on Token Binding, October 2018; the OWASP Session Management Cheat Sheet for the 64-bit entropy floor, the 16-hexadecimal-character figure, the 128-bit generation recommendation, the 585-year brute-force calculation, the renewal-on-privilege-change rule, idle timeouts of 2 to 5 and 15 to 30 minutes, absolute timeouts of 4 to 8 hours and the Cache-Control: no-store requirement; OWASP Top Ten 2021 category A07, Identification and Authentication Failures, with CWE-384 and CWE-613; Mitja Kolsek, ACROS Security, “Session Fixation Vulnerability in Web-based Applications”, version 1.0 revision 1, December 2002, announced 18 December 2002 and revised February 2007; Ian Goldberg and David Wagner, “Randomness and the Netscape Browser”, Dr. Dobb’s Journal, January 1996; Eric Butler’s release of Firesheep at Toorcon 12 on 24 October 2010; NIST SP 800-63B-4, published 1 August 2025 as part of SP 800-63-4, sections 5 and 5.2 and the AAL requirements in sections 2.1.3, 2.2.3 and 2.3.3; PCI DSS requirement 8.2.8 and the PCI Security Standards Council FAQ on 15 minutes of idle time; Okta Security, “Unauthorized Access to Okta’s Support Case Management System: Root Cause and Remediation”, 3 November 2023; press reporting of Yahoo’s annual report disclosure on 2 March 2017 regarding forged cookies and approximately 32 million accounts; CVE-2019-5420, published 27 March 2019, CVSS v3.1 base score 9.8; Java Servlet 3.1 under JSR 340, final release 28 May 2013; Spring Security reference documentation on session fixation protection strategies and concurrent session control; the PHP manual’s session runtime configuration page, the PHP 8.4 deprecations RFC and the PHP 7.1 RFC “session id without hashing”; the Apache Tomcat 9 configuration reference for the SessionIdGenerator component; Django source and settings reference; Microsoft documentation for SessionIDManager.CreateSessionID; the express-session documentation; W3C Clear Site Data, Working Draft of 30 November 2017 with editor’s draft of 10 November 2023; Chrome for Developers posts on Device Bound Session Credentials origin trials and the Google Workspace Updates announcement of general availability on Windows, 28 May 2026; Charles Miller, “Persistent Login Cookie Best Practice”, 19 January 2004; and Roy Fielding’s 2000 doctoral dissertation naming statelessness as a REST constraint.