Skip to content
Chapter 52Lesson 3

Session lifetimes and cookie hardening

Configure how long Better Auth sessions live and lock down the cookie that carries them.

A user can already sign in: the rows are in Postgres and the route is mounted. What you haven’t set is how that session behaves. How long does a login last before it forces a re-auth? When does its token rotate? Should “delete my account” trust a session created three weeks ago? Does every page load hit the database just to learn who’s asking?

These are decisions, and Better Auth exposes them as two option blocks on the auth instance: session and advanced. The session block sets the clocks a session runs on; the advanced block locks down the cookie that carries it. It also adds one export, SESSION_COOKIE_PREFIX, that the next lesson’s proxy reaches for.

It’s tempting to picture a session as one timeout that runs out and logs you out. That model can’t explain why a user clicking around for a month stays logged in, or why someone who just hit “change password” is re-prompted even though they signed in twenty minutes ago.

There isn’t one clock. There are three, running independently on the same session row.

The first is expiresIn, the absolute lifetime. Past this hard wall the session is dead no matter how active the user was, and they must re-authenticate. Better Auth stores the wall as session.expiresAt and defaults to 7 days; the course sets 30 days, long enough that a returning user rarely hits it, short enough that no session lives forever.

The second is updateAge, the sliding renewal. A 30-day wall alone would log out even a daily user after a month. While the session is inside its expiresIn window, any request that finds it older than updateAge pushes expiresAt out by another full window, so the wall slides into the future and an active user stays logged in indefinitely without any cookie that lives forever. The trade-off: shorter, and every active session triggers more UPDATEs on a hot table, which is write amplification ; longer, and a user active in bursts can drift toward the absolute wall and fall off it mid-task.

The third is freshAge, the freshness window for elevation. A session is “fresh” if its createdAt is within freshAge, measured from when the session was created, not from last activity, so a user active all afternoon still has a session that is not fresh if they signed in this morning. Inside the window, high-stakes actions such as changing a password or email, disabling two-factor, or deleting the account proceed; outside it, the action layer demands re-authentication first. Better Auth defaults to 1 day; the course tightens it to 10 minutes, so destructive actions re-prompt unless the user just signed in. This lesson only sets the number; the check that enforces it comes later, in the chapter on account security.

freshAge — 10 min high-stakes actions proceed without re-auth
updateAge — 1 day an active request past this tick slides the wall right →
sign-in (createdAt) the wall (expiresAt)

One time axis from sign-in to a 30-day expiresIn wall. A 10-minute freshAge band sits at sign-in for high-stakes actions; a 1-day updateAge tick partway in slides the wall further right whenever an active request arrives.

Three clocks on one session row: a 10-minute `freshAge` sliver at sign-in for sensitive actions, a 30-day `expiresIn` wall, and a 1-day `updateAge` tick that slides the wall right while you stay active. Note how small freshness is against the session's whole life.

Each answers a different question, which is why the bands are different sizes. expiresIn asks “is this session still valid at all?”, updateAge asks “should I extend it because the user is here?”, and freshAge asks “do I trust it enough to allow something destructive?”

KnobWhat it controlsLibrary defaultCourse defaultShorten when…Lengthen when…
expiresInAbsolute lifetime: the hard wall, regardless of activity7 days30 daysThe data is sensitive enough that a stale logged-in tab is a real riskFriction of frequent re-login outweighs the rotation benefit for a low-risk app
updateAgeSliding-renewal cadence: how stale before a request pushes the wall out1 day1 dayYou want the wall to track activity tightly (rarely needed)Renewal UPDATEs are measurably loading the session table
freshAgeFreshness window for elevation, measured from createdAt, not activity1 day10 minutesDestructive actions must re-prompt unless the user just authenticatedRe-prompts annoy users on a low-stakes surface

One more session flag is worth recognizing: disableSessionRefresh (default false) switches off the sliding renewal, making expiresIn a strict absolute expiry, which you’d only want under a compliance regime that mandates it.

The opaque token in the cookie is a bearer credential : if it leaks, the stolen copy works until it expires, and rotation shortens that window. Better Auth mints a fresh token at every sign-in, never reusing an id from before the user authenticated, which is the structural defense against session fixation . Sliding renewal can issue a new token too, retiring the old one.

