Skip to content
Chapter 51Lesson 2

Sessions vs JWTs and the auth cookie

Why a server-stored session beats a JWT for a browser app, and how a hardened cookie carries the proof from one request to the next.

Last lesson you proved who: the user typed an email and password, the server checked them, and from that one click it knows it’s talking to a verified person.

But HTTP has no memory. The next request, loading the dashboard or deleting an invoice, arrives as a blank stranger, carrying no trace of the proof the server accepted half a second ago. Re-typing a password on every click would be absurd, so something must travel with each request and carry that proven identity forward.

That something takes one of two shapes: a small server-stored session ID pointing back to a record on the server, or a self-contained signed token, a JWT, that packs the identity into the request itself. One question decides between them every time: can you take the proof away? The cookie that carries the model across the wire is the last piece, and every value in next chapter’s session config will already mean something.

A session is a record on the server: this ID maps to user X, created at 09:14, last seen two minutes ago, expires in 30 days, on this browser. That record is the truth. The browser holds one thing, the ID, a random, meaningless-looking string. Every request presents the ID, the server looks it up, and the request runs as whatever user the record points to. No record, anonymous stranger again.

A coat check makes this concrete. You hand over your coat and get a numbered ticket. The ticket isn’t the coat: you can’t wear it, and the number tells no one which coat it’s for. The coat hangs on a numbered hook on the server side, and you present the ticket to claim it. What matters most is that the staff can pull your coat off the hook whenever they want. The moment they do, your ticket points to nothing; wave it around and it’s just cardboard. Destroying the record server-side, so the ticket goes dead instantly, is why this lesson lands on sessions.

Browser
__Host-session = a8f3c1d9…

an opaque handle — no readable meaning

Server
{ id: 'a8f3c1d9…', userId: 'usr_7Q…', expiresAt, lastActiveAt, … }

the source of truth

The cookie carries only a handle. The server owns the record it points to — and can delete it at will.

The ID has to be opaque and unguessable, because anyone who guesses a valid ID is that user as far as the server can tell. So it comes from a CSPRNG , not Math.random(). Just how unguessable comes later in this lesson.

There are two ways to answer “who is this request from?” on every request.

The default for browser web apps, and the handle you just met. The cookie carries a random, unguessable token: crypto.randomUUID() packs 122 bits of randomness, or 32 raw bytes from crypto.getRandomValues() if you want more. That token is the primary key of a row in a session table. On every protected request the server does one indexed lookup: find the row whose token matches, read off the user.

Sketch — the shape of the lookup, not real API
const cookieToken = 'a8f3c1d9b2e7…'; // the opaque handle from the cookie
findSession(cookieToken); // -> { userId, expiresAt } | null

That lookup is the whole per-request cost, and it’s small: an indexed primary-key read on a warm connection pool runs in roughly one to three milliseconds. In exchange you get three properties that matter:

  • Instant revocation. Delete the row and the token is dead on the very next request.
  • Arbitrary metadata. The row can carry anything next to the user ID: last-seen time, device, IP, the active organization. It’s just columns.
  • Stateful by definition. The server holds state per session. That looks like a cost, but it’s the feature you’re paying for.

The other shape moves the data into the request. A JWT (pronounced “jot”) is a small JSON payload, { sub, iat, exp, … }, that the server signs with a secret it holds. sub is a claim : the subject, the user this token represents. iat and exp are the issued-at and expiry times. The browser holds the whole signed thing in the cookie.

On each request the server looks nothing up. It recomputes the signature with its secret, checks it matches, and checks that exp hasn’t passed. If both hold, the token is genuine and current, and the server trusts its claims with no database read. That’s the appeal: validation is pure CPU, no I/O. And because any service holding the same secret can verify the token, one token can authenticate a request against a completely separate API.

header algorithm { alg: 'HS256' }
payload the claims { sub, email, role, exp }
signature proof it wasn't changed

The payload is just base64url. Anyone holding the cookie can decode it and read every claim. The signature only proves the claims weren't tampered with — it does not hide them.

A JWT is signed, not encrypted. Tamper-evident is not the same as secret.

So the rule follows directly: never put a secret in a JWT claim. The signature proves the claims weren’t changed after signing; it doesn’t hide them. Signed is not encrypted .

