Backend Systems · Session and State
How a Session Identifier Binds Requests to Server State
What the opaque string in a session cookie actually denotes, how much entropy it needs, and why the identifier and the record it points at have separate lifetimes.
HTTP has no notion of a conversation. RFC 9110 describes each request as self-contained: the server is free to forget everything about the previous one, and most servers do. Applications that need continuity across requests therefore have to manufacture it, and the mechanism nearly all of them use is a name for the continuity. That name is the session identifier.
The identifier is widely described as “the session”, which is the source of most confusion about it. The string in the cookie is a lookup key, roughly 30 to 60 characters long, and it carries no facts at all. Everything the application knows about the visitor lives in a record the server holds somewhere else, indexed by that key. Once that split is clear, a large class of bugs becomes predictable: they are failures of the pointer, of the target, or of the agreement between them.
A pointer with no payload
A typical arrangement stores a row keyed by the identifier with fields for the authenticated user, the tenant or account scope, an expiry timestamp, and a few mechanism-specific values such as a CSRF token or the time of the last privilege check. The request handler reads the cookie, hashes the value, looks up the row, and attaches the result to the request context. The whole operation is one hash lookup, so cost is dominated by the round trip to whatever store holds the rows, not by the lookup itself.
The alternative is to put the facts into the cookie and sign them. That eliminates the lookup but changes the trust model completely, since the data now lives on a machine the server does not control. The trade is examined in Signed Cookie Tokens and the Limits of Statelessness. What matters here is that both designs need an identifier, and in the signed design the identifier is the signature’s subject rather than a database key.
Keeping the payload out of the identifier is not merely stylistic. A cookie’s size ceiling is enforced by browsers and specified as a minimum of 4096 bytes per cookie in RFC 6265, including the name, the value, and the attributes. A session record with a user identifier, a permission set, and a feature-flag snapshot can exceed that, and once a cookie crosses the ceiling browsers drop it entirely rather than truncate it. An identifier of 43 base64url characters leaves the entire budget for attributes.
Entropy is a security property, not a length
An identifier is a bearer credential: whoever presents it is treated as the holder of the session. Guessing it is therefore equivalent to stealing it. The relevant quantity is the number of uniformly random bits, not the character count. NIST SP 800-63B requires session secrets to carry at least 64 bits of entropy; in practice 128 bits is the common floor, which is 16 bytes from a cryptographic random source, encoded as 22 base64url characters or 32 hexadecimal ones. 256-bit identifiers are also common and cost nothing but a slightly longer header.
Generating those bytes requires getrandom-class sources, not a language’s general-purpose random number generator. The distinction is not academic: a predictable seed turns an unguessable identifier into a counter, and a counter can be walked in a few requests.
Collision is the other side of randomness. With a 128-bit space and $n$ live sessions, the probability that two identifiers coincide is roughly $n^2 / 2^{129}$ by the birthday bound, which stays negligible until $n$ approaches $2^{64}$. Shorter identifiers shift that curve sharply: at 32 bits, a few tens of thousands of simultaneous sessions produce a meaningful chance of a collision, and a collision means two visitors share one record.
Identifiers that encode structure are a separate hazard. Sequential database keys are enumerable by inspection. Time-based identifiers such as UUID version 1 expose the generating host and a timestamp, which leaks both deployment topology and session start times. UUID version 4 is acceptable because 122 of its 128 bits are random, though its hyphenated hexadecimal form is longer than a base64url encoding of the same entropy.
Storing the hash rather than the token
If the session store is dumped, every identifier in it is a working credential. Hashing the identifier before it becomes a key removes that: the server keeps SHA-256(token) and compares hashes on lookup, so the plaintext token exists only in the client’s cookie and in the request that carries it. A single SHA-256 pass over a 256-bit random input is not the weak link, because the input has no structure to exploit; password-hashing cost factors exist to compensate for low-entropy human input and buy nothing here.
This design has one operational consequence worth planning for: the plaintext token cannot be recovered from the store, so any debugging or support workflow that expects to read a session value has to change. Correlating a request to a stored session requires hashing the presented token first.
Where the identifier travels, and what that costs
Three transports are possible and they are not equivalent. A cookie is sent automatically by the browser to the matching origin, which is convenient and also the reason cross-site request forgery exists. An Authorization header carries the same value but only when client code sets it deliberately, which makes forgery harder and requires the client to persist the value somewhere the page’s own scripts can reach. A URL path segment or query parameter is the worst option: the value leaks through Referer headers, browser history, proxy logs, and any bookmark or shared link. RFC 6265 fixes the cookie mechanism enough that its scoping rules are worth knowing in detail; Cookie Scope, Attributes, and Transport Rules covers them.
Cookie prefixes tighten the transport contract. A cookie named with the __Host- prefix is only accepted by browsers when it is set with Secure, with Path=/, and without a Domain attribute, which makes it impossible for a sibling subdomain to overwrite it. The __Secure- prefix requires only the Secure attribute. Both are cheap defenses against a subdomain that has been compromised or is simply running older code that writes a colliding cookie name.
Two lifetimes that drift apart
The identifier and the record expire on independent schedules, and their disagreement produces the two classic symptoms. If the cookie outlives the record, the client presents a key that resolves to nothing; the correct behavior is to treat that as an unauthenticated request and issue a fresh identifier, not to error. If the record outlives the cookie, storage accumulates rows nobody can reach, and an attacker holding a copied token retains access after the browser has discarded it.
Idle timeouts and absolute timeouts answer different questions. An idle timeout bounds how long an abandoned session survives; an absolute timeout bounds how long a stolen token remains useful no matter how active it is. Both are usually wanted, and the record should carry both an absolute expiry and a last-seen timestamp so the two policies can be evaluated independently. The cookie’s Max-Age is a hint to the client and nothing more; clients can and do retain expired cookies, and a hostile client will send any value it likes.
Sliding expiration is where the cost appears. Refreshing the expiry on every request turns each read into a write. At a few thousand requests per second, that is a few thousand extra writes per second spent entirely on bookkeeping. Extending the deadline only when the remaining lifetime drops below half cuts the write rate by orders of magnitude while changing the observable idle window by at most one refresh interval. The pattern generalizes to any store with a write cost: Key Design and Eviction in External Session Stores works through the key layout and expiry mechanics.
When the pointer changes occupant
Because the identifier alone determines identity, anything that lets one party choose the identifier for another is a vulnerability. Session fixation is the canonical form: a value planted before authentication is still valid after it, so the planter inherits the authenticated session. The defense is to discard the pre-authentication identifier at the moment authentication succeeds and issue a new one, which means two records exist briefly and only the second is bound to a user.
Rotation on privilege change is the same operation applied elsewhere: elevation to an administrative role, a change of tenant, or a password change all warrant a new key, because all of them change what the old key is worth. Session Fixation, Rotation, and Revocation treats the mechanics and the failure modes in full.
What the identifier does not say
A valid lookup proves only that a record exists. It says nothing about whether the authorization decision recorded in that record is still correct, whether the account was disabled in an upstream directory five minutes ago, or whether the request arrived from the same network as the one that created the session. Caching a permission set in the record is a legitimate performance choice, but it converts authorization freshness into an explicit staleness budget that has to be stated and enforced. A record that is trusted indefinitely is not a session store; it is a copy of the authorization database with weaker controls.
The identifier also says nothing about which server can serve it. That question is settled by where the record lives, and it is the pivot on which every discussion of affinity, replication, and shared stores turns.