This is why a finite expiresIn with an active updateAge beats one long-lived token: every renewal is a chance to rotate, where a token that never expires never does. You don’t set the rotation cadence directly; it falls out of the clocks you already chose.

The cookie carries an opaque id, nothing secret, but its attributes are a structural defense: set them right once and a whole class of attacks is closed off, not just mitigated. They live in the advanced block.

useSecureCookies. Auto-resolves to true in production and on an HTTPS baseURL, and relaxes on http://localhost, since a Secure cookie can’t be set over plain HTTP. Confirm production resolves to true.

cookiePrefix. Defaults to 'better-auth', producing better-auth.session_token; the course overrides it to '__Host-better-auth'. The __Host- prefix isn’t a Better Auth feature, it’s a contract the browser enforces: a browser stores a __Host--named cookie only if it’s Secure, has Path=/, and carries no Domain attribute. So nobody can downgrade the cookie to non-Secure or widen its scope to a sibling domain, a whole class of attacks closed off by construction. Better Auth’s helpers, getSessionCookie among them next lesson, read the prefixed name once you pass them the prefix.

defaultCookieAttributes. Three secure defaults: sameSite: 'lax', httpOnly: true, path: '/'. SameSite=Lax defends against CSRF : the browser won’t attach the cookie to a cross-site POST, so a forged request fails for lack of credentials, while a top-level navigation still carries it, so following a link from an email keeps you logged in. httpOnly hides the token from JavaScript, so document.cookie returns nothing and an XSS payload can’t exfiltrate the session. path: '/' scopes it to the whole app.

crossSubDomainCookies ({ enabled, domain }). Off by default. Flip it on only when one session must span sibling subdomains like app.example.com and admin.example.com. The catch: it’s incompatible with __Host-. Sharing across subdomains needs a Domain attribute, which __Host- forbids, a cookie-spec fact rather than a Better Auth quirk. A single-origin app takes the __Host- lock and skips subdomain sharing.

The next lesson’s proxy must read the same prefix this file writes. Declare it once as a SESSION_COOKIE_PREFIX constant, feed it to advanced.cookiePrefix, and export it. The proxy then calls getSessionCookie(req, { cookiePrefix: SESSION_COOKIE_PREFIX }), reading from that one source of truth.

Skip the export, or restate the prefix as a literal, and you get a quiet failure. getSessionCookie defaults to 'better-auth.'; point it at the wrong name and it finds nothing, so a signed-in user looks signed-out to the proxy and gets bounced to login on every protected route. Nothing throws; everyone just silently logs out. Exporting the one constant is the whole fix.

The value also needs care. Because __Host- requires Secure, the cookie can’t be set over http://localhost, so a hardcoded '__Host-better-auth' silently breaks sign-in the moment you run locally. Make the prefix environment-aware: relaxed in dev, hardened in prod.

lib/auth.ts
export const SESSION_COOKIE_PREFIX = '__Host-better-auth';

Sign-in silently fails in dev. Over http://localhost the browser drops the Secure-requiring cookie the moment the server sets it, so the user looks logged out right after logging in.

The full block below uses that constant. Tinted lines diverge from Better Auth’s defaults.

import 'server-only';
import { betterAuth } from 'better-auth';
export const SESSION_COOKIE_PREFIX =
process.env.NODE_ENV === 'production' ? '__Host-better-auth' : 'better-auth';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
plugins: [nextCookies()],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24,
freshAge: 60 * 10,
},
advanced: {
cookiePrefix: SESSION_COOKIE_PREFIX,
defaultCookieAttributes: {
sameSite: 'lax',
httpOnly: true,
path: '/',
},
},
});

The three clocks, in seconds: expiresIn 30 days, updateAge 1 day, freshAge 10 minutes, all overriding the defaults. Writing 60 * 60 * 24 * 30 keeps the unit math legible instead of a magic 2592000.

import 'server-only';
import { betterAuth } from 'better-auth';
export const SESSION_COOKIE_PREFIX =
process.env.NODE_ENV === 'production' ? '__Host-better-auth' : 'better-auth';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
plugins: [nextCookies()],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24,
freshAge: 60 * 10,
},
advanced: {
cookiePrefix: SESSION_COOKIE_PREFIX,
defaultCookieAttributes: {
sameSite: 'lax',
httpOnly: true,
path: '/',
},
},
});