A JWT has no instant revocation. Once handed out, it’s valid until exp arrives: the server has nothing to delete, because the token isn’t stored anywhere on the server. You can bolt on a denylist of revoked tokens and check it every request, but that re-introduces the per-request database read you adopted JWTs to escape, throwing away the statelessness that was the whole point.

A JWT earns its place in two narrow situations, where you accept that revocation is eventual rather than instant. The first is edge-rendered routes where validating against a database means a slow round-trip to a distant region, and the latency is a measured problem. The second is service-to-service calls, where one backend hands a token to another with no shared session store between them. In both, you keep access tokens short (5 to 15 minutes) and rotate them with refresh tokens so a stolen one expires fast.

Opaque sessionJWT
What’s in the cookieA random handle (lookup key)The signed claims themselves
Per-request server workOne indexed DB lookup (~1–3 ms)Verify signature + exp, no I/O
RevocationInstant: delete the rowNone until exp (or a denylist that undoes statelessness)
Metadata (last-seen, device, org)Lives on the row, free to addBloats the cookie; still can’t be revoked
Payload visibilityNothing readable in the cookieAnyone with the cookie reads every claim
Cross-service portabilityNeeds a shared session storeAny service with the key can verify
StatefulnessStateful (a row per session)Stateless (until you add a denylist)
2026 default for browser SaaS?YesNo, the special-case reach

The table looks balanced, but the revocation row decides, and it decides one way for a browser web app. “Sign me out everywhere,” “this account is compromised, kill it now,” “this stolen cookie has to stop working this second” — each is routine, and each is a single DELETE against the session table. A pure JWT can’t do any of them until the token expires on its own, which can be minutes or hours of an attacker holding a session you can’t touch. Add the denylist to win revocation back and you’re running two systems with the per-request DB read you left to avoid.

You’ll also hear about the hybrid: a short-lived access JWT (5 to 15 minutes) for fast reads, paired with a server-side refresh token you can revoke. It’s a real, production-grade pattern, but the refresh-token rotation is operational overhead that doesn’t pay for itself until the database round-trip is a measured bottleneck. Start with sessions; move to the hybrid only when the numbers tell you to.

Sort each scenario into the session shape an experienced engineer would reach for. Notice how lopsided the result is — that imbalance is the point. Drag each item into the bucket it belongs to, then press Check.

Opaque session The default for browser SaaS
JWT The special-case reach
A user clicks “Sign out everywhere”
Edge middleware reads identity with no database nearby
Support needs to instantly kill a compromised account
A separate analytics service must trust the same token
Settings → show my active devices with revoke buttons
The browser session for a typical Next.js SaaS

The handle is an opaque string pointing at a server row, and it rides between browser and server on every request. The cookie’s configuration is what keeps it from being stolen, read by JavaScript, or sent where it shouldn’t go. You’ve met cookies and Set-Cookie; what’s new is the configuration an auth cookie wears, where each attribute closes a specific attack.

__Host-session the browser enforces tight scope — refuses the cookie otherwise a8f3c1d9… the opaque handle HttpOnly JavaScript can't read it Secure HTTPS only SameSite=Lax withheld on cross-site POSTs Path=/ sent on every same-origin request Max-Age=2592000 lifetime — 30 days, renewed on use
The auth cookie, attribute by attribute. Each one closes a specific attack.

The __Host- prefix. The browser refuses to store a __Host--prefixed cookie unless Secure and Path=/ are set and no Domain= is present. A too-loose scope can’t slip through, because the misconfigured cookie is never stored in the first place. Tight scoping stops being something the developer must remember and becomes a platform guarantee.

HttpOnly. document.cookie can’t read an HttpOnly cookie, so JavaScript never sees the token. This is the defense against XSS: even a script an attacker runs on your page can’t read the session token and ship it off. It’s exactly the protection localStorage lacks, where anything is plain JavaScript-readable, which is why session tokens never live there.

Secure. The cookie is sent only over HTTPS, so it can’t leak over a plaintext connection. The __Host- prefix already requires this, but local and preview environments sometimes relax it to work over http://localhost, while production never does.

