Better Auth cookie cache: getSession with zero database queries, and the five minutes a revoked session keeps working
The Better Auth cookie cache cut getSession from 2 SQL queries to 0 on Postgres. The catch: a revoked session keeps working for up to 5 minutes.
Every chat request in the kit starts with auth.api.getSession, and on Better Auth 1.6.18 that is two SQL queries: the session row by token, then the user row. On the chat route they are half of the four queries that run before the model sees a word. The Better Auth cookie cache answers getSession from a signed cookie instead, with no database at all, and the kit does not turn it on. I measured both configurations on a throwaway Postgres, including what the cache does to a session you have revoked, because that is the part the one-line config hides.
What getSession costs on every request
The check that decides between the cookie and the database is near the top of the getSession endpoint, in api/routes/session.mjs. Simplified:
if (sessionDataPayload?.session && options.session?.cookieCache?.enabled && !ctx.query?.disableCookieCache) {
// signature, version and expiry checks...
return ctx.json({ session, user }); // answered from the cookie
}
const session = await ctx.context.internalAdapter.findSession(sessionCookieToken);
To count what that costs, I ran the kit's production build against Postgres 16 with log_statement = 'all', signed up through /api/auth/sign-up/email, and counted the statements each request produced. The model call pointed at a closed port, so nothing reached Anthropic.
GET /api/auth/get-session: 2 statements on every call, aselectfromsessionand aselectfromuser.POST /api/chat: 4 statements before the model call: the same two, then the plan lookup onsubscriptionsand the monthly count onusage_events.
On a serverless deploy each of those is a round trip to the database, paid on every message a user sends.
Turning on the Better Auth cookie cache
The option lives under session in the betterAuth config:
export const auth = betterAuth({
database: drizzleAdapter(db(), { provider: "pg", schema: { user, session, account, verification } }),
session: {
cookieCache: { enabled: true, maxAge: 5 * 60 }, // 300 s is also the default
},
emailAndPassword: { enabled: true, requireEmailVerification: false },
plugins: [nextCookies()],
});
With that one change, sign-up set a second cookie, better-auth.session_data, next to the session token. Then:
GET /api/auth/get-session: 0 statements, on every call I made.POST /api/chat: 2 statements, the plan and usage queries only.
The plan is still read from subscriptions on every request, so an upgrade, downgrade or cancellation applies on the next message. Only who the caller is comes from the cookie.
The tradeoff, measured: sign-out does not reach a copied cookie
A cached session is valid because the cookie says so, not because the database still has the row. So I tested the two cases where those disagree.
- Sign-out, then replay. I copied the browser's cookies, called
/api/auth/sign-out(200, and the browser's cookies were cleared), then sent the copy.get-sessionanswered signed in, with 0 statements. The same copy with?disableCookieCache=truecame backnullafter one query found no session. - Revoked from another device. Device A and device B signed in as the same user, then B called
/api/auth/revoke-other-sessions. A's nextget-sessionstill answered signed in, with 0 statements. With?disableCookieCache=trueit wasnull.
Both windows closed on schedule. About four minutes after the sign-out and the revocation, both cookies still answered signed in. About five and a half minutes after, past the 300-second maxAge, both came back null: the cached copy had expired, so Better Auth went to the database and found no session.
In the kit's chat route that window is not cosmetic: the route takes session.user.id from getSession, so a revoked device keeps sending messages as that user, on that user's plan and quota, until its cookie expires. That is usually fine for a chat app, and it is the same window a short-lived JWT gives you. It is not fine for the requests where "signed out" has to mean signed out now.
Things that bite
- Skip the cache where it matters.
getSessionaccepts a query flag, so sensitive routes can ask the database:auth.api.getSession({ headers, query: { disableCookieCache: true } })type-checks against 1.6.18. Use it for billing, account deletion and password changes. - The default cookie is signed, not encrypted. The
compactstrategy is base64-encoded JSON plus an HMAC. Decoded, the 875-byte cookie held the session token, IP address, user agent, and the user's name and email. It ishttpOnly, so page scripts cannot read it, but it rides on every request to your domain.strategy: "jwe"encrypts it. - There is a kill switch.
cookieCache.versionis compared on every read, and a mismatch expires the cookie and falls through to the database. Bumping it is how you log every cached session back out to the source of truth, say after a security incident. - Do not copy
refreshCachefrom stateless examples. With a database configured, 1.6.18 turns it off and logs a warning; it is meant for setups that have no session table.
The cache does not change how Better Auth rate-limits sign-in, and it is one more setting in the list of Better Auth setup details on Next.js 16 worth deciding on purpose rather than by default.
Shipwright, the Next.js and Claude starter kit this blog documents, ships Better Auth with database sessions and the plan and quota checks wired into the chat route, so a choice like this is one config block. You can try the chat in the live demo.