The __Host- prefix, fed from the constant. The instance writes the cookie under whatever name it resolves to.

import 'server-only';
import { betterAuth } from 'better-auth';
export const SESSION_COOKIE_PREFIX =
process.env.NODE_ENV === 'production' ? '__Host-better-auth' : 'better-auth';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
plugins: [nextCookies()],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24,
freshAge: 60 * 10,
},
advanced: {
cookiePrefix: SESSION_COOKIE_PREFIX,
defaultCookieAttributes: {
sameSite: 'lax',
httpOnly: true,
path: '/',
},
},
});

The secure defaults: SameSite=Lax, httpOnly, path: '/'. useSecureCookies is left unset because it auto-resolves correctly per environment.

import 'server-only';
import { betterAuth } from 'better-auth';
export const SESSION_COOKIE_PREFIX =
process.env.NODE_ENV === 'production' ? '__Host-better-auth' : 'better-auth';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
plugins: [nextCookies()],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24,
freshAge: 60 * 10,
},
advanced: {
cookiePrefix: SESSION_COOKIE_PREFIX,
defaultCookieAttributes: {
sameSite: 'lax',
httpOnly: true,
path: '/',
},
},
});

The forward contract: the one symbol the next lesson’s proxy imports so read and write can’t drift, resolving the dev-vs-prod split in the single place both sides read.

1 / 1

Hover each cookie key for a one-line definition.

lib/auth.ts
advanced: {
cookiePrefix: SESSION_COOKIE_PREFIX,
defaultCookieAttributes: {
sameSite: 'lax',
httpOnly: true,
path: '/',
},
},
Section titled “The cookie cache: trading a DB read for staleness”

This is the chapter’s one performance reach; every other decision serves correctness and security. Identity-aware pages read the session on every load to learn who’s asking, and each read defaults to a database round-trip: many identical queries on one hot table.

Two cookies are now in play, and keeping them distinct matters. The session token cookie (the …session_token you’ve been hardening) holds the opaque id; it’s the credential, so it’s non-negotiable. The optional session data cookie, the cookie cache (a sibling …session_data), holds a signed copy of the { user, session } object. The token names you by reference; the data cookie carries the answer itself so the server can skip the lookup. The token is always there; the cache is opt-in.

Turn it on with one more session key:

lib/auth.ts
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24,
freshAge: 60 * 10,
cookieCache: { enabled: true, maxAge: 60 * 5 },
},

A session read now checks the data cookie first. Inside the maxAge window, Better Auth verifies the signature, deserializes the snapshot, and returns it with zero database round-trips; past maxAge it reads fresh from the database and refreshes the cache. The call shape is identical either way, so the caller can’t tell which path served the answer, and the next lesson teaches a single getSession shape. The signature makes the snapshot tamper-evident: a user editing their cookie to claim a different role fails the check. Encoding defaults to compact (jwt and jwe exist for interop).

The catch is delayed propagation of server-side changes. Because the cache serves a snapshot, once an admin revokes a session or a user’s email or role changes in the database, the cookie hands out the old snapshot for up to maxAge: for those five minutes a revoked user still looks valid to anything reading the cache. That one fact drives two non-negotiable rules:

  • Authorization decisions live at the action boundary, re-checked against the database, never in the proxy. The proxy gates on cookie presence only and bounces the request if no session cookie exists. Whether this user is allowed to act is answered freshly against the database when the action runs, so the action’s check is never stale even when the cache is.
  • Credential-mutating actions pass revokeOtherSessions: true. When a user changes their password or email, force every other session to re-authenticate at once rather than waiting out the cache, turning “stale for up to five minutes” into “revoked now.”

One more trap: a custom user-table field appears in the snapshot only if you declared it under additionalFields; an undeclared field keeps serving its old value after a change.

Map a route against the trade-off: cache on where a few minutes of staleness is harmless, off or with a short maxAge where revocation has to bite immediately.

