A session is one level of indirection: instead of proving who you are on every request, you present a meaningless string that the server can look up.
The previous lesson ended with a password verified exactly once. This lesson turns that single proof into a conversation. If you have arrived here directly, the two things carried over are that a bearer secret needs 128 bits of entropy from crypto.getRandomValues, and that anything the browser holds is something an attacker may eventually hold too.
The indirection, and why it is the whole idea
After a successful password check the server creates a row and hands the browser a random string that names it:
await db.createSession({
id: await sha256(token), // never the token itself, see below
userId: 4192,
createdAt: "2026-03-04T09:12:44Z",
lastSeenAt: "2026-03-04T09:12:44Z",
userAgent: request.headers.get("user-agent"),
ip: clientIp(request)
});The string the browser receives carries none of that. It is not the user's identity encoded, not their email, not a number that could be incremented. It is a bearer token: whoever presents it gets treated as the person it belongs to, with no further questions, which is exactly why it must be unguessable and exactly why it must be revocable.
Two properties follow from putting the state on the server, and they are the reason this design remains the right default for browser login.
Revocation is instant. Delete the row and the next request from that browser finds nothing, so the user is signed out. There is no window, no waiting for an expiry, no cache to invalidate. This sounds unremarkable until you meet the alternative in the next lesson, which cannot do it at all.
The token stays small and meaningless. Twenty-two characters go in a cookie regardless of how much you know about the user. Roles, permissions, display name and preferences live in the database, are read fresh on each request, and change the instant an administrator changes them.
The cost is a lookup per request. That is a single indexed read on a primary key, which for any store worth using is a fraction of a millisecond, and it is the cheapest part of a typical request. Arguments against session storage on performance grounds almost always compare against a workload nobody has measured.
Minting the identifier
function newSessionToken() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return bytes.toBase64({ alphabet: "base64url" });
}Thirty-two bytes rather than the minimum sixteen, because the cost of the extra 21 characters is nothing and the margin is free. Even at sixteen the arithmetic is decisive: an attacker firing a million requests per second at your server, which no service would tolerate, needs seconds to exhaust the space, or years.
The other question people ask is collisions. If two users are issued the same identifier, one of them is signed in as the other. With identifiers of bits, the expected number of collisions is about . For a billion sessions at 128 bits that is , which is not going to happen. At 64 bits the same billion sessions give , a 3 percent chance, which is a bug that will eventually bite and be impossible to reproduce. Fifty percent probability arrives at identifiers, which is at 64 bits and at 128. The uniqueness constraint in the database is still worth having, but it is a tripwire rather than a mechanism.
Note what is missing from the generator: any input at all. No user ID, no timestamp, no counter, no hostname. Anything you mix in that an attacker can predict reduces the effective entropy, and the classic failure is a "session ID" built from a UUIDv1, which encodes a timestamp and a MAC address and has perhaps a few dozen unpredictable bits. crypto.randomUUID is a v4 UUID with 122 random bits and is acceptable; uuid.v1() is not.
Example. A service has 400,000 concurrent sessions and issues identifiers of 8 random bytes. What is the chance an attacker guesses a live session in a single request, and how many requests to reach even odds?
Eight bytes is 64 bits, so the space is . With live sessions, one random guess hits with probability . Even odds need about requests, so guessing is not the problem here. The collision arithmetic is: at 64 bits, birthday behaviour puts a first duplicate near issued identifiers, and a busy service issuing ten per second reaches that in sixteen years. Thin, but survivable. It is the next factor of two down that kills you, and there is no reason to be anywhere near the edge.
Now you. Someone proposes session identifiers of the form base64(userId + ":" + Date.now() + ":" + random4bytes). How much entropy does it actually have, and what else is wrong?
Answer
Four random bytes is 32 bits, about 4.3 billion, and that is the whole of it: the user ID is known or guessable and the timestamp is known to within a second or two by anyone who can observe when a login happened. At a thousand guesses per second an attacker targeting one known login window walks the remaining space in weeks, and in parallel across many accounts far faster. The second problem is that the identifier leaks the user ID and the login time to anyone who base64-decodes it, which is trivially done. Generate the whole thing at random and store the metadata in the row.
Store a hash, not the token
The sessions table is a database table, and databases leak. A backup goes to the wrong bucket, a read-only injection dumps a table, a replica is exposed. If that table holds raw session tokens, every stolen row is an account the attacker walks into immediately, with no password and no guessing.
So store SHA-256(token) and index that. The browser sends the token, the server hashes it and looks up the digest.
async function sha256(text) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return new Uint8Array(digest).toBase64({ alphabet: "base64url" });
}
const row = await db.session(await sha256(cookieValue));The objection from the passwords lesson does not apply here. A password is a low-entropy value chosen by a person, so a fast hash of it can be guessed; a session token is 256 bits from a cryptographic generator, and no amount of hardware inverts a SHA-256 digest of an input drawn uniformly from that space. Fast is exactly right, because this runs on every request.
There is a second benefit that is easy to miss. Because the lookup is an indexed equality on a digest rather than a comparison of secrets in application code, the timing-leak problem from the first lesson disappears: an attacker who somehow learns the stored digest still cannot produce the token that yields it.
Lifecycle: expiry, rotation, revocation
A session is not a fact but a lease, and three separate clocks govern it.
Idle expiry ends a session after a period with no activity, refreshed on each request. It protects the abandoned browser on a shared machine. Thirty minutes is a reasonable default for an ordinary application, a few minutes for a banking session, and updating last_seen_at on every request is a write per request, so most systems only write when the stored value is more than a minute old.
Absolute expiry ends it a fixed time after creation regardless of activity, and it is the one people leave out. Without it a stolen token used regularly stays valid forever, and an attacker who steals a session keeps it by simply making a request every twenty minutes. Twelve hours suits an application people use during a working day; a "remember me" session runs to thirty days and should carry fewer privileges than a fresh one.
Rotation issues a new token for the same logical session and invalidates the old one. It must happen at authentication, and this is not optional. If the server hands out a session identifier to an anonymous visitor and keeps the same identifier after they sign in, an attacker who planted a known identifier in the victim's browser beforehand now holds an authenticated session. That is session fixation, and rotating the token at the moment privilege changes is the entire fix. Rotate again on a password change, on adding or removing a second factor, and on any step up to a more sensitive area.
Revocation is deleting rows, and it comes in two shapes that both need to exist. Signing out deletes the current row. "Sign out everywhere" deletes every row for that user, and it is what a person reaches for when they suspect compromise, so it must be reachable in the interface and must be triggered automatically on a password change. A password reset that leaves the attacker's session alive has accomplished nothing.
Example. A shop gives every anonymous visitor a session so it can hold a basket, and keeps the same identifier when they sign in. Walk through the attack this enables.
The attacker visits the shop, is issued session abc, and does not sign in. They then get the victim's browser to adopt abc, through a link carrying it if the application accepts a session identifier in the URL, or by setting the cookie from a subdomain they control. The victim, now holding abc, signs in normally. Because the identifier is unchanged, the server attaches the victim's user ID to session abc, which the attacker has known all along and can simply present. The victim did nothing wrong and the attacker never guessed anything. Rotating at login closes it: the moment the password is verified, the server issues a new token and deletes the old row, so abc names nothing.
Now you. A session has a 30-minute idle expiry and no absolute expiry. An attacker steals a live token. How long do they keep the account, and what is the smallest change that bounds it?
Answer
Indefinitely. Idle expiry is refreshed by activity, so a script making one request every twenty minutes keeps the session alive forever, and the victim sees nothing because their own session is a separate row. The smallest change that bounds it is an absolute expiry, twelve hours say, checked against created_at rather than last_seen_at, which caps the theft at the remaining life of that session regardless of use. Worth adding alongside: showing the user their active sessions, so a device they do not recognise is visible, and ending every session on a password change.
Because the sessions table holds user_agent, ip and last_seen_at, all of this can be shown to the user as a list of active devices, and that list is a genuinely useful security feature rather than decoration: it is how a person discovers a session they do not recognise.
The cookie that carries it
The token has to travel, and the cookie is the transport. Six attributes decide whether it is safe, and there is exactly one correct combination for a session cookie.
Set-Cookie: __Host-session=Kj8f...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=43200HttpOnly hides the cookie from document.cookie, so script injected into your page cannot read it. This is the single most valuable attribute, because it is the line between an XSS bug that defaces a page and one that hands over every visitor's account.
Secure stops the cookie being sent over plain HTTP, which matters even on an HTTPS-only site: an attacker on the network can force a request to http://yoursite.com and, without this flag, watch the cookie go past in the clear.
SameSite controls whether the cookie is attached to requests originating from other sites, and it is the main defence against CSRF, which the fifth lesson covers properly. Strict sends the cookie only for requests originating from your own site, which means a user following a link from an email arrives signed out. Lax sends it on top-level navigations that use a safe method, so following a link works while a cross-site form POST does not, and it has been the browser default since Chrome 80 in February 2020. None sends it always and requires Secure. Lax for a session cookie, and know that the default exists so that omitting the attribute is not the disaster it once was.
Path limits which paths receive the cookie. It is not a security boundary, since any page on the origin can read another path's cookies through script, but Path=/ is what a session wants anyway.
Domain is the dangerous one, and the next section is about it.
Max-Age or Expires sets when the browser discards it. Omit both and it is a session cookie in the browser's sense, discarded when the browser closes, which sounds tidy and is unreliable because browsers restore sessions on restart. Set it explicitly to match your absolute expiry, and remember that this is a hint to the browser and not a control: the server's own expiry check is the one that counts.
Example. What is wrong with this header, and what should it be?
Set-Cookie: session=Kj8f2p...; Domain=.example.com; Path=/; Max-Age=31536000Four faults. No HttpOnly, so any injected script reads the session token and the account is gone. No Secure, so a forced plain-HTTP request leaks it on the network. No SameSite, which modern browsers treat as Lax but older ones do not, so the behaviour depends on the visitor's browser. And Domain=.example.com sends the token to every subdomain, including blog.example.com and anything a contractor runs. The Max-Age of a year with no absolute expiry on the server is a fifth. It should read Set-Cookie: __Host-session=Kj8f2p...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=43200.
Now you. A team sets SameSite=Strict on their session cookie and users complain that clicking a link in the confirmation email lands them on a signed-out page, even though they are signed in. Explain, and give a fix that keeps the protection.
Answer
Strict withholds the cookie on every cross-site request including a plain top-level navigation, so arriving from a mail client the first request carries no cookie and the server renders the signed-out page. Reloading works, because that request is now same-site. The usual fix is SameSite=Lax, which sends the cookie on top-level safe-method navigations and still withholds it from cross-site form posts and subresource requests. If you want Strict for the main session, the pattern is two cookies: a Strict one carrying the session and a Lax one carrying nothing but a marker, so a request with the marker and no session triggers a same-site redirect that then arrives with both.
Subdomains, and the __Host- prefix
Cookies do not obey the same-origin policy. They were designed before it, and their scope is the domain, which creates a hole that catches teams repeatedly.
A cookie set with Domain=example.com is sent to example.com and every subdomain. Worse in the other direction, a page on blog.example.com can set a cookie for Domain=example.com, and that cookie is then sent to your application. So a marketing blog on a shared subdomain, a status page from a third party, a stale test.example.com pointing at an abandoned server: any of these can both read your session cookie, if you scoped it to the parent domain, and write cookies your application will receive. The second is cookie tossing, and it lets a subdomain overwrite a CSRF token or a session with one the attacker chose.
The __Host- prefix closes this. A cookie whose name begins __Host- is accepted by the browser only if it carries Secure, has Path=/, and has no Domain attribute, which pins it to exactly the host that set it. No subdomain can read it and no subdomain can overwrite it, and because the rule is enforced by the browser at the point of setting, a misconfiguration fails visibly rather than silently widening the scope.
There is a related prefix, __Secure-, which only requires the Secure flag and is much weaker. Use __Host- for the session cookie unless you genuinely need it shared across subdomains, in which case understand that you have accepted every subdomain as part of your trusted computing base.
Why not localStorage
The advice to store a token in localStorage and send it in an Authorization header appears constantly, usually justified by "cookies are vulnerable to CSRF". It is a bad trade, and the reason is worth spelling out because the argument sounds reasonable.
It is true that a value in localStorage is not sent automatically, so it is not spendable by a cross-site request. But localStorage is readable by any JavaScript running on the origin, by construction, and there is no attribute you can set to change that. HttpOnly has no equivalent. So you have traded a risk with a specific, well-understood, cheap defence for one with no defence at all.
Line the two up. Under CSRF with a cookie, the attacker causes a request to be sent with the victim's credentials but cannot read the response, is limited to the shapes a cross-site request can take, and is stopped by SameSite=Lax plus an origin check. Under XSS with localStorage, the attacker reads the token, exfiltrates it, and uses it from their own machine at leisure, with no origin restriction, no expiry they cannot outlast, and nothing in your logs to distinguish them.
Between an attack that requires the victim to be tricked into visiting a page and is blocked by a header, and one that hands over the credential permanently with no defence, the cookie is not close. Use HttpOnly cookies for browser sessions. The Authorization header belongs to clients that are not browsers, where there is no ambient credential and no cross-site request problem to have.
What this buys, and what it costs
The design in this lesson has one clear virtue: the server decides. Every request consults state the server owns, so a session can be ended, downgraded, listed or audited at the moment somebody asks, and none of it depends on the browser cooperating.
The cost is that the server must hold that state, which means a store every application server can reach, which means Redis or a database table and a story about what happens when it is unavailable. For most applications that store already exists and the objection is theoretical. For a system spread across regions, or built from services that would rather not share a session store, the lookup starts to look like a real constraint.
That constraint is what motivates the alternative: put the state in the token itself, sign it so it cannot be forged, and let any server verify it with no lookup at all. It is a genuine engineering trade with a genuine cost, and the cost is precisely the property this lesson called instant. The next lesson is about what you give up.