SameSite=Lax. With SameSite =Lax, the browser sends the cookie when the user clicks a link to your site but withholds it when another site POSTs to yours in the background. That withholding is the default defense against CSRF : a forged cross-site POST arrives without the cookie and is treated as anonymous. Strict withholds the cookie even on a top-level navigation, so a sign-in link from email would land the user logged-out. None sends it on every cross-site request, reopening CSRF; it exists for third-party embeds this app doesn’t have. Lax is the considered middle.

Path=/. Also required by the __Host- prefix, this attaches the cookie to every same-origin request, so the session is visible to your whole app.

Expiration. Max-Age sets how long the cookie lives. The session uses a sliding lifetime: good for a generous window, with the clock resetting each time the user is active. Whether it survives a browser restart (persistent) or dies with the tab (session-only) is a UX decision, and a “remember me” checkbox is just a toggle on Max-Age.

The defaults this stack uses, which you’ll see as real config in the next chapter:

  • Name: __Host- prefix.
  • Flags: HttpOnly; Secure; SameSite=Lax; Path=/.
  • Lifetime: 30 days, with sliding renewal roughly once a day of activity.
  • Secure is relaxed only in dev and preview; production never relaxes it.

One more clock is worth naming: a much shorter freshness window of about 10 minutes that high-stakes actions check first. Changing a password or deleting an account shouldn’t run on a session that signed in three weeks ago.

Token entropy and constant-time comparison

Section titled “Token entropy and constant-time comparison”

The token has two safety properties worth knowing. A good library handles both, so the point is to recognize them and notice their absence in hand-rolled auth.

Entropy. The token is a secret, so it has to be unguessable to an attacker firing requests at your server: at least 128 bits of randomness from a CSPRNG. crypto.randomUUID() gives 122 bits, which is fine; 32 bytes from crypto.getRandomValues() gives far more. What fails the bar is a sequential ID (guess one, guess the next) or anything from Math.random(), which is predictable and never for secrets.

Constant-time comparison. Comparing the raw bytes with === short-circuits at the first mismatched byte, so a wrong guess returns almost immediately while one correct for ten characters takes measurably longer. An attacker who times the responses can walk the secret character by character: a timing attack . The fix is a constant-time compare , which examines every byte and so takes the same time wherever the inputs differ.

Because the session is a row, you can hang useful columns off it, and each earns its place by powering a concrete feature. The minimum is (id, userId, expiresAt); the rest pull their weight by what they unlock.

Session-row column What it powers
minimum (id, userId, expiresAt) — always present; everything below is leverage
  • lastActiveAt “Last seen 3 hours ago” UI · idle-timeout policies
  • userAgent ipAddress The active-sessions list · “new device signed in” alerts
  • activeOrganizationId Which org’s data to show (multi-tenancy)
  • impersonatedBy Admin support — “acting as this user”
Every non-obvious column on the session row exists because a downstream feature needs it.

lastActiveAt is both a display value and the field an idle-timeout policy checks. userAgent and ipAddress make an active-sessions list readable (“Chrome on macOS, signed in from Madrid”) and give anomaly detection something to flag. activeOrganizationId remembers which organization’s data to show when a user belongs to several. impersonatedBy records who is really behind the session when a support engineer acts as a user.

This is leverage only the opaque-session shape has cheaply. Carry the same metadata in a JWT and you bloat the cookie on every request, and still can’t revoke it.

The session lifecycle: issue, refresh, revoke, expire

Section titled “The session lifecycle: issue, refresh, revoke, expire”

A session is created, kept alive, and torn down; if nothing tears it down, it eventually lapses.

  • Issue. On successful authentication, the server inserts a new session row and sends Set-Cookie with the opaque handle.
  • Refresh. On activity, the server bumps lastActiveAt and, on a cadence, rotates the token to a fresh value as defense-in-depth. Cadence is the key word: Better Auth rotates roughly once a day, not per request. Per-request rotation sounds safer but breaks concurrent tabs, since two requests in flight race to rotate the same token and one is left holding a stale value.
  • Revoke. On sign-out, a password change, or an admin killing the account, the server deletes the row. The next request carrying the now-orphaned cookie finds no row, fails authentication cleanly, and is bounced to sign-in.
  • Expire. Left alone, a session lapses at expiresAt. The server filters expired rows out at lookup time with WHERE expires_at > now(), and a periodic sweep deletes the dead rows.