Cache onCache off
Read latencySub-millisecond (verify + deserialize)A DB round-trip every read
DB load per requestNone inside maxAgeOne query per session read
Revocation latencyUp to maxAge (default 5 min)Immediate; the next read sees the truth
Best-fit surfaceRead-heavy identity-aware pages (dashboards, headers)Admin, billing, anything where stale access is unacceptable
Shorten maxAge when…Revocation latency starts to matter but you still want most reads cached
Disable when…Instant revocation is non-negotiable and you can’t rely on the action-boundary re-check

Four heavier session tools are worth recognizing by name and trigger; an early web app hasn’t hit the threshold for any of them.

secondaryStorage (Redis)

Moves session reads off Postgres into a key-value store like Upstash Redis. Trigger: thousands of reads per second where the DB lookup is a measured bottleneck. The cookie cache already closes most of that gap early on.

The jwt() plugin

Issues JWTs so a second service can verify identity without the auth database. Trigger: that second service exists. Hard rule: never swap the browser session cookie for a JWT, which loses server-side revocation.

multiSession()

Lets multiple accounts sign in to one browser, Gmail-style switching between personal@ and work@. Multiple devices is already built in (the active-sessions list, no plugin). Trigger: the product needs account-switching UX.

trustedOrigins

The CSRF allowlist; Better Auth won’t set auth cookies for origins not on it. Defaults to [baseURL], correct for a same-origin Next.js app. Trigger: a separate-domain client needs to authenticate. Hard rule: never use ['*'].

Lifetime config decides when a session ends on its own; sign-out ends one on demand, and it’s the payoff of the server-stored token.

auth.api.signOut deletes the session row and clears the cookie through the nextCookies plugin. Once the row is gone, any lingering cookie is harmless: it points at a row that no longer exists. A self-contained JWT can’t do this, since the server is never consulted, so it stays valid until it expires. The row is the source of truth, and deleting it is the truth changing.

“Sign out everywhere” fans the same move out: delete every session row for the user, so each device goes cold on its next read. Its UI comes in the account-security chapter. The clocks decide when sessions end on their own; sign-out and revocation decide that they can be ended deliberately. Both rest on the row as source of truth.

Configuration mistakes to check before shipping

Section titled “Configuration mistakes to check before shipping”
  • expiresIn set to a year or “forever” for a frictionless login. It never rotates, so one leaked cookie stays valid indefinitely. Set a finite expiry and lean on updateAge to keep active users in.
  • The cookie cache left on for a destructive-action surface. Its up-to-maxAge staleness collides with the freshAge check and with revocation. Force a fresh read at the action boundary and pass revokeOtherSessions: true on credential mutations.
  • crossSubDomainCookies enabled while __Host- is configured. The browser silently rejects the cookie, a cookie-spec conflict no log will show. Pick one.
  • trustedOrigins: ['*'] to clear a CORS error. List the specific origin, never the wildcard.
  • Shipping with cookiePrefix: 'better-auth'. You lose the __Host- defense, free protection left on the table.
  • Expecting the cookie cache to update the instant a user’s data changes. Email, profile, role, and org changes appear only after a session refresh or a maxAge-bounded wait.
  • Forgetting to export SESSION_COOKIE_PREFIX, or hardcoding the literal in the proxy. The two drift, getSessionCookie reads the wrong name, and signed-in users get bounced as signed out.

Now check the facts most likely to trip you up.

Each claim is about the session and cookie config you just walked through. Mark each statement True or False.

A __Host--prefixed cookie can be set over http://localhost.

False. __Host- requires the Secure flag, and Secure cookies won’t set over plain HTTP — which is exactly why dev relaxes the prefix to plain 'better-auth'.

Turning on the cookie cache means a revoked session is rejected on the very next request.

False. The cache serves a signed snapshot for up to maxAge (default 5 minutes), so a revoked session can still look valid until it expires. For instant revocation, re-check at the action boundary against the database and pass revokeOtherSessions: true on credential mutations.

freshAge is measured from the user’s last activity.

False. It’s measured from createdAt — when the session was created. A user active all day still has a non-fresh session if they signed in this morning, so a destructive action would re-prompt for their password.

The defaults this lesson locks in have a fuller options surface behind them, and the cookie rules they lean on are browser specs, not Better Auth inventions. These four references go a layer deeper on each.