albertonline.org docs
Learn

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:

  1. The server instance (auth-instance.ts) — the single betterAuth(...) object: trusted origins, DB adapter, session cookie cache, and plugins.
  2. The session resolver (request.ts) — the only blessed way to read a session, wrapped in a Supavisor-pooler retry.
  3. The Effect-wrapped server (effect.ts) — AuthService, the typed-error Effect front door for RPC handlers.
  4. The client (client.ts) — the browser-side authClient.

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".

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 actual auth.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's cause chain.
  • If the error is connection-class and we're under the budget, we back off (100ms, 200ms, 400ms — exponential via base * 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 — passes disableCookieCache: 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):

  1. A Server Component / layout calls getSession() (or the RPC handler yields AuthService.getSession()).
  2. That reads headers() from next/headers and calls getSessionFromHeaders(headersList).
  3. getSessionFromHeaders invokes auth.api.getSession({ headers }) inside retrySessionLookup.
  4. Better Auth verifies the signed session cookie. Cache hit → returns the session with no DB query. Cache miss / expired → queries the session + user tables through the Supavisor pooler.
  5. If the pooler drops the connection, retrySessionLookup backs off and retries (up to 3 times); a fresh connection heals it. A deterministic failure rethrows immediately.
  6. The caller gets { user, session } | null. Role gates re-run steps 3–5 via getFreshSessionFromHeaders to 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 patterns AuthService is built on.

On this page