Revocation is the asymmetry from the start of the lesson paying off: the server deletes the row, and the next request arrives with a byte-for-byte unchanged cookie that’s already dead. The cookie didn’t change; the truth on the server did.

1 Issue
Browser
POST /sign-in (email + password)
Set-Cookie: __Host-session=a8f3c1d9…
Server
+ new row { id: 'a8f3c1d9…', userId: 'usr_7Q…',
lastActiveAt: 09:14 , expiresAt: }
verified → INSERT a new session row
Sign-in succeeds. The server creates the session row and sets the cookie.
2 Authenticated request
Browser
GET /dashboard Cookie: __Host-session=a8f3c1d9…
Server
{ id: 'a8f3c1d9…', userId: 'usr_7Q…',
lastActiveAt: 09:14 , expiresAt: }
indexed lookup → row found → runs as user X
Every later request carries the cookie. The server looks it up and runs as that user.
3 Refresh
Browser
GET /invoices Cookie: __Host-session=a8f3c1d9…
Server
{ id: 'a8f3c1d9…', userId: 'usr_7Q…',
lastActiveAt: 09:32 ↑ , expiresAt: }
lookup found → bump lastActiveAt · rotate on a cadence, not per request
On activity the server updates last-seen, and rotates the token on a cadence — not every request.
4 Revoke
Browser
POST /sign-out-everywhere (or an admin kills the account)
Server
row deleted { id: 'a8f3c1d9…', userId: 'usr_7Q…',
lastActiveAt: 09:14 , expiresAt: }
DELETE the row — one statement
Sign-out, a compromised account, a stolen cookie — one DELETE removes the row.
5 Stale cookie
Browser
GET /dashboard Cookie: __Host-session=a8f3c1d9…
Server
no matching row
lookup → no row → 401, redirect to /sign-in
The cookie is unchanged — but it now points to nothing. The next request fails and is bounced to sign-in.

Two refinements, both handled by the library; you only need to recognize them.

Session fixation. Suppose an attacker plants a session ID they know into the victim’s browser before sign-in, and the server then reuses that same ID for the authenticated session. The attacker’s pre-planted ID is now a fully authenticated session. The defense is one rule: regenerate the token at sign-in. Mint a fresh token the moment authentication succeeds, so whatever the attacker planted is worthless. Better Auth does this; in hand-rolled auth, its absence is the bug.

Multi-device. Because each session is its own row, signing in on your laptop and your phone produces two independent rows for the same user. Everything you’d want falls out for free: “Settings → Security” lists devices by reading WHERE userId = ? ORDER BY lastActiveAt DESC with a revoke button on each, and “sign out everywhere” is DELETE WHERE userId = ?. The statefulness you might have read as a cost is exactly what makes each a one-line query.

A user’s laptop is stolen with a live session on it. They want every session of theirs dead right now. With opaque sessions that’s one step; with a pure JWT it isn’t. What’s the actual reason for the difference?

The server keeps the proof, so erasing it leaves the cookie pointing at nothing the next time it’s checked — but a JWT is its own proof, carried in the request, so the server has nothing to take away before the token’s clock runs out.
A JWT can be killed just as fast — the server looks it up in the token table it was saved to at sign-in and removes that entry.
Swapping out the JWT signing secret would void this user’s token on the spot.
Neither design has an edge here; opaque sessions only revoke a little quicker.

The same model recurs everywhere ahead: same cookie, same lookup, same User | null at the end, with only the location and use differing.

  • The proxy (Next.js 16’s proxy.ts, formerly middleware.ts) runs before a page renders and does a cheap cookie-presence check to bounce signed-out visitors to /sign-in. It gates on presence, not validation, and a short caching window can let it read a session that was already revoked, so it’s a fast first filter, never where real security decisions live.
  • Layouts and Server Components read the session to drive identity-dependent UI: your name in the corner, the right nav for a signed-in user.
  • Server Actions and route handlers read it on every mutating call. This is the action boundary, where identity and permissions get checked for real, because the cookie is only a tamper-evident pointer to a row the server owns and can erase at will.