Authentication (Better Auth)
How Better Auth is wired here — the server instance, the Effect-wrapped AuthService, the client, and the retry-guarded session lookup.
Authentication in this repo is Better Auth, not
NextAuth. It lives in @repo/auth (packages/auth/src). There are four moving
parts, and the whole point of this guide is to show how they fit together and,
more importantly, the one rule that keeps authed pages from flaking under
load:
- The server instance (
auth-instance.ts) — the singlebetterAuth(...)object: trusted origins, DB adapter, session cookie cache, and plugins. - The session resolver (
request.ts) — the only blessed way to read a session, wrapped in a Supavisor-pooler retry. - The Effect-wrapped server (
effect.ts) —AuthService, the typed-error Effect front door for RPC handlers. - The client (
client.ts) — the browser-sideauthClient.
1. The server instance
Everything starts from one call to betterAuth(...) in
packages/auth/src/auth-instance.ts. This is the object that owns cookies,
sessions, the DB adapter, and every plugin.
export const auth: Auth<BetterAuthOptions> = betterAuth(authOptions);The interesting parts of authOptions are trustedOrigins, session, and
plugins.
Trusted origins
Better Auth rejects any request whose Origin isn't in this allow-list — that
is the CSRF boundary. It's built from an env-driven CORS_ORIGIN plus the known
prod/native origins:
const trustedOrigins = [
authEnv.CORS_ORIGIN,
"https://www.albertonline.org",
"https://albertonline.org",
"https://it-f-store.albertonline.org",
"mybettertapp://",
"exp://",
].filter((origin): origin is string => typeof origin === "string" && origin.trim().length > 0);mybettertapp:// and exp:// are the Expo native app's custom schemes — the
mobile app authenticates against the same instance. The .filter(...) drops
CORS_ORIGIN when it's unset, so an empty env var never becomes a trusted
empty-string origin.
There is no localhost here. That's why running the web app against prod Supabase needs pnpm dev:web:local-prod, which overrides CORS_ORIGIN to http://localhost:3001 at launch —
otherwise a localhost browser is rejected as an untrusted origin. See the root CLAUDE.md "Dev
against prod Supabase".
The session cookie cache
This is the single most load-bearing option in the file. Sessions are validated from a short-lived signed cookie instead of a DB round-trip per request:
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60,
},
},Why it matters: without it, every authenticated request — and the plan board
fires four parallel RPCs — runs a user-table query just to resolve who you
are. That query volume is the dominant load that exhausts the Supavisor session
pool. The cost of the cache is staleness: a role or ban change only propagates
after at most maxAge (5 minutes). That trade-off is why there's a separate
"fresh" read path (section 3) for privilege checks.
Plugins
plugins: [
admin({ ac, roles: { admin: adminRole, user: customerRole }, defaultRole: "user" }),
polar({ client: polarClient, /* checkout + portal */ }),
expo(),
// nextCookies must stay LAST: it re-emits Set-Cookie via Next's cookies()
// after every other plugin has finished mutating the response.
nextCookies(),
],Each plugin bolts capabilities onto the same instance: admin gives you
roles + access-control (this is what powers the admin-only routes), polar
wires billing/checkout, expo supports the native client. The DB is a
drizzleAdapter(db, { provider: "pg", schema: { account, session, user, verification } })
— Better Auth reads and writes those four Drizzle tables from
@repo/db/schema/auth.
nextCookies() must be last in the array. It re-emits Set-Cookie through Next's cookies()
after every other plugin has mutated the response — put a plugin after it and that plugin's cookie
writes get dropped.
2. The rule: never call auth.api.getSession(...) directly
Better Auth exposes auth.api.getSession({ headers }) to resolve a session.
Do not call it directly anywhere. Always go through the blessed wrapper
getSessionFromHeaders in packages/auth/src/request.ts.
Here's the whole reason it exists — the retry:
export const retrySessionLookup = async <A>(run: () => Promise<A>): Promise<A> => {
const attempt = async (n: number): Promise<A> => {
try {
return await run();
} catch (error) {
if (!isConnectionError(error) || n >= SESSION_RETRY.times) {
throw error;
}
await sleep(SESSION_RETRY.base * SESSION_RETRY.factor ** n);
return attempt(n + 1);
}
};
return attempt(0);
};Walking it line by line:
run()is the actualauth.api.getSession(...)call. It validates the signed cookie, and on a cache miss it hits the DB — over the Supavisor pooler.- The pooler recycles backends and can drop a socket mid-call. When it does,
the promise rejects —
ECONNRESET,socket hang up,Connection terminated, and friends.isConnectionError(from@repo/db/effect/connection-retry) pattern-matches exactly those recoverable messages by walking the error'scausechain. - If the error is connection-class and we're under the budget, we back off
(
100ms,200ms,400ms— exponential viabase * factor ** n) and retry. Re-acquiring drops the dead connection and gets a fresh one, which heals it. - Anything not connection-class — a bad cookie signature, a decode error — is deterministic: it fails identically on retry, so it's rethrown immediately. Same for an exhausted budget. The error channel is unchanged: the caller sees the same error it always would.
The budget is a plain-ms mirror of the Effect-level DB retry, kept in lock-step on purpose:
export const SESSION_RETRY = {
base: 100,
factor: 2,
times: 3,
} as const;The public wrapper is a one-liner over it:
export const getSessionFromHeaders = (headers: Headers) =>
retrySessionLookup(() => auth.api.getSession({ headers }));Why "never call it directly" is the rule, not a suggestion
The (auth) layout resolves the session during SSR on every authed page. A raw
auth.api.getSession call there means a single pooler blip throws straight out
of SSR and 500s the page. The wrapper turns that flaky 500 into a
transparent 100ms retry. Skip the wrapper and you reintroduce the exact class of
flake it was written to kill.
This is enforced by a guard script — check:no-raw-getsession
(scripts/check-no-raw-getsession.mjs), wired into ci:precheck. It greps apps/ and
packages/ for auth.api.getSession( and fails CI on any hit outside the exemptions
(request.ts itself, apps/juicy-shop, test files). A plain oxlint rule can't express this: the
framework's banMember only matches Identifier.prop, but auth.api.getSession is a three-level
member whose object (auth.api) is itself a MemberExpression — so the lint rule never fires and
the script is the fallback. It strips comments and string literals first, so only real call sites
trip it.
3. Cached vs fresh sessions
request.ts gives you two read paths, and choosing correctly is a security
decision:
export const getFreshSessionFromHeaders = (headers: Headers) =>
retrySessionLookup(() => auth.api.getSession({ headers, query: { disableCookieCache: true } }));getSessionFromHeaders— uses the cookie cache. Cheap, no DB query on a cache hit. Use it for everything non-sensitive (rendering the dashboard, reading the current user's id).getFreshSessionFromHeaders— passesdisableCookieCache: true, so the read always reflects the live DB row.
Role gates use the fresh path deliberately:
export const hasRoleFromHeaders = async (headers: Headers, role: string) => {
// Always read fresh: role gates (admin pages, the MCP/RPC admin checks) must
// see privilege/ban changes immediately, not after the cookie-cache window.
const session = await getFreshSessionFromHeaders(headers);
const user = session?.user;
if (user === null || user === undefined) return false;
return "role" in user && user.role === role;
};The cache/fresh split is the answer to "won't a just-revoked admin still get through?"
Non-sensitive reads accept up to maxAge of staleness for the performance win; anything that
gates a privilege bypasses the cache so a demotion or ban is effective immediately.
4. The Effect-wrapped server: AuthService
Effect-capable call sites (RPC handlers, anything already running in an Effect)
should not touch getSessionFromHeaders directly — they use AuthService from
packages/auth/src/effect.ts. Its job is to turn the rejectable promise into a
typed error channel.
const getSession = () =>
Effect.tryPromise({
try: async () => {
const headersList = await headers();
return getSessionFromHeaders(headersList);
},
catch: (cause): SessionLookupError =>
new SessionLookupError({
message: cause instanceof Error ? cause.message : "session lookup failed",
cause,
}),
});The critical choice is Effect.tryPromise, not Effect.promise.
getSessionFromHeaders can reject (deterministic failures, or a connection
error that exhausted the retry budget). Effect.promise would convert any
rejection into an uncaught Die — bypassing the typed error channel entirely,
so a route handler's catchTag/catchAll would never see it. tryPromise's
catch maps the rejection to a typed SessionLookupError (a Fail), keeping
the error channel sound.
The errors are defined with Data.TaggedError (in-memory tagged errors):
export class NotAuthenticatedError extends Data.TaggedError("NotAuthenticatedError")<{
readonly message: string;
}> {}
export class SessionLookupError extends Data.TaggedError("SessionLookupError")<{
readonly cause: unknown;
readonly message: string;
}> {}These are distinct on purpose. A missing session is not an error — it's the
normal "logged out" state, surfaced as null from getSession (and as
NotAuthenticatedError from requireUser). SessionLookupError means the
lookup itself broke — so a handler can Effect.catchTag("SessionLookupError", …) to return a 500, while treating "not logged in" as a 401.
requireUser and requireRole build the guard ergonomics on top:
const requireUser = () =>
Effect.gen(function* () {
const user = yield* getUser();
if (!user) {
return yield* new NotAuthenticatedError({ message: "Not authenticated" });
}
return user;
});Note the return types are hand-declared as Effect.Effect<User, NotAuthenticatedError | SessionLookupError> etc. — the failure channel is
explicit, so callers know exactly which tags they must handle. The service
follows the house Context.Service shape with a static layer:
export class AuthService extends Context.Service<AuthService>()("@repo/auth/AuthService", {
make: Effect.succeed(/* … the object above … */),
}) {
static readonly layer = Layer.effect(this, this.make);
}5. The client
The browser side is small — packages/auth/src/client.ts builds one
authClient with the matching plugins:
export const authClient = createAuthClient({
plugins: [inferAdditionalFields({ user: additionalUserFields }), polarClient(), adminClient()],
});inferAdditionalFields re-uses the same additionalUserFields as the server so
the client's user type stays in sync; polarClient() and adminClient() mirror
the server's billing and admin plugins. Most of the file is actually a
defensive shim: it pre-seeds Better Auth's cross-tab broadcast channel with a
try/catch-wrapped localStorage wrapper, because Chrome throws (and logs a
console error) when storage is blocked in strict-privacy contexts. If storage is
available, cross-tab session sync works; if blocked, it degrades silently
instead of erroring.
6. The session flow for one request
Putting it together, here's what happens when an authed page renders on the
server (packages/auth/src/next.ts / server.ts):
- A Server Component / layout calls
getSession()(or the RPC handler yieldsAuthService.getSession()). - That reads
headers()fromnext/headersand callsgetSessionFromHeaders(headersList). getSessionFromHeadersinvokesauth.api.getSession({ headers })insideretrySessionLookup.- Better Auth verifies the signed session cookie. Cache hit → returns the
session with no DB query. Cache miss / expired → queries the
session+usertables through the Supavisor pooler. - If the pooler drops the connection,
retrySessionLookupbacks off and retries (up to 3 times); a fresh connection heals it. A deterministic failure rethrows immediately. - The caller gets
{ user, session } | null. Role gates re-run steps 3–5 viagetFreshSessionFromHeadersto bypass the cache.
The thin Next.js entry points just marshal headers() and delegate — e.g.
server.ts:
export async function getSession() {
const headersList = await headers();
return getSessionFromHeaders(headersList);
}Two front doors, one resolver: Promise-shaped code (a feature-flag decide() callback that
can't host an Effect) uses getSession() from ./next or ./server; Effect-shaped code uses
AuthService. Both bottom out in getSessionFromHeaders, so the pooler retry and the
no-raw-getSession rule apply everywhere.
See also
- Request flow — how a request travels from the edge through SSR and the RPC layer.
- Effect v4 — the
Context.Service+ typed-error patternsAuthServiceis built on.
How a request flows
One real request traced end to end — RPC client, Next route, session, handler, Effect repo, Postgres RLS, and the typed result home.
Database & drizzle-kit
How the data layer works — Drizzle schema, RLS, the Effect DB layer — and the drizzle-kit generate/migrate/push workflow and its production traps.